Activation Records and the Call Stack

Every time a procedure is called, it needs a private scratch space: somewhere to keep its parameters, its local variables, the address to jump back to when it finishes, and whatever registers it must borrow and restore. That bundle of per-call storage is an activation record — also called a stack frame. Because calls nest in strict last-in-first-out order (the last procedure called is always the first to return), these records live on a stack: push one on entry, pop it on exit. This single data structure is what makes procedures, and recursion, possible at all.

The frame is the meeting point of the whole run-time system. The storage organization gave the stack its region of memory; the activation record is the unit that the stack is built from.

Anatomy of a frame

A frame is a small, ordered collection of fields. Compilers vary in the exact order, but a canonical activation record contains these parts, from the caller's side down into the callee's:

FieldPurpose
Actual parametersthe argument values passed in by the caller
Returned valueslot for the result handed back to the caller
Return addresswhere to resume in the caller after this call finishes
Control link (dynamic link)pointer to the caller's frame — restores the stack on return
Saved registers / machine stateregister values to restore so the caller sees them unchanged
Local variablesthe procedure's own automatic variables
Temporariesintermediate values from evaluating expressions

The control link (sometimes dynamic link) is the thread that ties the stack together: each frame points to the frame of the procedure that called it. Following the control links from the top of the stack downward walks the exact chain of active calls — the very thing a debugger prints as a backtrace.

Two pointers run the stack

The machine tracks the stack with two registers. The stack pointer (SP) marks the current top of the stack — the boundary between used and free space. The frame pointer (FP, also called the base pointer) marks a fixed anchor inside the current frame, from which every parameter and local is addressed at a constant offset.

Why two? Because the stack pointer moves during a procedure — pushing temporaries, making nested calls — so offsets from SP would be a moving target. The frame pointer stays put for the whole activation, so the compiler can compile "local x" into a fixed [FP − 8] and "parameter n" into [FP + 16], once, at compile time. The control link is exactly the saved old FP: on return, you restore FP from it and the previous frame snaps back into place.

Reveal the frames one at a time. Here main calls fact(3), which calls fact(2). Each new frame is pushed below the previous one (the stack grows toward low addresses), and each carries a control link pointing back down to its caller's frame. When fact(2) returns, its frame is popped and FP follows the control link back to fact(3).

The call and return sequence

Setting up and tearing down a frame is a little dance split between the two parties. The caller knows the arguments and where it wants to resume; the callee knows its own locals and which registers it will clobber. A calling convention is precisely the contract that decides who does which step — get it wrong on either side and the stack corrupts.

StepDone by
Evaluate arguments, place them where the callee expectsCaller
Save caller-saved registers still needed after the callCaller
Push return address and jump to the calleeCaller (the call instruction)
Save old FP (the control link), set FP to the new frameCallee (prologue)
Allocate space for locals; save callee-saved registers it will useCallee (prologue)
Run the body; place the result in the return slotCallee
Restore callee-saved registers; restore FP from control link; pop the frameCallee (epilogue)
Jump back to the return addressCallee (the ret instruction)
Restore caller-saved registers; read the returned valueCaller

The neat property is symmetry: the epilogue undoes the prologue in reverse, and the caller's cleanup undoes its setup. Do them in the right order and the stack pointer ends exactly where it began, ready for the next call.

Why recursion needs a stack

Recursion is the killer argument for stack-allocated frames. When fact(3) calls fact(2), the value n = 3 must be remembered while n = 2 is being worked on — and then n = 1, and so on. Each active call needs its own copy of n. A single static slot could hold only one; a stack gives every activation a fresh frame, automatically, at whatever depth the recursion reaches.

This simulator makes the frames explicit. It never calls the language's own recursion — it pushes and pops model activation records on an array, exactly as the machine would, and prints the frame at every step:

interface Frame { fn: string; n: number; // the parameter, private to this activation returnTo: string; // where control resumes (the "return address") } const stack: Frame[] = []; function factWithStack(n: number): number { // PROLOGUE: push a fresh activation record. stack.push({ fn: "fact", n, returnTo: n === 0 ? "main" : `fact(n=${n + 1})` }); console.log(`push fact(n=${n}) depth=${stack.length}`); let result: number; if (n <= 1) { result = 1; // base case } else { result = n * factWithStack(n - 1); // nested call: another frame below } // EPILOGUE: pop this activation record and return. stack.pop(); console.log(`pop fact(n=${n}) -> ${result} depth=${stack.length}`); return result; } console.log(`fact(4) = ${factWithStack(4)}`); console.log(`stack empty again? ${stack.length === 0}`);

The output shows four frames stacking up on the way down and unwinding in reverse on the way back — the push order and the pop order are mirror images, which is the LIFO discipline that a stack exists to provide.

You do not strictly have to — many optimizing compilers omit the frame pointer and address everything from SP, freeing that register for general use (the -fomit-frame-pointer flag). The catch is that SP moves whenever the frame changes size (a push, an alloca, a nested-call argument spill), so the compiler must track SP's offset at every program point — doable, but it complicates debugging and stack unwinding, which is why frames used to always keep FP. Modern toolchains split the difference: omit FP for speed, but emit separate unwind tables so debuggers and exception handlers can still reconstruct the call chain.

The single most error-prone part of a calling convention is the register-saving split. Registers are divided into caller-saved (volatile — the callee may freely clobber them, so the caller must save any it still needs) and callee-saved (non-volatile — the callee must preserve them, saving and restoring any it uses). Save a register on the wrong side and one of two bugs appears: a value silently corrupted across a call (nobody saved it), or wasted work (both sides saved it). And the teardown order matters: the epilogue must restore callee-saved registers and the old FP before popping the frame and returning — reverse those and you either return to a garbage address or restore from bytes that are already gone. Prologue and epilogue must be exact mirror images.