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):
mainputs the arguments whereaddexpects them: here, 2 in registerrdiand 3 inrsi.mainexecutescall add. This pushes the return address (the address of the instruction right after the call) onto the stack, then jumps toadd.addmay set up its own stack frame: it saves the oldrbpand subtracts fromrspto make room for its locals.adddoes its work and puts the result inrax.addtears down its frame: it adds back torspand restoresrbp.addexecutesret, which pops the return address off the stack intorip. The CPU carries on inmain, right after the call.mainfinds the result inrax.
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,rbpandr12tor15) 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. rspmust be a multiple of 16 whenever acallhappens. 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
rspmarks its top. - Allocating on the stack is just subtracting from
rsp, which is why it’s fast. callpushes a return address and jumps;retpops it back intorip.- 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.