Part 2 · The calling contract
Prologue, epilogue, frame pointers
The bookkeeping at the top and bottom of every function — and the one flag that makes your profiler useless.
What this lesson is trying to break (3)
- The frame pointer is required for correctness.
- Every function has a prologue.
- Profilers read debug info to build stack traces.
Every non-trivial function opens and closes with bookkeeping. Learn to skip it, and learn what its absence tells you.
The standard opening
"f":
push rbp ; save caller's frame pointer
mov rbp, rsp ; rbp now marks this frame
sub rsp, 32 ; allocate 32 bytes of locals
...
leave ; = mov rsp, rbp ; pop rbp
ret
rbp becomes a fixed landmark: locals sit at [rbp-8], [rbp-16], and
arguments that arrived on the stack sit at [rbp+16] and up. Because rbp does
not move while the function runs, every offset is constant — which is convenient
for humans and for debuggers.
leave is a single instruction that undoes both steps.
push rbp / mov rbp, rsp
A frame-pointer prologue. Skip both lines — the function starts after them.
Its absence is the more informative case. If a function begins immediately
with real work and refers to locals as [rsp+k], it was compiled with
-fomit-frame-pointer and rbp is being used as an ordinary register.
The frame pointer is optional
The compiler knows exactly how far rsp has moved at every point, so it can
address locals relative to rsp directly. rbp is redundant.
Omitting it buys two things: one extra general-purpose register (a real gain when you only have sixteen), and two fewer instructions per call. Historically that was measured at around 1%.
The cost is that you break cheap stack unwinding.
int add(int a, int b) {
return a + b;
}Compile at -O2.
No. You get roughly:
"add":
lea eax, [rdi+rsi]
retNo prologue, no frame, no stack traffic — and a lea doing the addition so both
inputs survive (1.3).
Modern compilers omit the frame pointer by default at -O2 and this is a leaf
function with no locals, so there is nothing to set up. Most small functions look
like this.
Now compile the same thing at -O0 and compare. You will see push rbp,
mov rbp, rsp, both arguments stored to stack slots, then loaded back to be
added. That is 3.2 in preview: -O0 is readable
precisely because it is doing pointless work in a predictable shape.
Why this is a real engineering decision
A sampling profiler interrupts the program thousands of times per second and asks “where am I, and who called me?”. Answering the second half requires walking the stack.
With frame pointers that is trivial: each frame stores the previous rbp, so
they form a linked list. Follow it. Cheap enough to do at high sampling rates.
Without them, the profiler needs DWARF unwind tables — extra data describing how to recover the caller at every instruction. These are large, sometimes absent in third-party or JIT-generated code, and expensive to interpret while sampling. The practical result is truncated stacks, stacks that stop at a library boundary, or plausible-looking nonsense.
This is why Fedora and Ubuntu re-enabled frame pointers by default, accepting roughly 1% throughput to make production profiling work. It is a genuine trade-off, and knowing which side you are on is useful: if you cannot profile production, you will lose far more than 1% to problems you never find.
ARM64
The shape is the same, the spelling differs:
stp x29, x30, [sp, #-16]! ; save frame pointer AND link register
mov x29, sp
...
ldp x29, x30, [sp], #16
ret
x29 is the frame pointer, x30 the link register. stp/ldp store and load a
pair in one instruction, and the ! means pre-decrement — so allocation and
saving happen together.
The tell for a leaf function is clean: if x30 is not saved, the function makes
no calls. On x86 you have to infer that from the absence of call.
Your team wants to profile a production service and the flame graphs are mostly
[unknown] above the third frame.
Give the likely cause and two options, with the cost of each. One option is a build-flag change; the other is not.
What you should now be able to say
- The prologue is
push rbp/mov rbp, rsp/ optionalsub rsp, N;leaveundoes it. - The frame pointer is optional — the compiler can address locals off
rsp. - Omitting it gains a register and ~1%, and breaks cheap stack unwinding.
- Small leaf functions at
-O2typically have no prologue at all. - ARM64 saves
x29/x30as a pair; unsavedx30means leaf.
Next: 2.4 — Who saves what.