# From Source Code to Executable

> Source: https://www.ymotongpoo.com/books/go-ebpf-primer/15-source_to_binary/


Chapter 2 said that instructions sit in memory and have addresses. The executable file is where that sequence of instructions comes from. This chapter follows your source code through compilation and linking, and then opens the finished file.

Three things carry into the hurdle chapters. `go tool objdump` turns machine code back into a form you can read. `go tool nm` shows you the table that maps names to addresses. The third is knowing how an executable divides its own contents.

## From source to machine code

When you build Go source code, you get a binary that holds a sequence of machine-code instructions. **Machine code** is the byte sequence that represents the smallest units of direction a CPU can act on, which Chapter 2 covered. The instructions sit in order from the start of the sequence, and each one has an address. Execution is the repeated cycle of fetching the instruction at the address that `PC` points to, executing it, and advancing `PC`.

Somewhere in this transformation, the function that you wrote in `main.go` becomes a sequence of instructions plus the address that holds them. The world of machine code does not, as a rule, carry over the names of your functions and variables.

![From source to machine code, and symbols](20260911-source-to-machine.png)
*Figure 1: Solid arrows show the direction of the transformation. The dashed arrow shows the symbol table looking up an address from a name, which is a reference rather than a transformation.*

## Disassembly

Turning a sequence of machine-code bytes back into instruction notation that a person can read is **disassembly**. Go ships `go tool objdump` for the job.

Assembly is about to appear, so let me say this first. You do not need to write assembly for anything in this book. When disassembly output appears, read the lines that carry an arrow or an annotation, and skip the rest.

A small function makes a good example. `//go:noinline` tells the compiler to leave the function as a call instead of expanding it, and Chapter 5 explains why that matters.

* [Open in the Go Playground](https://go.dev/play/p/h4yfSjSkiNd)

```go
package main

import "fmt"

//go:noinline
func double(n int) int {
	return n * 2
}

func main() {
	fmt.Println(double(21))
}
```

Build it and disassemble `main.double`. The output below comes from a binary built with `GOOS=linux GOARCH=amd64`. Your machine may run macOS or Windows, and these two environment variables still let you observe the same thing.

```
$ GOOS=linux GOARCH=amd64 go build -o demo main.go
$ go tool objdump -s 'main\.double$' demo
TEXT main.double(SB) .../main.go
  main.go:7   0x49e180   4801c0   ADDQ AX, AX   ← computes n*2
  main.go:7   0x49e183   c3       RET           ← returns from the function
```

The columns, from left to right, are the source line number, the address of the instruction, the machine-code bytes, and the readable instruction notation. The single Go line `n * 2` became one instruction, `ADDQ AX, AX`, which adds `AX` to itself and thus doubles it. `RET` returns from the function.

Look at the column of bytes. `4801c0` is three bytes and `c3` is one byte, so the instructions do not share a length. The byte sequence itself also carries no marker that says where one instruction ends and the next begins. A disassembler has no choice but to interpret one instruction at a time, from the start. This property returns in Hurdle 1, as real OBI code.

![The byte sequence contains no instruction boundaries](20260911-disassemble-boundaries.png)
*Figure 2: The arrow shows the direction in which interpretation proceeds. The same four bytes separate into a three-byte instruction and a one-byte instruction only once you interpret them in order from the start. Start reading from a byte in the middle and you get a different interpretation.*

## The symbol table

Names do not reach machine code, but the binary carries a separate table that maps names to addresses. That table is the **symbol table**. It holds the fact from the output above, that `main.double` starts at address `0x49e180`, and `go tool nm` shows it to you.

```
$ go tool nm demo | grep 'main\.double'
  49e180 T main.double
```

This table is also why you can hand a function name to `go tool objdump` and disassemble only that function. When you want to place a hook on a function entry from outside, this table gives you the address from the name.

## Static linking and dynamic linking

The compiler works on one component of the source at a time, which in Go means one package at a time. **Linking** is the step that joins the machine code from each component into one executable and fixes the final address of each function. The program that does the work is the **linker**.

Two methods join libraries to a program. **Static linking** pulls the machine code of every library you use into the executable whole. **Dynamic linking** leaves each library in a separate file, called a **shared library**, and binds it at run time. The typical C program uses the second method. The machine code of the standard C library (libc) sits outside the executable, and its addresses become fixed at run time.

The Go linker chooses static linking unless you use cgo. On Linux with a C toolchain installed, however, `net` and `os/user` pick their cgo-based name-resolution implementations by default, so a program that uses `net/http` links dynamically. `CGO_ENABLED=0` switches them to the pure Go implementations and removes the dependency on shared libraries. Either way, the property that matters to the side that instruments the program does not change. Chapter 6 covers what that property is.

![A statically linked Go binary versus dynamic linking](20260911-static-link.png)
*Figure 3: Arrows show the direction of a dependency. A C process resolves its dependencies on shared libraries at run time, and a Go binary has no such step.*

## ELF sections

An executable file on Linux uses a format called **ELF**. The file divides into sections: machine code goes into `.text`, constants into `.rodata`, and the symbol table from the previous section into `.symtab`.

A Go binary carries two more things that belong to Go. One is `.gopclntab`, a table for looking up a function name and a line number from an address. The other is a build-info blob that records the Go version and the list of dependency modules. The Go runtime reads `.gopclntab` itself, to build a stack trace on panic.

![What's inside an ELF binary, and who reads it](20260911-elf-sections.png)
*Figure 4: A table of who needs which section. Execution itself needs `.text`, `.rodata`, `.data`, `.bss`, and `.gopclntab`. The ones that exist only for whoever reads the file are `.symtab`, DWARF, and the build info.*

## How an executable gets loaded into memory

What Chapter 3 called the text segment is the `.text` section from the previous section. `.text` therefore exists in two places: inside the file on disk, and in the memory of the running process.

You might expect the kernel to copy the whole file into physical memory at startup, and it does not. All the kernel does at startup is write a note in the mapping table from Chapter 3. The note says which position in the executable to read when something references a given range of virtual addresses. The table works in fixed-size units called **pages**, normally 4KB on Linux. A page reaches physical memory at the moment the CPU tries to execute an instruction on it[^demand-paging]. A huge executable therefore starts running right away.

[^demand-paging]: Loading pages in the order that the process touches them is called demand paging. The page fault is the name for the event where the CPU tells the kernel that a referenced page is not yet in physical memory.

Sharing is another result of this scheme. In the Chapter 3 experiment, the two processes started from one executable (PIDs 25 and 31) had independent virtual address spaces. Their `.text` pages can still share one copy in physical memory. Independent virtual addresses and a shared physical page do not contradict each other. However many copies of a program you start, the `.text` pages cost one copy of physical memory[^page-cache].

[^page-cache]: The place that manages pages read from files in physical memory is called the page cache. What the processes share, to be exact, are pages in this page cache.

![Two processes sharing the same physical page](20260911-text-page-sharing.png)
*Figure 5: Solid arrows show where the mapping tables point, and the dashed arrow shows a read from the file. The virtual addresses of the two processes are independent, and the `.text` entries in their mapping tables can still point at the same physical page.*

Sharing works because `.text` pages are read-only. The mapping table records the physical page and a permission alongside it: reading and executing allowed, writing not. A write by one sharer would change execution for every sharer, so this permission is what holds the sharing together. If a program tries to overwrite its own machine code, the kernel stops it on the spot.

Some tools still want to write. A debugger, for one, edits the machine code of a running process to plant breakpoints. The kernel handles this with **copy-on-write**. It leaves the shared page alone and makes a private copy of the single page to be changed. It writes to the copy, then points the mapping table of that process at the copy. Neither the file on disk nor the original shared page changes.

![Page replacement via copy-on-write](20260911-cow-page-swap.png)
*Figure 6: Solid arrows show where the mapping tables point, and the dashed arrow shows the copy being made. Only the mapping table of the process that wrote now points at the copy, and the original page and the file on disk are unchanged.*

Injecting an observation point into a running program from outside also happens on top of this mechanism. Chapter 7 covers the details.

## DWARF

Chapter 2 ended with the point that struct field names disappear after compilation, and yet a debugger still shows `r.count` by name. It can do so because the compiler writes debug information called **DWARF** into the ELF sections whose names start with `.debug_`[^symtab-dwarf].

[^symtab-dwarf]: Two separate stores record names because their roles and their sizes differ. The symbol table is a small table of names and addresses, and it is the part of ELF that the linker needs to join components. DWARF describes types, variables, scopes, and line numbers. It is a standard of its own, separate from ELF, and it exists for debuggers. Neither execution nor linking needs it, and it is far larger in exchange. Being separate, the two can be dropped separately. `-w` in the Go linker drops DWARF and keeps the symbol table, while `-s` implies `-w` unless you say otherwise. DWARF holds type definitions, field names and offsets, and the mapping from instruction addresses to source lines. A debugger reads this table to match the world of byte sequences against the world of source code.

![DWARF is the table that reconciles source with memory](20260911-dwarf-field-mapping.png)
*Figure 7: Arrows show references. Running memory holds nothing but a byte sequence. DWARF records how many bytes from the start each field of each type sits at, so the debugger can show that field by name.*

What is useful to a debugger is useful to an instrumentation tool that looks in from outside. Such a tool reads `.gopclntab` or the symbol table to learn where the functions are. It reads DWARF to learn where the fields are, and the build info to learn the version.

The program itself runs without DWARF and without the symbol table. Building with `-ldflags="-s -w"` drops `.symtab` and DWARF, and the file gets smaller. Chapter 14 checks what that does to instrumentation.

## Key points for the hurdles

- A machine-code byte sequence carries no instruction boundaries, and the only way to interpret it is in order from the start.
- You can find the address of a function from its name through the symbol table or `.gopclntab`.
- The mapping from field names to offsets survives in DWARF and also in the type information for reflection. `-s -w` erases only the DWARF copy.
- Machine-code pages are read-only and shared between processes. A write swaps in a copy that is private to one process, which is copy-on-write.

## Exercises

1. Build any Go program. Disassemble `main.main` with `go tool objdump -s 'main\.main$'` and count the `RET` instructions.
2. How many of the functions that you wrote, meaning the symbols that start with `main.`, appear in the output of `go tool nm`? If a small function that you know you wrote is missing, what do you think happened? (Hint: Chapter 5 covers it.)

{{< details "Answer" >}}
1. The count depends on the program, and one is not the only possible answer. Hurdle 1 covers why all of the `RET` instructions matter when there are several.
2. The compiler sometimes inlines a small function, and the call disappears with it. An inlined function has no machine code of its own, so it does not appear in the symbol table either. Chapter 5 looks at this in detail.
{{< /details >}}

