Stack Frames
Each call pushes a frame — return address, saved frame pointer, arguments that did not fit in registers, locals and callee-saved registers — and each return pops it; the layout is fixed by a calling convention that varies by platform but always answers the same three questions.
The problem
add(2, 3) is called from main. When add finishes, the CPU must continue at the instruction after the call, with main’s locals intact and the result somewhere main can find it. Where do the arguments, the locals, and the way back live while add runs?What a call pushes
The call instruction does one thing besides jumping: it pushes the return address — the address of the instruction after the call — onto the stack. That single push is what makes returning possible; ret pops it into the instruction pointer. Everything else in a frame is put there by the called function’s prologue: it saves the caller’s frame pointer, sets its own, and moves the stack pointer down to reserve space for its locals. The epilogue undoes it in reverse, and ret completes the round trip.
Arguments travel in registers when they fit — on x86-64 System V (Linux, macOS) the first six integer arguments go in rdi, rsi, rdx, rcx, r8, r9, on ARM64 in x0–x7 — and on the stack when they do not, or when they are large structs. The return value comes back in rax (x0). A function that needs to keep an argument across a call it makes itself must save it to its frame first, because the callee will use those same registers. Registers the callee promises to preserve (callee-saved: rbx, rbp, r12–r15) are pushed into the frame if the callee uses them.
1int add(int a, int b) {2 int sum = a + b; // a, b arrive in edi, esi; sum is a local (or just a register)3 return sum; // result leaves in eax4}5 6int main() {7 int x = 2, y = 3;8 int r = add(x, y); // call pushes the return address; add's prologue builds its frame9 return r;10}The frame, laid out
Below is a conceptual frame for add on a 64-bit machine with frame pointers enabled and optimisation off — the layout a debugger shows. With optimisation on, add is a single lea eax, [rdi + rsi]; ret and has no frame at all; the frame exists when the function has state that must survive a call or an address that must be taken.
The stack grows towards lower addresses on every mainstream platform, so main’s frame is at higher addresses than add’s, and "pushing" means subtracting from the stack pointer. The frame pointer (rbp, x29) points at a fixed spot in the current frame; locals are at negative offsets from it and the saved caller’s frame pointer sits right at it, so following frame pointers from frame to frame is how a debugger or profiler walks the stack (a backtrace).
┌────────────────────────────┐ higher addresses
│ main's locals: x=2, y=3, r │ main's frame
│ saved rbp of main's caller │
├────────────────────────────┤
│ return address into main │ ← pushed by 'call add'
rbp → │ saved rbp (main's frame) │ ← pushed by add's prologue
│ int sum │ rbp − 4
│ (spilled a, b if needed) │ rbp − 8, rbp − 12
rsp → │ (alignment padding) │ lower addresses; next call pushes below here
└────────────────────────────┘
arguments a=2, b=3 arrived in registers edi, esi; return value leaves in eaxNested calls push, returns pop
If add called helper, helper’s frame would be built below add’s: another return address (into add), another saved frame pointer (add’s), another block of locals. Frames strictly nest, so at any moment the stack is the exact history of active calls, innermost at the lowest address — the reason a stack trace can be reconstructed from memory, and the reason the region is called a stack at all. When helper returns, its frame is abandoned (not zeroed — the bytes stay until overwritten, which is why uninitialised locals contain "random" old values), and add’s frame is the top again.
Recursion is just nested calls of the same function: fib(30) has at most 30 live frames of fib at once but creates about 1.6 million of them over its life. Each frame costs the same ~10–100 bytes and the same handful of instructions; the depth, not the count, is what the stack limit bounds (Stack Overflow). Tail calls, where the last act of a function is another call, can reuse the current frame instead of pushing a new one — an optimisation C compilers do at -O2 and some languages (Scheme, Erlang, Lua) guarantee; JavaScript specified it in ES2015 and only Safari shipped it, so do not rely on it.
The frame pointer is optional. -fomit-frame-pointer frees rbp as a general register and shaves a couple of instructions per call, at the cost of making backtraces depend on separate unwind tables (.eh_frame), which profilers must parse. Fedora, Ubuntu and Android have recently switched back to keeping frame pointers by default precisely because cheap, reliable stack walks in perf and eBPF profilers turned out to be worth more than one register.
Calling conventions exist, but vary
Everything above is a calling convention: an agreement between caller and callee about which registers carry arguments, which the callee may clobber, how the stack is aligned, who cleans up. The convention is per platform-and-ABI, not per language. x86-64 System V (Linux, macOS, BSD) passes six integer and eight floating-point arguments in registers and requires 16-byte stack alignment at each call. Microsoft x64 (Windows) passes only four in rcx, rdx, r8, r9, and requires the caller to reserve 32 bytes of shadow space on the stack for the callee to spill them. ARM64 AAPCS passes eight in x0–x7 with x30 holding the return address in a register rather than on the stack — the callee pushes it only if it makes calls of its own. 32-bit x86 passed everything on the stack, with cdecl, stdcall and fastcall variants that were a persistent source of crashes when mismatched.
This matters when you cross a language boundary. FFI from Python (ctypes), Node (N-API) or Rust (extern "C") works because everyone can speak the platform’s C convention; C++ name mangling and pass-by-value of non-trivial types are not part of it, which is why C++ libraries expose extern "C" wrappers. The details of the layout are the ABI’s business; the three questions — where do arguments go, where does the result go, how do we get back — are what every convention answers, and what the Stack Frames interactive shows one push at a time.
- System V x86-64: args in rdi, rsi, rdx, rcx, r8, r9; return in rax; callee-saved rbx, rbp, r12–r15.
- Microsoft x64: args in rcx, rdx, r8, r9 plus 32 bytes of shadow space; more callee-saved registers (rsi, rdi, xmm6–15).
- ARM64: args in x0–x7; return address in x30 (link register); frame pointer x29.
- Stack canaries (
-fstack-protector) sit between locals and the saved return address; a buffer overrun that reaches the return address changes control flow — the mechanism of classic exploits.
Key points
callpushes the return address; the callee’s prologue saves the frame pointer and reserves locals;retpops the return address.- Arguments go in registers when they fit (six on System V x86-64, four on Windows x64, eight on ARM64) and on the stack otherwise; the result returns in a register.
- Frames nest strictly, so the stack is the exact history of active calls — which is what a backtrace reads.
- Abandoned frames are not cleared; uninitialised locals see old bytes.
- The frame pointer makes stack walks trivial; omitting it saves a register and complicates profilers.
- Calling conventions are per platform ABI, not per language; FFI works because everyone can speak the platform C convention.
Why does this exist?
Mechanisms are answers to constraints. Open each question before reading the answer.
▸Why push the return address instead of keeping it in a register?
Because calls nest. A register holds one address; a stack holds one per active call in the order they must be used. ARM64 does keep the innermost one in a register and pushes it only when the callee calls further.
▸Why pass arguments in registers?
A register is written and read in the same cycle; a stack slot is a store, a load and cache traffic. Six registers cover the vast majority of calls; the stack is the fallback.
▸Why does a buffer overflow let an attacker run code?
The return address is in the frame, just above the locals. Writing past a local array overwrites it, and the next ret jumps wherever the attacker wrote. Canaries, non-executable stacks and ASLR each break one step of that chain.
Stack frames
int add(int a, int b) { int sum = a + b; return sum; }
int compute(int a, int b) { int tmp = add(a, b); return tmp * 2; }
int main() { int result = compute(3, 4); return 0; }How it fails
What the failure looks like from inside real software.
- Stack corruption: a debugger shows a backtrace of
??frames because a buffer overrun overwrote the saved frame pointer;-fstack-protectorwould have aborted with "stack smashing detected". - Mismatched convention across an FFI boundary (wrong ABI declared in
ctypesor astdcall/cdeclmix-up on 32-bit Windows): arguments land in the wrong registers and the function computes on garbage. - A profiler shows flat, uninformative stacks because the binary was built without frame pointers and without unwind info.
- Reading an uninitialised local returns whatever the previous call left in that slot — a bug that changes with compiler flags and disappears under the debugger.