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

The stack & calling conventions

How function calls really work: frames, return addresses and the registers everyone agrees on.

Why functions need somewhere to put things

When you call a function, it needs space for its local variables. It also needs to remember where to go back to when it’s done, because the caller was in the middle of something. Functions call other functions, which call others, each needing their own space, and they finish in reverse order: the last one called is the first to return. That “last in, first out” pattern is exactly how a stack works, like a stack of plates where you only add or remove at the top. So every thread gets a region of memory used as a stack, called the call stack.

How the stack works in memory

On x86-64 the stack starts at a high address and grows downward, toward lower addresses. (It’s just a convention, and it leaves the space below free for the heap to grow upward.) The register rsp, the stack pointer, always holds the address of the current top.

  • To make room for 16 bytes of local variables: subtract 16 from rsp.
  • To give them back: add 16 to rsp. That’s all. There’s no searching for free space and no bookkeeping. This is why stack allocation is essentially free, and why the stack is the default home for local variables.

What happens during a call, step by step

Say main calls add(2, 3):

  1. main puts the arguments where add expects them: here, 2 in register rdi and 3 in rsi.
  2. main executes call add. This pushes the return address (the address of the instruction right after the call) onto the stack, then jumps to add.
  3. add may set up its own stack frame: it saves the old rbp and subtracts from rsp to make room for its locals.
  4. add does its work and puts the result in rax.
  5. add tears down its frame: it adds back to rsp and restores rbp.
  6. add executes ret, which pops the return address off the stack into rip. The CPU carries on in main, right after the call.
  7. main finds the result in rax.
higher addresses
  |  main's frame         |
  |  return address       |  <- pushed by call
  |  saved rbp            |
  |  add's locals         |
  |                       |  <- rsp (top of stack)
lower addresses   (the stack grows this way)

If add called another function, a new frame would go below add’s, and so on. When a function returns, its frame is simply abandoned: the bytes are still there, but rsp has moved back above them, so the next call will write over them.

The calling convention: rules everyone agrees on

For step 1 to work, the caller and the function have to agree on where arguments go. Code from different compilers and languages (your Zig, the C library, the operating system) has to agree too. That agreement is called a calling convention. Linux and macOS on x86-64 use the System V convention:

  • The first six integer or pointer arguments go in rdi, rsi, rdx, rcx, r8, r9, in that order. Any more go on the stack.
  • The return value comes back in rax.
  • Some registers (rbx, rbp and r12 to r15) are callee-saved: a function that wants to use them must restore their old values before returning, so the caller can count on them surviving the call. Every other register may be overwritten by the call.
  • rsp must be a multiple of 16 whenever a call happens. You don’t need to memorise all of it. What matters is knowing it exists: it’s why Zig can call C functions and C can call Zig, and it’s what you’re looking at whenever you read assembly.

Dangling pointers: the classic stack bug

Since a frame is abandoned when its function returns, any pointer into that frame now points at memory that’s about to be reused. Such a pointer is called dangling.

fn broken() *u32 {
    var x: u32 = 42;
    return &x; // BUG: x lives in broken's frame
}

Here x lives in broken’s frame. After broken returns, the caller holds an address in the abandoned area. It might still read 42 for a moment, because nothing has overwritten it yet, which makes this bug sneaky: it can pass a quick test and break later when another call reuses that memory. Zig won’t always catch this for you. The fixes are: return the value itself (it gets copied to the caller), have the caller pass in a pointer to its own variable, or allocate on the heap (next lesson) when the data really must outlive the function.

Stack overflow

The stack has a fixed size. On Linux the main thread usually gets 8 MiB (check with ulimit -s), and other threads often get less. Two common ways to run out:

  • Recursion that goes too deep, or never stops: every call adds another frame.
  • A huge local variable, like var buf: [16 * 1024 * 1024]u8 = undefined; (16 MiB) inside a function. Just past the end of the stack there’s a deliberately unmapped guard page, so running off the end crashes immediately instead of silently corrupting other memory. That crash is a stack overflow. Big or variable-sized data belongs on the heap.

Recap

  • Each thread has a call stack that grows downward, and rsp marks its top.
  • Allocating on the stack is just subtracting from rsp, which is why it’s fast.
  • call pushes a return address and jumps; ret pops it back into rip.
  • Each call gets a frame for its locals. It’s abandoned on return, so pointers into it dangle.
  • The calling convention says where arguments and return values go.
Try it

Write a recursive function that prints the address of one of its locals at each depth. Watch the addresses go down by a fixed amount per call (that's the frame size). Then let it recurse until it crashes and see what Zig reports.

14 flashcards for this lesson

not started
See the questions
  1. Which way does the x86-64 stack grow?
  2. What exactly do call and ret do?
  3. What's in a stack frame?
  4. Why is stack allocation nearly free?
  5. Why is returning a pointer to a local variable a bug?
  6. System V x86-64: which registers carry the first six integer arguments?
  7. Which registers are callee-saved in System V x86-64?
  8. Typical main-thread stack size on Linux?
  9. Why is the call stack a stack (last in, first out)?
  10. What stops a stack overflow from corrupting other memory?
  11. Three fixes for returning a pointer to a local variable?
  12. Why is the call stack a stack (last in, first out)?
  13. What stops a stack overflow from corrupting other memory?
  14. Three fixes for returning a pointer to a local variable?