The Runtime Theory
Runtime & Execution

Stack Frames and Calling Conventions: The Cost of Every Function Call

What really happens at every function call: the rbp and rsp registers, prologue and epilogue, the x86-64 red zone, and why -fomit-frame-pointer trades stack traces for speed.

The Runtime Theory Team3 min read#stack-frames#calling-conventions#x86-64#assembly#performance
On this page

When you write int x = foo(y);, the compiler emits a small ritual on entry to foo: save the old frame pointer, anchor the stack pointer to it, reserve space. That ritual is the prologue. Before returning, a mirror-image epilogue tears everything down. The stack frame in between is where locals live, where the return address sits, and where the fixed per-call cost of every program you've ever written is paid.

rsp, rbp, and the shape of a frame

Two registers define every frame on x86-64. rsp is the stack pointer — it always points at the current top of the stack, and it is authoritative: the ABI assumes it is correct at every function boundary. rbp is the frame pointer — a convenience register that freezes the frame's base address, so the compiler can address locals and arguments relative to something that doesn't move while the frame grows (it can grow if the function calls alloca or spills register arrays).

text
        ┌─────────────────────────┐  lower addresses
        │  locals (negative rbp offsets)   │  ← rsp points here
        ├─────────────────────────┤
        │  saved rbp              │  ← rbp
        ├─────────────────────────┤
        │  return address         │  ← pushed by `call`
        ├─────────────────────────┤
        │  caller's frame         │
        └─────────────────────────┘  higher addresses

Everything above rbp belongs to the caller or the call machinery. Everything from rbp down is the current frame. The frame pointer is pure convenience — the ABI does not require it, and that fact shapes the performance story below.

The prologue: what the compiler does on entry

Compiled with a frame pointer, foo starts like this:

asm
foo:
    push   rbp              ; save caller's frame pointer
    mov    rbp, rsp         ; freeze this frame's base
    sub    rsp, 32          ; reserve 32 bytes for locals
    ...

Three instructions. The third one is where stack alignment happens: the System V AMD64 ABI demands that rsp be 16-byte aligned at the point of a call, and the compiler sizes the reservation so that property holds no matter how the frame is used.

The epilogue: leaving without a trace

The epilogue undoes all of it, usually with a single instruction:

asm
    leave                   ; mov rsp, rbp; pop rbp — both in one opcode
    ret                     ; pop the return address, jump to it

leave is literally mov rsp, rbp followed by pop rbp — it doesn't matter how much space the prologue reserved, because rbp knows where the frame started. That is the frame pointer's payoff: the epilogue is always one instruction, regardless of frame size.

The red zone: 128 bytes you didn't allocate

The System V ABI grants each function a red zone: 128 bytes below rsp that a leaf function (one that calls nothing) may use as scratch without moving the stack pointer. It's safe because nothing asynchronous writes below rsp — signal handlers and hardware interrupts push above it. The compiler uses the red zone whenever a leaf's locals fit:

asm
tiny:
    mov    QWORD PTR [rsp-8], rdi   ; store arg directly in the red zone
    mov    rax, [rsp-8]
    ret

That saves the sub rsp, N / add rsp, N pair — two instructions per leaf call. Windows x64 has no red zone, which is why equivalent code differs between platforms.

Frame pointers vs -fomit-frame-pointer

A frame pointer costs two things: two extra instructions per call (the push rbp / mov rbp, rsp in the prologue, the leave in the epilogue) and a whole register that can't be used for anything else. -fomit-frame-pointer removes both and instead locates locals relative to rsp. The price is invisibility: without rbp chains, unwinding the stack requires DWARF .eh_frame tables emitted by the compiler — and if the binary is stripped, backtraces turn into noise. This is exactly the trade-off distros make when they ship -O2 -fomit-frame-pointer production binaries that produce useless perf stacks, and why kernel and debug builds keep frame pointers on.

The fixed cost of every call

Even the smallest call pays something:

  • call/ret: push and pop of the return address — a memory write and read of the stack, ~1–2 cycles each.
  • Return prediction: the CPU predicts ret targets with a return stack buffer; a mispredicted return costs 15–20 cycles — more than the call itself.
  • The frame, if any: prologue, epilogue, and the cache/TLB pressure of a growing stack.

None of this shows in source. It's why compilers inline aggressively — an inlined call has zero frame cost — and why "it's just a function call" is never a complete sentence about performance.