zig / under the hood
DUE 000 DECK 000 STREAK 0d LESSONS 0/14 saved in this browser only
0x02
Part I · The machine

The CPU & instructions

Registers, the fetch-decode-execute loop, and reading the assembly your code turns into.

What the CPU actually does

The CPU (central processing unit) is the part that runs your program. For all the cleverness inside a modern CPU, the core idea is small: it follows a list of very simple instructions, one after another. Each instruction does one tiny thing:

  • copy a value from memory into the CPU, or from the CPU back to memory
  • add, subtract, multiply or compare two numbers
  • jump to a different place in the list, possibly only if a comparison came out a certain way Every program you’ve used, a browser, a game, React itself, is built out of billions of these little steps.

Your code becomes instructions

You don’t write instructions yourself. The compiler translates your Zig into machine code: instructions encoded as bytes. Humans read machine code in a text form called assembly, where each line is one instruction:

mov rax, 5        ; put 5 into register rax
add rax, 3        ; rax = rax + 3, so rax is now 8

Those instruction bytes sit in memory, right alongside your data. So the CPU needs a way to keep track of where it is in the list.

Fetch, decode, execute

The CPU has a special register called the instruction pointer (rip on x86-64) that holds the address of the next instruction. Then it repeats one loop, billions of times a second:

  1. Fetch: read the instruction bytes at the address in rip.
  2. Decode: work out what they mean, for example “add these two registers”.
  3. Execute: do it.
  4. Advance: move rip on to the next instruction. Each tick of the CPU’s clock is called a cycle. A 4 GHz CPU has about 4 billion cycles per second, and simple instructions take around one cycle. Normally the CPU walks forward through the list. A jump instruction changes that by writing a new address into rip. That’s how if, loops and function calls all work at the bottom: they’re jumps. A while loop that counts to 10 becomes roughly:
loop_start:
    cmp rcx, 10       ; compare the counter with 10
    jge loop_end      ; if counter >= 10, jump out of the loop
    ...               ; the loop body
    inc rcx           ; counter += 1
    jmp loop_start    ; jump back to the top
loop_end:

Registers: the CPU’s own tiny variables

Reaching out to main memory is slow (the caches lesson measures how slow). So the CPU has a small set of storage slots inside itself, called registers, that it can read essentially instantly. Most real work follows one pattern: load values from memory into registers, do the maths in registers, store results back to memory. x86-64 has 16 general-purpose registers, each 64 bits wide: rax, rbx, rcx, rdx, rsi, rdi, rbp, rsp and r8 to r15. The odd names are history from older Intel chips. Two have special jobs you’ll meet in the next lesson: rsp points at the top of the stack, and rbp often marks the current function’s part of it. There’s also a flags register. After an arithmetic or compare instruction, the CPU records facts about the result: was it zero? negative? did it carry out of the top bit? did it overflow? Conditional jumps such as jge (“jump if greater or equal”) work by looking at those flags.

The ISA: the CPU’s contract

An instruction set architecture (ISA) is the list of instructions a family of CPUs understands, the registers it has, and how instructions are encoded as bytes. The main ones today:

  • x86-64: Intel and AMD desktops, laptops and servers.
  • AArch64 (64-bit ARM): phones, Apple Silicon Macs and a growing number of servers.
  • RISC-V: an open ISA, popular for teaching and embedded chips. A program compiled for one ISA won’t run on another, which is why Zig asks for a -target when you cross-compile. The ISA is only a contract, though. Two chips with the same ISA can be built completely differently inside.

See it for yourself

The best way to make this concrete is to look at what the compiler produces. This Zig function:

export fn add(a: u64, b: u64) u64 {
    return a + b;
}

compiles with -O ReleaseFast for x86-64 to roughly:

add:
    lea rax, [rdi + rsi]   ; a arrives in rdi, b in rsi
    ret                    ; return; the result is expected in rax

Two instructions. The arguments arrive in the registers rdi and rsi (the next lesson explains why those two), lea puts their sum into rax, and ret returns to the caller. (lea is officially for calculating addresses, but compilers love it as a quick “add two registers into a third”.) Paste Zig into Compiler Explorer, pick Zig as the language, add -O ReleaseFast to the compiler options and read the output side by side. Clicking a line of source highlights the instructions it became. Locally, zig build-obj -O ReleaseFast -femit-asm=add.s add.zig writes the assembly to a file.

Why real CPUs are faster than this model

Real CPUs don’t wait for one instruction to finish before starting the next. They work like a factory assembly line, called a pipeline: while one instruction executes, the next is being decoded and the one after that fetched. They also run independent instructions side by side and out of order. That creates a problem at every if. The CPU doesn’t know which way the branch goes until the comparison is done, but it doesn’t want the line to stand still. So it guesses (this is branch prediction) and keeps working down the guessed path. If the guess was right, no time was lost. If it was wrong, all that work is thrown away and the line restarts, wasting roughly 15 to 20 cycles. Predictors are very good at patterns, like a loop branch that’s taken 999 times and then not taken once. They’re bad at random data. That’s why sorting data first can make a loop with an if inside much faster, and why compilers sometimes replace a branch with cmov (conditional move), which picks one of two values without jumping at all.

Recap

  • A CPU runs simple instructions in a fetch-decode-execute loop, and rip holds where it is.
  • if, loops and function calls all become jumps that change rip.
  • Registers are tiny, instant storage inside the CPU, and most work happens there.
  • The ISA (x86-64, AArch64, RISC-V) is the contract compilers target.
  • Pipelines guess which way branches go. A wrong guess costs about 15–20 cycles.
Try it

Write export fn max(a: i64, b: i64) i64 using an if, and look at its ReleaseFast assembly in Compiler Explorer. You'll most likely find cmov instead of a jump. Then compare the Debug output.

14 flashcards for this lesson

not started
See the questions
  1. What does the instruction pointer (rip) hold?
  2. Describe the CPU's basic cycle.
  3. How many general-purpose registers does x86-64 have?
  4. What is an ISA?
  5. What does the flags register record?
  6. What does a branch misprediction cost, and why?
  7. What is cmov and why do compilers like it?
  8. How can you see the assembly Zig generates?
  9. What is assembly?
  10. How do if and loops work at the machine level?
  11. What is a clock cycle?
  12. What is assembly?
  13. How do if and loops work at the machine level?
  14. What is a clock cycle?