TRT
Sign in
Operating Systems

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 Team8 min read#stack-frames#calling-conventions#x86-64#assembly#performance

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.

Production cost model and latency numbers

To apply Stack Frames and Calling Conventions: The Cost of Every Function Call effectively in production systems, engineers must move beyond qualitative diagrams and quantify the exact hardware and network costs at each boundary:

Operation / BoundaryTypical LatencyDominant BottleneckProduction Mitigation
L1 / L2 Cache Access1 ns – 4 nsRegister dependency chains & pointer chasingContiguous memory layouts, cache-line alignment (64 B), and batch iteration
Main Memory (DRAM) Fetch60 ns – 100 nsTLB misses, random heap indirection, NUMA hopsHugePages (2 MB), arena/slab allocators, and pre-allocated ring buffers
Kernel Syscall / Context Switch1 µs – 5 µsUser-to-kernel mode switch, page-table flush, lock contentionNon-blocking readiness loops (epoll / io_uring), batching, and zero-copy I/O
NVMe SSD Random Read / fsync50 µs – 500 µsBlock I/O queue depth, write amplification, WAL flushesGroup commit, sequential append logs, and bounded buffer-pool pinning
Intra-Region RPC Roundtrip0.5 ms – 2.5 msSerialization, TCP/TLS framing, tail queueing ($L = \lambda W$)Connection pooling, deadline propagation, and p95 hedged requests

Failure modes, debugging, and operational checklist

In real incidents, systems rarely fail at average (p50) load—they fail when a slow dependency, skewed hot key, or resource leak pushes concurrency across a non-linear saturation cliff. When reviewing code or architecture that relies on Stack Frames and Calling Conventions: The Cost of Every Function Call, walk through three verification questions:

  1. What is the invariant under partial failure or contention? If a worker crashes mid-operation, a network packet is delayed and reordered, or two threads race on the same state transition, prove which atomic primitive, version number, or idempotency key prevents silent corruption.
  2. How is backpressure enforced when demand exceeds capacity? Unbounded in-memory queues, unthrottled retries, and blocking calls without explicit deadlines turn a localized 100 ms slowdown into a fleet-wide Out-Of-Memory (OOM) cascade. Always bound queue depths, propagate caller deadlines, and shed excess load early.
  3. Which metrics and traces isolate the bottleneck in under 60 seconds? Instrument saturation, queue wait time vs service execution time, cache hit/miss ratios, and error rates by failure reason so on-call engineers can immediately distinguish a compute bottleneck from an I/O or lock-contention stall.

Spotted a mistake or something broken? Report a problem

Not started

Sign in to save your learning progress.

Sign in to save