How Computers Run Programs

Originally published in Japanese at https://zenn.dev/ymotongpoo/books/go-ebpf-primer/viewer/05-computer.

This chapter through Chapter 6 forms the background part of the book. The difficulties of eBPF instrumentation arise from the details of how programs run. This chapter connects memory, the CPU, registers, and pointers through the layout of a Go struct. The explanation assumes only basic Go syntax.

Each section includes a short Go program. Reading alone lets you follow along, but running the code yourself turns every hurdle-chapter experiment into something you’ve already tried here.

Memory and addresses

Inside a computer, all information is represented as bits, each either 0 or 1. Eight bits grouped together make a byte, and one byte can represent 256 different values, from 0 to 255.

Memory is a huge shelf of these bytes lined up in a row. Every slot on the shelf carries a serial number, and this number is called an address. The CPU reads and writes memory by asking for “the byte at address such-and-such.” Memory has no mechanism for referring to things by name. All it has is numbers.

A single byte can only represent 0 through 255, so larger values use several consecutive bytes. Go’s int occupies 8 consecutive slots on the shelf.

Memory is a row of bytes with addresses Figure 1: The band at the top is memory; each cell is one byte. The numbers below the cells are addresses. An 8-byte int variable occupies 8 consecutive cells, and the variable’s “address” means the address of its first cell.

You can verify this from Go. unsafe.Sizeof gives the number of bytes a value occupies, and %p in fmt.Printf prints a variable’s address.

package main

import (
	"fmt"
	"unsafe"
)

func main() {
	var b byte = 200
	var n int = 300
	fmt.Println(unsafe.Sizeof(b), unsafe.Sizeof(n))
	fmt.Printf("address of b: %p\n", &b)
	fmt.Printf("address of n: %p\n", &n)
}
1 8
address of b: 0x3dcdc86d8078
address of n: 0x3dcdc86d8070

byte is 1 byte and int is 8 bytes. A value like 0x3dcdc86d8078, printed as an address, changes on every run. The individual value does not matter; an address is a number.

This notation starting with 0x is hexadecimal: a way of writing numbers using 16 characters, the digits 0–9 followed by a–f. Two digits represent exactly one byte (256 values), so addresses and memory contents are commonly written in hexadecimal. Numbers like 0x49e1c0 recur throughout this book, but none of the examples require hexadecimal arithmetic. Read each one as an address written in hex.

Why hexadecimal rather than decimal? A decimal digit does not line up with bit boundaries, so you cannot read off, say, “the low 8 bits of this value” at a glance. In hexadecimal, one digit corresponds neatly to 4 bits and two digits to one byte, so you can read a number byte by byte. In 0x3dcdc86d8078, the last two digits, 78, are the final byte.

Executing instructions and the PC

The body of a program is a sequence of instructions. An instruction is the smallest unit of direction a CPU can act on, such as “add these two values” or “read the value at this address.”

Instructions themselves are placed in memory, just like data. The sequence of instructions sits in memory, and each individual instruction has an address. This point matters throughout the book: an operation such as “hook this function” ultimately means “do something at this instruction address.”

The CPU’s job is a simple loop. Remember the address of the instruction to execute now, fetch the instruction from that address, decode its meaning, and execute it. When done, advance to the address of the next instruction and repeat. The place that remembers “the address of the instruction to execute now” is called the PC (program counter).

The CPU executes instructions one at a time Figure 2: Time flows from top to bottom. The CPU fetches and executes the instruction at the address the PC points to, and the PC advances to the address of the next instruction. A jump instruction works by rewriting the PC to a different address.

Chapter 4 shows actual instructions through disassembly. The model for now is that instructions occupy memory and the PC points to the current one.

Registers

The CPU does not compute on values in memory where they sit. It first loads a value into a small container inside the CPU, computes there, and writes the result back if needed. These containers are registers. There are 16 general-purpose registers used for computation, each 64 bits (8 bytes) wide. They are orders of magnitude faster than memory, but there are far fewer of them (there are other register banks too, such as those for floating-point operations, but they do not appear in this book).

The CPU this book deals with is amd64 (the 64-bit x86 family), whose general-purpose registers have names like RAX, RBX, and RCX. There are also two registers with special roles. One is the PC from the previous section; the other is SP, which points to the top of the stack (a region for function calls, covered in Chapters 3 and 5). (Their official names are RIP and RSP, but I follow the notation used by Go’s toolchain.)

One more note on notation. CPU manuals and eBPF-side documents write the 64-bit register as RAX, while Go’s tools write the same thing as AX. Both notations appear in the output later in the book, but they refer to the same thing.

The CPU and registers Figure 3: Arrows show the direction data moves. Computation happens on the registers; memory is read and written only when needed.

“Passing function arguments in registers” comes up in Hurdle 2. The caller puts values into registers such as RAX and RBX, and the callee reads them there. Skipping memory makes the call faster and hides the arguments from an outside observer.

Pointers

An address is just a number, so you can put it in a variable and carry it around. A variable that holds an address as its value is a pointer.

In Go, &x takes the address of a variable x, and *p reads the value that a pointer p points to. Going further, unsafe.Pointer and uintptr let you extract an address as a plain integer.

package main

import (
	"fmt"
	"unsafe"
)

func main() {
	x := 42
	p := &x
	fmt.Printf("value of x: %d\n", x)
	fmt.Printf("address of x: %p\n", p)
	fmt.Printf("value p points to: %d\n", *p)
	fmt.Printf("the address as a plain number: %d\n", uintptr(unsafe.Pointer(p)))
}
value of x: 42
address of x: 0x18eef8586008
value p points to: 42
the address as a plain number: 27414647824392

The 27414647824392 on the last line is the same number as 0x18eef8586008 above it, written in decimal. A pointer “points” to something when the address it stores is the address of that object.

A pointer holds an address as a value Figure 4: The arrow represents a reference. The variable p holds an address. When it matches the address of the cell containing x, we say “p points to x.”

You do not normally need unsafe.Pointer or uintptr in application code. This book uses them only to print addresses as numbers and compare them. In Hurdle 1, they let us catch the moment a variable’s address changes while the program runs.

How a struct is laid out in memory

A struct stores its fields in declaration order, though alignment constraints can insert padding between them. An 8-byte value, for example, may need to start at an address that is a multiple of 8.

The number of bytes from the start of the struct to a field is called that field’s field offset. In Go you can get it with unsafe.Offsetof.

package main

import (
	"fmt"
	"unsafe"
)

type record struct {
	flag  bool
	count int64
	id    int32
}

func main() {
	var r record
	fmt.Println("flag :", unsafe.Offsetof(r.flag))
	fmt.Println("count:", unsafe.Offsetof(r.count))
	fmt.Println("id   :", unsafe.Offsetof(r.id))
	fmt.Println("total:", unsafe.Sizeof(r))
}
flag : 0
count: 8
id   : 16
total: 24

flag is only 1 byte, and yet count starts at 8, not 1. To place the 8-byte int64 at an address that is a multiple of 8, the 7 bytes in between became padding. There is padding at the end too, rounding the struct’s total size up to a multiple of 8.

Field layout and padding Figure 5: This figure has no arrows. The 24 bytes of the record struct are shown as cells. The padding cells between fields and at the end hold no field’s value.

Field names do not survive compilation. Memory holds bytes and addresses, while r.count exists only in source code. An outside observer needs the field’s byte offset from the start of the struct.

The struct in source, the bytes in memory Figure 6: Arrows show the direction of transformation. The field names in the source on the left do not remain in the memory on the right; only byte positions remain.

A debugger recovers names such as r.count from DWARF, covered in Chapter 4. Hurdle 3 explains how an external instrumentation tool determines the same field offset.

Key points for the hurdles

  • Memory is a row of bytes with addresses, and an address is just a number. A pointer holds that number as a value.
  • Instructions also sit in memory and have addresses, and the PC points to the position currently being executed. Arguments are sometimes passed in registers.
  • A struct’s field names disappear after compilation; only byte positions (offsets) remain.

Exercises

  1. For type pair struct { a int32; b int64 }, what will unsafe.Offsetof(p.b) and unsafe.Sizeof(p) be? Make a prediction, then verify on your machine.
  2. In the first code sample of this chapter, if you swap the order of the declarations of b and n, what happens to the relationship between the two printed addresses? Run it and see.
Answer
  1. b is 8 bytes, so it is placed at an address that is a multiple of 8: Offsetof(p.b) is 8 and Sizeof(p) is 16. Four bytes of padding go in after a.
  2. The concrete address values change from run to run, but the two variables still end up at nearby addresses. Which one gets the higher address is up to the compiler’s layout, and need not match declaration order.