Stack frames: function prologue and epilogue
A stack frame is the part of the stack owned by one active function call. It is where a function can save the caller's frame pointer, reserve local storage, spill registers, and preserve the state it must restore before returning. The prologue builds that frame; the epilogue tears it down. Reading those two bookends lets you separate call-frame mechanics from the actual computation.
The reset: a stack frame is not a C object. It is an ABI-shaped region of memory around
rsp, often anchored byrbp, that exists only for one dynamic call.
How it really works
The classic x86-64 frame-pointer shape is:
push rbp
mov rbp, rsp
sub rsp, N
Then, before returning:
add rsp, N
pop rbp
ret
Read it as a small protocol:
| Instruction | Role |
|---|---|
push rbp |
save the caller's frame pointer |
mov rbp, rsp |
make a stable base for this frame |
sub rsp, N |
reserve N bytes of local/spill space |
add rsp, N |
release that local/spill space |
pop rbp |
restore the caller's frame pointer |
ret |
pop the return address into rip |
The compiler may omit some of this in optimized code. A tiny leaf function might need no
stack storage. A function compiled with frame-pointer omission may use rsp directly
and describe unwinding through metadata instead. But when you see this shape, it is the
function's entry and exit contract written in instructions.
A real prologue and epilogue
The executable artifact for this note lives in
examples/assembly-and-compiler-output/stack-frames-function-prologue-and-epilogue/.
static NOINLINE long add_bias(long value) {
return value + 7;
}
long framed_total(long a, long b) {
volatile long local = a + b;
long adjusted = add_bias(local);
return adjusted + local;
}
volatile forces a visible stack slot for local. The call to add_bias prevents the
whole function from collapsing into a single arithmetic expression.
On this machine, gcc is Apple clang 15 targeting x86-64 Mach-O. This is the relevant
function body copied from the real output of:
gcc -S -O2 -fno-omit-frame-pointer -masm=intel demo.c -o demo.s
_framed_total: ## @framed_total
.cfi_startproc
## %bb.0:
push rbp
.cfi_def_cfa_offset 16
.cfi_offset rbp, -16
mov rbp, rsp
.cfi_def_cfa_register rbp
sub rsp, 16
add rdi, rsi
mov qword ptr [rbp - 8], rdi
mov rdi, qword ptr [rbp - 8]
call _add_bias
add rax, qword ptr [rbp - 8]
add rsp, 16
pop rbp
ret
.cfi_endproc
Map the arguments first: a arrives in rdi, b arrives in rsi, and the return
value leaves in rax.
Now split mechanics from business logic:
push rbp; mov rbp, rspestablishes a visible frame pointer.sub rsp, 16reserves stack space. Only 8 bytes are visibly used here, but the ABI alignment rules make 16 a natural reservation size.add rdi, rsicomputesa + b.mov qword ptr [rbp - 8], rdistoreslocalin the current frame.mov rdi, qword ptr [rbp - 8]reloadslocalas the first argument toadd_bias.call _add_biaspushes a return address and transfers control.add rax, qword ptr [rbp - 8]addslocalto the helper's return value.add rsp, 16; pop rbp; retreleases the frame and returns to the caller.
The .cfi_* directives are not CPU instructions. They are unwind metadata for
debuggers, profilers, exception machinery, and stack walkers. They describe where the
canonical frame address and saved registers are while the function runs.
The helper has a smaller frame because it has no local stack slot:
_add_bias: ## @add_bias
.cfi_startproc
## %bb.0:
push rbp
.cfi_def_cfa_offset 16
.cfi_offset rbp, -16
mov rbp, rsp
.cfi_def_cfa_register rbp
lea rax, [rdi + 7]
pop rbp
ret
.cfi_endproc
The pattern is the same, but the middle is just lea rax, [rdi + 7]. No sub rsp
appears because there is no local stack storage to reserve.
Frame pointer versus stack pointer
rsp is the moving top of the stack. It changes when the function reserves storage,
pushes temporary values, calls another function, or releases storage. rbp is an
optional stable anchor. With a frame pointer, a local such as [rbp - 8] keeps the same
address even if rsp changes later.
This is why debug builds tend to preserve rbp: stack walking and local inspection are
straightforward. Optimized builds often omit it to free another general-purpose register
and rely on unwind tables. The code is still valid; it is just less visually tidy.
ARM64 appendix
The same demo.c was cross-compiled on this machine with:
clang -S -O2 -fno-omit-frame-pointer -arch arm64 demo.c -o demo.arm64.s
Relevant function body:
_framed_total: ; @framed_total
.cfi_startproc
; %bb.0:
sub sp, sp, #32
.cfi_def_cfa_offset 32
stp x29, x30, [sp, #16] ; 16-byte Folded Spill
add x29, sp, #16
.cfi_def_cfa w29, 16
.cfi_offset w30, -8
.cfi_offset w29, -16
add x8, x1, x0
str x8, [sp, #8]
ldr x0, [sp, #8]
bl _add_bias
ldr x8, [sp, #8]
add x0, x8, x0
ldp x29, x30, [sp, #16] ; 16-byte Folded Reload
add sp, sp, #32
ret
.cfi_endproc
AArch64 uses sp as the stack pointer, x29 as the conventional frame pointer, and
x30 as the link register. bl _add_bias writes the return address into x30, so a
function that calls another function usually saves x29 and x30 together with stp.
The epilogue reloads both with ldp, releases stack space, and returns with ret.
The same idea is visible, but the vocabulary differs: x86-64 uses call to push a
return address to the stack; ARM64 uses a link register and spills it when needed.
Failure modes & trade-offs
- Assuming every function has a visible frame. Optimized leaf functions may have no
prologue at all, and frame-pointer omission removes the tidy
rbpchain. - Forgetting stack alignment. The
sub rsp, Namount is not only about named locals. It also keeps the stack aligned for calls and ABI expectations. - Confusing
.cfi_*with executable instructions. CFI directives matter for unwinding, but they do not run on the CPU as part of the function body. - Ignoring alternate exits. Real functions may have multiple returns, error paths, or tailcalls. Each path must leave the stack and callee-saved registers in a valid state.
- Using red-zone assumptions in the wrong place. System V AMD64 user code can use a red zone, but kernels and interrupt-heavy code usually cannot rely on it.
- Treating stack bytes as lifetime-safe. Once the function returns, its frame is no longer yours. Pointers into it are dangling.
In practice
- Find the prologue first. It tells you whether a frame pointer is present and how much stack space is reserved.
- Name local slots by offset.
[rbp - 8]is a slot, not a variable name. The note you assign to it comes from nearby stores and loads. - Separate ABI bookkeeping from computation.
push,mov rbp, rsp,sub rsp, CFI, and epilogue code are frame mechanics. - Check calls before returns. A real
callmeans a return address is pushed and stack alignment matters. - Pair this with the raw stack model. The frame is the structured version of the stack operations from the stack at the assembly level.
Connects to: The stack at the assembly level · The System V AMD64 calling convention · Control flow: jumps, conditions, and the flags register · The stack: frames & automatic storage · Stack vs heap
Sources
- System V AMD64 ABI — stack alignment, call-frame rules, red zone, register saving, and unwind expectations. https://gitlab.com/x86-psABIs/x86-64-ABI
- Intel 64 and IA-32 Architectures Software Developer's Manual, Vol. 1 and Vol. 2 — semantics for
push,pop,call,ret, stack addressing, and frame-related instructions. https://www.intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html - Bryant & O'Hallaron — Computer Systems: A Programmer's Perspective (CS:APP), ch. 3 — procedure calls, stack frames, register saving, and machine-level C. https://csapp.cs.cmu.edu/
- Ed Jorgensen — x86-64 Assembly Language Programming with Ubuntu — approachable x86-64 stack-frame examples. https://open.umn.edu/opentextbooks/textbooks/x86-64-assembly-language-programming-with-ubuntu
- Felix Cloutier — x86 and amd64 instruction reference — quick lookup for
push,pop,call,ret, andleave; verify edge cases against Intel SDM. https://www.felixcloutier.com/x86/ - Arm Architecture Reference Manual + Apple ARM64 docs —
sp,x29,x30,bl,ret, and Apple platform calling-convention details for the appendix output. https://developer.arm.com/documentation/ddi0487/latest · https://developer.apple.com/documentation/xcode/writing-arm64-code-for-apple-platforms