Stack Frame Layout
What a prologue actually builds: a saved frame pointer, space for spills and locals, and a return address it did not put there. Omitting the frame pointer buys one register and costs a profiler its stack walk.
What is in a stack frame, who puts it there, and what does -fomit-frame-pointer actually cost me?
The function's local state as a contiguous region of the stack, addressed by fixed offsets from one register. That is the representation the backend commits to when it runs out of registers: every spilled value, every address-taken local and every outgoing stack argument becomes an offset, and the frame exists to answer "where does this named thing live for the duration of this call" once the answer can no longer be "a register".
A prologue may lay out the frame however it likes, subject to four obligations it cannot trade away: the stack pointer must satisfy the ABI's alignment at every call, any callee-saved register the body writes must be saved before its first write and restored before the return, the return address must be intact when ret executes, and the unwind metadata the platform requires must describe the frame accurately at every instruction boundary where an exception or a signal can occur. Within those, whether a frame pointer exists, in what order slots are assigned, and whether the frame is allocated at all are entirely the backend's choice.
Key points
- A frame is built by both sides: the caller pushes overflow arguments,
callpushes the return address, and the prologue adds the saved frame pointer, callee-saved registers and local space. - The frame pointer buys stable offsets and a walkable chain; omitting it buys one register and two instructions per call.
- Without a frame pointer, stack walking requires side tables, and those are slow to consult in a profiler and frequently missing in stripped or JIT-generated code.
- A local variable is in memory for a specific reason — address-taken, too large, spilled, volatile, or unoptimized — not by default.
- Frame layout is not source order: slots are sorted by alignment, shared between non-overlapping lifetimes, and rearranged by hardening instrumentation.
- The prologue cannot be written until register allocation is finished, because it does not know the frame size or which registers to save until then.
What the prologue builds, and what was already there
x30 and is on the stack only if the callee saves it, the frame pointer is x29, and the standard prologue is a single stp x29, x30, [sp, #-16]! that saves both and adjusts the stack in one instruction. On Windows x64 the caller also reserves 32 bytes of shadow space above the return address, which appears in this diagram as extra unused-looking slots.A frame is assembled by two parties. The caller pushes the arguments that did not fit in registers, and then the call instruction itself pushes the return address. Only then does the callee's prologue run, and it adds the rest: a saved copy of the caller's frame pointer, the callee-saved registers it intends to use, and a block of space for spill slots and address-taken locals.
The order matters because it is what makes the offsets constant. With a frame pointer established, arguments passed on the stack are at positive offsets from it, and everything the function allocated is at negative offsets. Those offsets do not change as the function pushes and pops, which is precisely why a debugger can find a variable and why a compiler can generate the addressing without tracking the stack pointer through every instruction.
The frame layout below is x86-64 System V, and the direction matters: this stack grows downward, so "above" means a higher address and an older frame. AArch64 and most other mainstream targets grow downward too, but the contents differ — most importantly, AArch64 has no return address on the stack unless the callee chooses to save x30 there.
higher addresses (caller's frame)
┌────────────────────────────────┐
│ stack argument 8 │ [rbp+24]
│ stack argument 7 │ [rbp+16]
├────────────────────────────────┤
│ return address │ [rbp+8] <- pushed by CALL
├────────────────────────────────┤
│ saved rbp (caller's) │ [rbp+0] <- pushed by the prologue
├────────────────────────────────┤ <=== rbp
│ saved rbx, r12..r15 │ [rbp-8] callee-saved, only if used
│ local: buf[16] │ [rbp-32] address-taken, must be memory
│ spill slot for %7 │ [rbp-40] the allocator ran out
│ spill slot for %12 │ [rbp-48]
│ outgoing stack arguments │ [rsp+0] for calls this function makes
└────────────────────────────────┘ <=== rsp, kept 16-byte aligned at calls
lower addresses (stack grows this way)Prologue and epilogue as a matched pair
-O1 and above for x86-64 Linux; several distributions have since re-enabled frame pointers by default specifically to make continuous profiling work. AArch64 platforms and Apple's ABI keep the frame pointer, and macOS effectively requires it. Check your build rather than assuming.The prologue and epilogue are a single obligation written in two places, and the compiler generates them last, after register allocation has decided which callee-saved registers were needed and how many spill slots exist. That ordering is why the frame size is not knowable until the backend is nearly finished, and why [[spilling]] says that spilling changes the prologue.
A leaf function that needs no stack space at all gets neither. This is extremely common after inlining, and it is why small functions in optimized builds often consist of two or three instructions with no push anywhere. A function that needs space but no frame pointer gets a bare sub rsp, N and addresses everything relative to rsp. A function that needs a frame pointer — because it uses alloca, or has variable-length arrays, or the build asked for one — gets the full sequence.
1; -O2 -fno-omit-frame-pointer2f:3 push rbp4 mov rbp, rsp5 sub rsp, 32 ; locals and spills6 ...7 mov rax, [rbp-8] ; offsets are stable no matter what rsp does8 ...9 mov rsp, rbp ; deallocate whatever the body did to rsp10 pop rbp11 ret12 13; -O2 -fomit-frame-pointer (the default at -O2 on most Linux toolchains)14f:15 sub rsp, 40 ; 32 for locals plus 8 to realign after the return address16 ...17 mov rax, [rsp+24] ; offsets are relative to rsp and shift if rsp moves18 ...19 add rsp, 4020 retThe second version has one more register available for the whole function and two fewer instructions per call. The cost is that [rsp+24] is only correct while rsp holds the value it held at that point, so the compiler must track the stack pointer through the entire body — and anything walking the stack from outside must be told the offsets rather than following a chain.
What omitting the frame pointer actually costs
On x86-64, dedicating rbp to the frame pointer removes one of roughly thirteen usable registers, and adds a push and a pop to every non-trivial function. That is a genuine, measurable win to reclaim — usually low single-digit percent, occasionally more in register-hungry code. Toolchains did not turn it on for no reason.
What it costs is the frame-pointer chain: with rbp maintained, every frame stores the caller's rbp, so walking the stack is following a linked list, which any tool can do cheaply and without metadata. Without it, unwinding requires consulting a side table — .eh_frame or .debug_frame on ELF, the compact unwind tables on Mach-O — that says, per address range, where the return address and the saved registers are relative to the stack pointer.
That side table is correct and complete, and it is also slow to consult and frequently absent. A sampling profiler that must unwind at 99 hertz inside a signal handler cannot afford a DWARF interpreter, and a JIT frame or a stripped third-party library may have no table at all. The practical result is the one every performance engineer has met: flame graphs with truncated stacks, perf reporting a hot leaf function with no callers, and the fix being to rebuild the world with -fno-omit-frame-pointer. Debuggers hit a weaker version of the same problem — gdb will usually manage with .eh_frame but degrades on optimized code and on anything without unwind information.
| Consumer | With a frame pointer | Without |
|---|---|---|
| The function body | One register gone, two instructions per call | One more allocatable register, no prologue cost in leaf functions |
| A sampling profilertypical | Walks a linked list; cheap, reliable, works in a signal handler | Needs unwind tables at sample time, or falls back to truncated stacks |
| A debuggertypical | Backtraces work even with no debug info at all | Works from .eh_frame, degrades where that is stripped or wrong |
| A crash reporter | Can symbolicate from the frame chain alone | Needs the unwind table shipped or archived alongside the binary — see [[symbolication]] |
| Exception unwindingspec | Uses the tables anyway — it is not on the fast path | Identical; C++ unwinding never used the frame pointer |
| Stack-based security tooling | Frame boundaries are self-describing | Requires the same metadata as everything else |
Where locals actually live
[rbp-8k] and never merges or reorders them, and it emits push rbp; mov rbp, rsp unconditionally. Real frame layout also does alignment-aware packing, lifetime-based slot sharing, stack-protector placement and shrink-wrapping — moving the prologue past an early return so a fast path pays nothing. None of that is in ours.A source-level local variable is not a stack slot by default. In an optimized build most locals live in registers and never touch memory, which is what [[register-allocation]] is for. A local acquires a stack slot for one of a small number of concrete reasons, and knowing them is what lets you predict which variables a debugger will be able to show you.
It is address-taken, so a pointer to it exists and it must have an address. It is larger than a register — an array or a struct that the classification rules did not put in registers. It is live across a call and the allocator chose to spill rather than use a callee-saved register. It survives a setjmp or is volatile, so the language requires it to be in memory. Or the build is unoptimized, in which case everything is in memory precisely so the debugger can find it — that is a large part of what -O0 means.
The frame is also not laid out in source order. Compilers sort slots by alignment to avoid padding, merge slots for locals whose lifetimes do not overlap, and place buffers adjacent to the canary when stack-protector instrumentation is on. Reasoning about frame layout from declaration order is a reliable way to be wrong, which matters because a surprising amount of exploit lore assumes otherwise.
- Address-taken:
&xforces a slot, and taking the address of a local is the single most common reason a variable becomes uninspectable-in-registers and inspectable-in-memory. - Too large for the register classes the ABI defines.
- Live across a call and cheaper to spill than to occupy a callee-saved register.
volatile, or live acrosssetjmp, where the language mandates memory.- Unoptimized build, where every named value is given a slot for debuggability.
How it works
The steps, in the order the compiler takes them.
- The caller places arguments beyond the register limit onto the stack and adjusts the stack pointer so it satisfies the ABI alignment at the call.
- The
callinstruction pushes the return address, or on AArch64 writes it into the link register. - The prologue saves the caller's frame pointer, establishes a new one, and pushes any callee-saved registers the body will write.
- It subtracts the frame size from the stack pointer to reserve spill slots, address-taken locals, and space for outgoing stack arguments.
- The body addresses slots at fixed offsets from the frame pointer, or from the stack pointer when the frame pointer has been omitted.
- The epilogue restores the stack pointer, pops the callee-saved registers in reverse order, pops the frame pointer, and returns to the address the caller left.
- In parallel, the compiler emits unwind metadata describing, for every address range in the function, where the return address and each saved register are — which is what makes stack walking possible without a frame chain.
How it breaks
What the engineer observes when it goes wrong — not what goes wrong internally.
- A flame graph shows a hot function with no callers and stacks one or two frames deep, because the profiler could not unwind past a library built with frame pointers omitted and no usable unwind tables.
- A debugger reports
??for every frame beyond the first in a release build, and the backtrace is unusable exactly when it is most needed. - A buffer overflow overwrites the saved return address and the program jumps to an arbitrary address, crashing in a function that was never called — the classic stack-smash, whose shape is entirely a consequence of this layout.
- Recursion or a large stack array exhausts the stack, and the fault appears as a segfault on a normal-looking memory access at the moment the guard page is touched.
- Hand-written assembly restores the stack pointer incorrectly and the
retreads a value that was never a return address, transferring control somewhere arbitrary with no diagnostic at all. - A variable reads as
<optimized out>in the debugger in exactly the region under investigation, because it was in a register there and only spilled elsewhere.
When it helps
- Reading a crash dump or a core file: knowing what is at
[rbp+8]and[rbp+0]is what turns raw stack memory into a call chain. - Diagnosing broken profiles. Truncated stacks are almost always frame-pointer omission plus missing unwind information, and the fix is a build flag rather than a profiler setting.
- Understanding why a variable is inspectable in one build and not another, and why taking its address makes it inspectable again.
When it hurts
- Predicting layout from source. Slot ordering, merging and padding are backend choices that change between optimization levels and compiler versions.
- Assuming the frame is where the data is. In optimized code most values are in registers most of the time, and the frame holds only what could not stay there.
What it costs
Every one of these is paid by something.
- Maintaining a frame pointer buys cheap, metadata-free stack walking for profilers, debuggers and crash reporters, and costs one general-purpose register plus a push and a pop in every non-leaf function.
- Unwind tables buy correct stack walking without a frame pointer and cost binary size —
.eh_frameis routinely a few percent of an ELF binary — plus the interpretation time that makes them unattractive inside a sampling handler. - Giving every local its own slot buys a debugger that can always find every variable, and costs stack footprint and memory traffic on every access. That is the trade
-O0makes deliberately. - Shrink-wrapping — moving the prologue past an early return — buys a cheaper fast path and costs unwind metadata that must now describe several different frame states within one function, which is more table and more ways to be subtly wrong.
What else you could do
What a different compiler or language does instead, and when that is better.
- Frame pointers always on, as macOS effectively requires and as several Linux distributions have returned to: pay the register and the instructions, get working profiles across the whole system without shipping unwind tables everywhere.
- Stack maps instead of frames: a managed runtime records, per safepoint, where every live reference is, which serves the collector and the unwinder from one table. Precise and only possible because the runtime controls all its own code.
- Segmented or growable stacks, as early Go and Rust used: allocate small frames and chain new segments when one is exhausted. It removes stack-overflow crashes and costs a check in every prologue, plus the "hot split" pathology when a call across the boundary happens in a loop. Go replaced it with contiguous stacks that are copied and relocated on growth.
- Heap-allocated activation records, as some functional-language implementations and every continuation-passing implementation use: frames become ordinary heap objects and closures can outlive their creator, at the cost of allocation and collection on every call.
See it for yourself
The flag, dump or tool that shows you this directly.
- See the prologue change: compile the same file with
-fomit-frame-pointerand-fno-omit-frame-pointerand diffclang -O2 -Soutput. - See the unwind tables:
readelf --debug-dump=frames-interp binaryprints.eh_framein interpreted form, one row per address range, saying where the return address is. - See a live frame: in
gdb,info framereports the frame base, the saved registers and where the return address is;x/16gx $rspshows the raw stack. - See the frame size the compiler chose:
-fstack-usagemakes GCC and Clang write a.sufile with the frame size of every function, which is how embedded projects bound their stack. - See the profiler failure directly: run
perf record -gon a binary built without frame pointers, then again with--call-graph dwarf, and compare the stack depth.
Plausible wrong readings
Stated the way a confident engineer states them.
- "Local variables live on the stack." They live in registers when they can. A stack slot is what happens when they cannot, and the reasons are specific and enumerable.
- "
-fomit-frame-pointeris a micro-optimization with no downside." It is a real win and it is why your flame graphs are truncated. The trade is register pressure against observability, and it is a genuine argument with a live answer on both sides. - "The frame pointer is needed for exceptions." C++ unwinding uses
.eh_frame, not the frame chain, and has done for decades. Frame pointers are for cheap stack walking, not for correctness. - "Frame layout follows declaration order." It follows alignment, lifetime overlap and hardening instrumentation. Compilers reorder slots freely and change the ordering between versions.
Misconceptions
The claim, and what is actually true.
call instruction. The prologue only adds the callee's own half.Go deeper
The same idea at increasing depth. Stop wherever it stops being useful.
overview
Each call gets a block of stack: the return address the call instruction pushed, a saved copy of the previous frame marker, whatever registers the function promised to give back, and room for anything that could not live in a register. The prologue builds it, the epilogue takes it down, and the two must match exactly or the function returns to the wrong place.
practical
When a profile shows shallow or nonsensical stacks, suspect frame pointers before suspecting the profiler. Rebuild the hot components with -fno-omit-frame-pointer, or ask the profiler to unwind from DWARF and accept the overhead and the sample loss. When a debugger says a variable is optimized out, taking its address in a scratch line forces it into memory — which is also the reason that adding a printf sometimes makes a bug disappear.
advanced
The frame-pointer argument is the domain's cleanest example of a cost that is paid by a different team than the one that measures it. The register is worth low single-digit percent to the code being compiled, and the missing frame chain is worth much more than that to whoever is trying to find out where the time goes across a whole fleet. Google, Meta and several Linux distributions independently concluded that continuous fleet-wide profiling is worth more than the register and turned frame pointers back on. Shrink-wrapping is the more sophisticated response: keep the frame minimal on paths that do not need it, and describe the several resulting frame states in the unwind tables. It gets most of the win without losing the walkability, and it costs implementation complexity and a much larger surface for unwind-metadata bugs — which show up as unwinding failures during exception propagation, i.e. as a crash rather than a bad profile.
How much this depends on
Nothing in this domain is true of every compiler. These say how much.
[rbp+8] return address and the push rbp; mov rbp, rsp prologue are x86-64 System V. AArch64 keeps the return address in x30, uses x29 as the frame pointer, and saves both with one stp instruction; on Windows x64 the caller also reserves 32 bytes of shadow space, which changes every positive offset.-O0 is typical of x86-64 Linux toolchains and not universal: several distributions have re-enabled frame pointers by default to make system-wide profiling work, and Apple platforms keep them. It is a distribution and platform policy, not a compiler law.If you were asked this in an interview
- Draw a stack frame on x86-64 System V and say who wrote each part of it.
- What does
-fomit-frame-pointerbuy, and who pays for it? - Name four reasons a local variable ends up in memory instead of a register.
Connections
- Programming Languages & Runtime Internals — Stack maps, safepoints and how a garbage collector finds live references in a frameA collector must know which stack slots hold references at every point it can pause. The compiler emits that table alongside the frame layout described here; how the runtime consumes it, and what it does with a frame it cannot describe, is owned there.