Programming Languages · Memory and Ownership

Stack Frames and the Lifetime of an Automatic Variable

How a function call turns scope into a pointer adjustment, what the frame really holds, and why a local object dies exactly when its block exits.

A local variable costs nothing to create. No allocator is called, no free list is searched, no header is written. Something still has to decide where the variable lives and when that storage stops being the variable, and in most compiled languages the entire decision is made by a single register and a compile-time constant. Understanding that arrangement explains why scope exit is free, why a pointer to a local is invalid the moment the function returns, and why some objects cannot live on the stack at all.

A call is a pointer move

On x86-64 under the System V ABI, the first six integer arguments travel in registers (rdi, rsi, rdx, rcx, r8, r9). The call instruction pushes an 8-byte return address onto the stack and jumps. The callee’s prologue then subtracts a fixed amount from the stack pointer: sub rsp, 48, for instance. That subtraction is the allocation. The epilogue adds the same constant back and returns.

The constant is the frame size, computed by the compiler as the sum of every local, every spill slot for arguments that did not fit in registers, and every callee-saved register the function clobbers (rbx, rbp, r12 through r15). Because it is a constant, the address of a local is an immediate offset from the stack pointer, encoded directly in the instruction that touches it. A store to a local is a mov [rsp+16], eax with no address computation at runtime.

Two ABI details shape the layout. The stack must be 16-byte aligned at the point of a call, so frame sizes are rounded up to a multiple of 16; a function needing 40 bytes reserves 48. And on x86-64 System V, the 128 bytes below the stack pointer form a red zone that signal handlers and interrupts are forbidden to touch, letting a leaf function use that space without adjusting rsp at all. Windows on x64 has no red zone and instead requires the caller to reserve 32 bytes of shadow space for the callee to spill its register arguments into.

What the frame actually contains

At function entry the return address pushed by call sits at the lowest address of the callee’s frame area. Above it the callee pushes the old frame pointer and then the callee-saved registers it is going to disturb, and only then reserves space for locals and spills. The result is a contiguous region described at positive offsets from the frame pointer. Debuggers walk the stack because each frame records, in the saved frame pointer slot, the value of the frame pointer in the frame below it; following that chain visits every caller in turn.

Aggregates complicate this. A struct too large to return in registers is returned through a hidden first argument, a pointer supplied by the caller to space it allocated in its own frame. On System V, the threshold for register return is two eightbytes, so a struct of three longs already switches to the hidden-pointer convention and shifts every subsequent argument register by one.

The C standard describes this storage class abstractly. An object with automatic storage duration has a lifetime that begins when execution enters the block that declares it and ends when execution leaves that block. Nothing in the standard mentions a stack; the implementation is free to place such objects anywhere, provided the lifetime rule holds. In practice the stack is the only structure that gives away storage in exactly the LIFO order the rule requires, which is why the two are almost always the same thing.

The rule has visible exceptions. A variable-length array is an ordinary automatic object, and its lifetime still ends when execution leaves the block that declares it; but its size is known only at run time, so the compiler cannot fold the allocation into a constant frame size and must move the stack pointer on entry to the block. alloca goes further and is outside the C standard entirely: the storage it returns lasts until the allocating function returns, not merely until the enclosing block exits, so the lifetime it actually has is not the lifetime of the enclosing block. The divergence between the standard’s model and the implementation is also why variable-length arrays became optional in C11.

Why scope exit is free

Because the frame is one region, exiting an inner block emits no code. The compiler has already reserved all the space; leaving a block just means the compiler stops treating a subrange of the frame as live. This is what allows slot reuse:

#include <stdio.h>

int main(void) {
    { int a = 1; printf("%d\n", a); }
    { int b = 2; printf("%d\n", b); }
    return 0;
}

With optimizations enabled, a and b normally receive the same address. The C abstract machine permits this because the two lifetimes do not overlap, and a pointer to a dead object may not be dereferenced anyway. Two live objects never share an address; a dead object’s address is free to be recycled by the next variable whose lifetime begins in the same frame.

That reuse is the mechanism behind the most common stack bug. Returning a pointer to a local hands the caller an address whose object has ceased to exist:

int *bad(void) {
    int local = 42;
    return &local;   /* the slot belongs to the frame, and the frame is gone */
}

Compilers diagnose the direct form (GCC reports it under -Wreturn-local-addr), and the returned value is unusable even though the memory often still holds 42 at the moment of the first read. The next call at the same depth will typically reuse those bytes for its own locals, so the read silently observes an unrelated value. The rule is about provenance rather than bytes: a pointer is only valid for the object it was derived from, for as long as that object exists, and optimizers exploit exactly this assumption.

The same reasoning makes stack addresses poor identities. Address space layout randomization shifts the stack base at process start, and the address of a given local changes with call depth, so an address taken in one call is not a stable key across calls.

Cleaning up what the pointer move leaves behind

Restoring the stack pointer reclaims memory but not resources. If a local owns a heap buffer, an open file descriptor, or a lock, moving rsp leaks all three. A language that wants scope exit to reclaim resources must attach code to it, and that code has to run on every exit path.

C++ destructors are the canonical form. For each object that is fully constructed when an exit out of the block happens, the compiler emits cleanup code, and that code runs on normal return, on early return, on goto out of a block, and during exception propagation. Normal exits cost a straight-line sequence of destructor calls, often merged into a single epilogue that falls through to the return. Exceptions cannot be handled that way, because a throw can occur anywhere below the handler: the compiler emits tables mapping instruction ranges to landing pads, and the unwinder walks the frames between the throw point and the handler consulting those tables. That is why exception-enabled binaries carry .eh_frame and .gcc_except_table sections and why unwinding is dramatically slower than returning: the work is proportional to the number of frames crossed, not to a single pointer addition.

Rust reaches the same place by a different route. Values with a destructor get drop glue emitted at each scope exit, including on the unwind path; a build configured to abort on panic omits the unwinding path entirely, so a panic ends the process and the destructors of the frames it crosses do not run. Go defers cleanup to defer statements recorded per frame and run at return.

The three structural limits

The stack model imposes constraints that no amount of compiler cleverness removes.

Size is fixed at compile time. This follows directly from the prologue subtracting a constant. Variable-length arrays and alloca break the assumption and make the frame size dynamic, which is why both are treated with suspicion: a loop that calls alloca on each iteration grows the frame without bound and will eventually cross the guard page.

Deallocation order is the reverse of allocation. A frame cannot release one local early while keeping another, because there is only one pointer to restore. Lexical scoping happens to match this discipline, but a short-lived block that allocates a long-lived buffer does not.

Stack space is per-thread and small. The main thread’s stack on Linux is bounded by the RLIMIT_STACK soft limit, commonly 8 MiB; on Windows the default reserve for the main thread is 1 MiB. Threads created by the runtime get whatever size the creator asks for, which for a default is often a few megabytes or less. Exceeding the limit touches a guard page and the kernel delivers SIGSEGV on Linux or raises a structured exception on Windows. Neither is recoverable in portable code, and a recursive function without a depth bound converts a bad input into a process death rather than an error return.

Where the model hands over

Anything that outlives its frame, has a size known only at runtime, or must be shared across threads needs a different discipline. That is heap territory, and the reclamation strategies for it are what the rest of this cluster covers: move semantics and affine types make the transfer of heap ownership checkable, destructors and unwinding make reclamation automatic, and arena allocation shows how manual C code recovers some of the stack’s cheapness on the heap.

The trade is stark and worth stating plainly. A stack allocation is one subtraction of a constant, with no metadata, no free-list traversal, and locality that the hardware prefetcher handles for free. A heap allocation is a call into an allocator that must find a block, write a header of typically 8 to 16 bytes, and possibly split a larger block, with a floor cost in the tens of cycles at best. What the heap buys is dynamic size, arbitrary free order, and sharing across threads. Languages differ mainly in where they draw that boundary: Go’s escape analysis moves whatever it can to the stack, while Rust’s ownership rules fix the decision statically, so whether a value lives in a frame or behind a Box is visible in the type. How escape analysis removes a heap allocation covers the proof that makes the first of those possible.

References

  1. System V Application Binary Interface: AMD64 Architecture Processor Supplement — x86-64 psABI project
  2. ISO/IEC 9899:2018 — Programming languages — C — ISO/IEC
  3. The Rustonomicon — The Rust Project