Procedure Calls and the Stack

Every time a program calls a function, the processor must remember where to resume afterward. It must hand off arguments. Eventually, it must restore the caller's world exactly as it was. This sounds simple, but it hides a genuine engineering problem. Functions can call other functions, which call other functions, recursively and unpredictably. The hardware provides no built-in notion of "function" at all. The solution that every modern architecture converges on is the stack. It is a simple, disciplined region of memory managed by convention rather than by special hardware. It is paired with a calling convention that both caller and callee agree to obey. This matters because procedure calls are the backbone of all structured software. Every high-level language construct — function calls, recursion, exceptions, closures, virtual dispatch — ultimately compiles down to something specific. It becomes a sequence of instructions that manipulate a stack pointer, save registers, and jump. Compiler writers, systems programmers, and security engineers all need a precise mental model of stack frames. It explains why recursion has a depth limit. It explains why stack-based buffer overflows are possible. It explains why debuggers can print a call chain. It also explains why some function calls are dramatically cheaper than others. The big picture is this: the Instruction Set Architecture gives you a handful of primitives. These include a dedicated register acting as the stack pointer, instructions to jump to a subroutine and jump back, and ordinary load/store instructions. Everything else is convention. There is no "call frame" instruction in most ISAs. There is only an agreement, called the Application Binary Interface (ABI). It specifies which registers hold arguments, which registers survive a call, and how the stack grows and shrinks. Understanding this convention is what lets code compiled by different compilers, or written in different languages, call each other correctly. This article builds the stack frame from first principles. It explains why a stack (rather than, say, a fixed table) is the right data structure for nested calls. It shows how the prologue and epilogue of a function are constructed. It covers how registers are partitioned into caller-saved and callee-saved sets. Finally, it shows how all of this generalizes toward the mechanism used for Exceptions and Interrupts, which is really just a forced, hardware-triggered procedure call.

Key takeaways
  • The stack is a LIFO memory region that naturally matches the nested, last-in-first-out structure of procedure calls.
  • A stack frame (activation record) holds a function's return address, saved registers, local variables, and spilled arguments.
  • The calling convention (ABI) fixes which registers pass arguments/return values and which are caller-saved vs. callee-saved.
  • Prologue and epilogue code bracket every function body to allocate and tear down its frame in constant extra time.
  • Correctness rests on one invariant: the stack pointer and frame layout are restored to their pre-call state before returning.
  • This same "save state, jump, restore state" pattern reappears, hardware-forced, in Exceptions and Interrupts.
The core insight

A procedure call is nothing more than a disciplined, mutually-agreed protocol for saving state on a stack before transferring control, and restoring it before transferring control back.

The idea

A procedure call transfers control from a caller to a callee and expects control to return afterward, resuming exactly where it left off. The hardware alone cannot guarantee this: a `jump` instruction has no memory of where it came from, and if procedure A calls B which calls C, the processor needs a place to keep multiple pending return addresses simultaneously, one per level of nesting. Because calls nest and unwind in last-in-first-out order — the most recently called procedure is the first to return — a stack is the natural data structure: push on entry, pop on exit, and the LIFO discipline automatically matches call nesting to return order. Concretely, the stack lives in ordinary memory, and one general-purpose register is reserved as the stack pointer (SP), conventionally pointing to the most recently pushed word. Many architectures also use a frame pointer (FP), sometimes called the base pointer, that stays fixed for the duration of one procedure's execution and gives a stable reference for accessing that procedure's arguments and local variables even as SP moves during the procedure's own pushes and pops. The contiguous block of stack memory belonging to one active call is its stack frame or activation record; it typically contains the return address, saved register values, local variables that don't fit in registers, and sometimes incoming arguments that didn't fit in argument registers. The rules that make all of this interoperate — which registers carry arguments, which carry the return value, which the callee must preserve, how the stack grows (typically downward, toward lower addresses, in most modern ISAs), and who is responsible for cleaning up pushed arguments — constitute the calling convention, formalized as part of the platform's ABI. The ABI is a social contract: nothing in the hardware enforces it, but if a compiler violates it, code compiled by another compiler (or a different function in the same program) will read garbage from the wrong registers or the wrong stack slots. This is precisely why the same convention persists across compilers on a given platform (e.g., the RISC-V calling convention, or the x86-64 System V ABI), enabling separate compilation and linking.

How it works

A procedure call consists of four coordinated phases, split between caller and callee: 1. Caller setup — place arguments in agreed argument registers (or push overflow arguments on the stack), then execute a call instruction that (a) saves the return address (often in a link register or by pushing it) and (b) jumps to the callee's first instruction. 2. Callee prologue — the callee allocates its own stack frame by decrementing SP, saves any callee-saved registers it intends to overwrite, and optionally sets up a frame pointer. 3. Callee body and epilogue — the callee executes its logic, places its result in the return-value register, restores the callee-saved registers it saved, deallocates its frame by restoring SP, and returns control via a return instruction that jumps to the saved return address. 4. Caller cleanup — the caller resumes at the instruction after the call, retrieves the return value, and (in some conventions) removes any stack arguments it pushed. The essential invariant across this whole dance is: > The stack pointer, and every register the callee promised to preserve, must have the same value immediately after the call returns as immediately before the call was made (aside from the return-value register and any register explicitly used to pass the result). Everything else — how many registers you use for arguments, whether you use a frame pointer, whether locals live in registers or on the stack — is an optimization detail layered on top of that one guarantee. ``` // Generic RISC-style calling convention. // Convention (illustrative, RISC-V-like): // a0..a7 = argument / return-value registers (caller-saved) // t0..t6 = temporary registers (caller-saved) // s0..s11 = saved registers (callee-saved) // ra = return address register // sp = stack pointer (grows toward lower addresses) CALL(callee, args...): // --- Caller side --- place args[0..k] into a0..a(k) // registers first if extra args beyond register count: push remaining args onto stack // in reverse, so first extra arg is on top jal ra, callee // "jump and link": ra <- PC+4 ; PC <- callee // control returns here after callee executes RET result <- a0 // read return value if extra args were pushed: sp <- sp + (bytes pushed) // caller cleans up (varies by convention) PROCEDURE callee: // --- Prologue --- sp <- sp - FRAME_SIZE // allocate frame (return addr + saved regs + locals) store ra -> [sp + RA_OFFSET] // save return address if callee itself calls out store s0 -> [sp + S0_OFFSET] // save any callee-saved regs this function will clobber store s1 -> [sp + S1_OFFSET] fp <- sp + FRAME_SIZE // (optional) frame pointer for stable local access // --- Body --- // ... use a0..a7 as incoming arguments ... // ... use t0..t6 freely, no need to save ... // ... use s0, s1 for values that must survive nested calls ... // ... nested call, if any: recurses into CALL(...) exactly as above ... a0 <- computed_result // place return value // --- Epilogue --- load s0 <- [sp + S0_OFFSET] // restore callee-saved regs load s1 <- [sp + S1_OFFSET] load ra <- [sp + RA_OFFSET] // restore return address sp <- sp + FRAME_SIZE // deallocate frame jr ra // "jump register": PC <- ra ``` The prologue/epilogue pair is symmetric by construction: whatever the prologue subtracted from `sp`, the epilogue adds back; whatever registers the prologue pushed, the epilogue pops in matching slots. This symmetry is exactly what re-establishes the invariant above, and it is why a stack frame can be thought of as a self-contained, self-cleaning unit — the callee leaves no trace on the stack once it returns, regardless of how much work happened in between, including arbitrarily deep recursive calls.

Figure
Anatomy of a stack frame during a nested call chain (main → f → g)
Stack growth (downward) during main() → f() → g() high addr low addr main()'s frame locals of main saved regs (s0..) f()'s frame return addr (into main) saved regs args to g / locals of f g()'s frame ← current SP return addr (into f) saved regs locals of g sp fp of f (frozen) main calls f: f calls g: g returns to f, frame popped

Note the growth direction: as calls nest deeper, `sp` moves toward lower addresses, and each frame sits directly below the frame of its caller. Returning simply moves `sp` back up, effectively deallocating the topmost frame in O(1) time regardless of what that frame contained.

Complexity & correctness

Time. Each call costs a constant number of extra instructions independent of program size or recursion depth: one instruction to jump-and-link, a handful of stores in the prologue (bounded by the number of registers the callee chooses to save, a small constant, typically 0–15), and a matching handful of loads in the epilogue plus one jump-register to return. So the overhead of a single call/return pair is O(1). A program that makes nn calls in total (including recursive calls) therefore pays O(n) total overhead for call/return mechanics, on top of whatever work each call body does — this is why recursion is not inherently slow; the bookkeeping is cheap, and any slowness comes from the algorithm's own recursive structure (e.g., naive Fibonacci's exponential blow-up is a algorithmic issue, not a stack-mechanism issue).

Space. Each active (not-yet-returned) call occupies one frame of fixed size determined by that function's needs. If the maximum simultaneous call depth is dd, the stack uses O(d · s) space, where ss is the (per-function) frame size — this is why unbounded or excessively deep recursion causes a stack overflow: the stack region is finite (a fixed-size memory segment, typically megabytes), and depth is effectively bounded by (stack size) / (average frame size).

Correctness argument. The key invariant to prove is: after `jr ra` executes in the callee, the machine state (SP, callee-saved registers, and the return address stream) matches what a call-free execution would have produced, except for the agreed-upon changes (return value in `a0`, memory side effects). The argument is by structural induction on call nesting:

  • Base case: a leaf procedure that makes no further calls. Its prologue saves exactly the callee-saved registers it modifies and decrements `sp` by its frame size; its epilogue restores those registers and restores `sp` by the same amount before `jr ra`. Since these are exact inverses executed on the same, unaliased memory region (the frame it just allocated), the net effect on `sp` and callee-saved registers is zero, satisfying the invariant trivially.
  • Inductive step: assume every procedure the callee calls satisfies the invariant (preserves `sp` and callee-saved registers across its own call/return). The callee's body issues zero or more such calls; by the inductive hypothesis, each one leaves `sp` and callee-saved registers as they were immediately before that call. Hence between calls, the callee's local view of `sp`/registers evolves only due to its own prologue/epilogue arithmetic, which — as in the base case — is self-canceling. Therefore the whole procedure, including all its nested calls, preserves the invariant.

This induction is exactly why separately-compiled functions, unaware of each other's internals, can call each other correctly: each only needs to trust that the ABI contract is upheld, not know how the callee is implemented. The correctness of the whole system reduces to each function locally upholding one register-preservation contract — a beautiful instance of compositional reasoning enabled by convention rather than hardware enforcement.

Tradeoffs. Caller-saved vs. callee-saved registers is a genuine time/space tradeoff: caller-saved registers avoid needless saves when the caller doesn't need a value across the call (good for temporaries), but force a save+restore around every call site that does need the value preserved. Callee-saved registers push the save/restore into the callee, done exactly once regardless of how many call sites call it — better when a function is called from many places and needs to preserve values for its callers. Real ABIs split registers into both categories to balance this. Using a frame pointer costs one extra register and a few instructions but simplifies debugging (stack walking, unwinding) and variable-length stack frames (e.g., `alloca`); omitting it ("frame-pointer omission") frees a register for general use at the cost of harder stack introspection.

Worked example

Consider this small program, computing `f(3)` where `f` calls a helper `g`: ``` int g(int x) { return x x; } int f(int n) { int t = g(n) + 1; // must survive the call to g, so t's source* n needs to be preserved return t; } int main() { return f(3); } ``` We trace register/stack state using the convention from the pseudocode above (`a0` = arg/return, `s0` = callee-saved, `ra` = return address, `sp` = stack pointer; stack grows downward; assume each frame is 16 bytes for simplicity, aligned).

StepInstruction (conceptual)spraa0s0Stack contents (top → down)
0`main` calls `f(3)`0x7F00(main's ra)3—(main's frame)
1`jal ra, f`0x7F00ra→main3—(main's frame)
2`f` prologue: `sp -= 16`0x7EF0ra→main3—[empty 16B for f]
3`f` saves `ra`, `s0`; `s0 ← a0` (n=3)0x7EF0ra→main33[ra→main][s0=3]
4`f` sets up call to `g(n)`: `a0 ← s0`0x7EF0ra→main33[ra→main][s0=3]
5`jal ra, g`0x7EF0ra→(after step5 in f)33(f's frame still intact below)
6`g` prologue: `sp -= 16`0x7EE0ra→f33[g's frame: empty]
7`g` body: `a0 ← a0 * a0` (3×3=9)0x7EE0ra→f93[g's frame]
8`g` epilogue: `sp += 16`, `jr ra`0x7EF0ra→f (consumed)93back to f's frame
9back in `f`: `a0` holds 9 (g's result)0x7EF0—93[ra→main][s0=3]
10`f` computes `t = a0 + 1 = 10`; `a0 ← t`0x7EF0—103[ra→main][s0=3]
11`f` epilogue: restore `s0`, restore `ra`, `sp += 16`, `jr ra`0x7F00ra→main10(restored)(main's frame)
12back in `main`, `a0 = 10`0x7F00—10——

Two things are worth emphasizing from this trace. First, notice at step 5: `f` had to save `n` into the callee-saved register `s0` before calling `g`, because `g` is free to clobber `a0` (a caller-saved/argument register) and even `t0`–`t6`, but is contractually forbidden from clobbering `s0` without restoring it — that's exactly what lets `f` retrieve `n`'s value's role safely across the nested call, even though in this case it doesn't need `n` again (a more elaborate example — `g(n) + n` — would make this necessity obvious, since `a0` is destroyed by `g`'s own return value). Second, notice that `sp` returns to exactly 0x7F00 after `f` returns to `main`, and to exactly 0x7EF0 after `g` returns to `f` — precisely the invariant proved above, holding at every level of nesting.

Common pitfalls & traps

PitfallWhat goes wrongFix
Forgetting to save `ra` before a nested callIf a function calls another function without saving its own return address, the nested call's `jal` overwrites `ra`, and the outer function can no longer find its way back — it returns to the wrong caller or crashes.Always push/store `ra` in the prologue before any call inside the body, unless the function is a leaf (makes no calls).
Mismatched prologue/epilogue frame sizeIf the epilogue restores `sp` by a different amount than the prologue subtracted, every enclosing frame becomes misaligned, corrupting locals and return addresses further up the stack.Compute `FRAME_SIZE` once and use the identical constant in both prologue and epilogue; compilers automate this.
Clobbering a callee-saved register without saving itUsing `s0` for a scratch value without a save/restore pair silently corrupts the caller's long-lived value, producing a bug that appears far from its cause.Treat the callee-saved set as "borrow, then return exactly as found" — always bracket use with save/restore.
Assuming caller-saved registers survive a callReading a temporary register (e.g., `t0`) right after a call, expecting it to hold a pre-call value, silently reads garbage because the callee was free to overwrite it.Move any value you need after a call into a callee-saved register, or re-derive it, before making the call.
Stack growing into another region (overflow)Unbounded or too-deep recursion pushes `sp` past the bottom of the allocated stack segment, overwriting adjacent memory (heap, or another thread's stack) or faulting.Bound recursion depth, convert to iteration, or increase stack size; guard pages typically turn this into a detectable fault rather than silent corruption.
Off-by-one in stack pointer arithmetic / alignmentMany ABIs require `sp` to be aligned (e.g., 16-byte aligned on entry to a call); a frame size that violates this can corrupt vectorized loads/stores or crash on strict architectures.Round frame sizes up to the required alignment; let the compiler's calling-convention code handle this rather than hand-rolling it.

Pioneers & history

The stack-based procedure call, as a hardware-supported convention, crystallized alongside the earliest stored-program computers and the first high-level, block-structured languages that needed to support recursion and nested scopes — most influentially ALGOL 60, whose formalization of the block structure required exactly the LIFO activation-record discipline described here.

Origins · 1 minFriedrich L. Bauer and how this was discovered
FL
Friedrich L. Bauer
1924–2015 · Munich, developer of the stack principle and co-designer of ALGOL

Bauer is widely credited with formalizing the stack (Kellerspeicher, "cellar store") as a computational mechanism in the mid-1950s, recognizing that nested arithmetic expressions and, soon after, nested procedure calls both demand LIFO storage discipline. His work directly shaped how ALGOL 60 — and every stack-frame-based language since — manages recursion and local scope.

History
- 1955–57 — Friedrich Bauer and Klaus Samelson patent and describe the stack principle for expression evaluation.
- 1960 — ALGOL 60 formalizes block structure and recursive procedures, requiring runtime stack-based activation records.
- 1964 — IBM System/360 standardizes general-purpose registers and calling conventions across a commercial product line.
- 1980s — RISC architectures (MIPS, SPARC, and later ARM) formalize explicit caller-saved/callee-saved register conventions as part of published ABIs.
- 2000s–present — Compiler-enforced stack canaries, non-executable stacks (NX/DEP), and shadow stacks harden the calling convention against control-flow-hijacking exploits.

Where this matters

Every compiled language runtime — C, C++, Rust, Go, Java's JIT, Python's CPython interpreter internals — relies on stack frames to implement function calls, recursion, and local variable scoping; this is the mechanism a debugger reads when it prints a backtrace. Operating system kernels rely on the exact same save-state/restore-state discipline, generalized, to implement context switches between threads and to handle Exceptions and Interrupts, which can be viewed as a forced procedure call triggered by hardware rather than by an explicit instruction. Compiler backends (LLVM, GCC) contain dedicated calling-convention modules per target ISA, precisely encoding which registers are caller-/callee-saved and how frames are laid out, because getting this wrong breaks interoperability between separately compiled object files. Security hardening features — stack canaries, shadow stacks, control-flow integrity — exist specifically to detect or prevent violations of the stack-frame invariant described above, making a precise understanding of calling conventions foundational to systems security as well as to compiler construction and processor design.

Ask the tutor: How does a hardware interrupt's forced "call" differ from a normal procedure call in what state must be saved?

Why do we need both a stack pointer and a frame pointer?

The stack pointer (`sp`) marks the current top of the stack and moves whenever the running function pushes or pops values — including during its own body, e.g., for temporary spills. This makes `sp` a moving target for referencing a specific local variable by a fixed offset. A frame pointer (`fp`) is set once, at the start of the function, and stays fixed for that invocation, giving a stable base from which all of that function's locals and arguments can be addressed by constant offsets regardless of later stack movement within the body. Many modern compilers omit the frame pointer when possible (computing offsets from `sp` directly via static analysis) to free up a register, trading debuggability for a small performance/register gain.

What is the actual difference between caller-saved and callee-saved registers?

Caller-saved (also called volatile or temporary) registers may be freely overwritten by any function it calls; if the caller needs that value after the call, it must save it itself before calling. Callee-saved (saved/non-volatile) registers must have the same value after a call as before it; if a callee wants to use one internally, it must save the original value in its prologue and restore it in its epilogue. This split balances save/restore cost: values that rarely need to survive calls go in caller-saved registers (avoiding needless saves), while values a function relies on across many nested calls go in callee-saved registers (saved once per call, not once per call site).

Does every procedure call require a full stack frame with saved registers?

No — a common and important optimization is the leaf function, one that makes no further calls. Since it never needs to preserve a return address across a nested call (there is none), and it may need very few or no callee-saved registers, its prologue/epilogue can be trivial or entirely eliminated, keeping arguments and results in registers throughout. Compilers actively detect leaf functions and minimize their stack usage; this is part of why small utility functions are often extremely cheap to call, and why aggressive inlining of leaf functions is such a common optimization.

How does this relate to recursion depth limits in languages like Python or C?

Recursion depth limits exist precisely because each recursive call allocates a new stack frame, consuming stack space proportional to the frame size, and the stack occupies a fixed-size memory region (often a few megabytes by default). A recursive function with an O(1)-sized frame recursing to depth dd uses O(d) stack space; once that exceeds the available stack region, the program faults — a stack overflow. Languages sometimes impose an explicit recursion-depth ceiling (like CPython's default ~1000) as an early, controlled failure rather than letting the OS deliver a raw segmentation fault, and some languages support tail-call optimization to convert certain recursive patterns into loops that reuse a single frame.

Who is responsible for cleaning up stack-passed arguments, the caller or the callee?

This varies by convention. In "cdecl"-style conventions (historically common in C on x86), the caller removes pushed arguments after the call returns, which allows variadic functions (like `printf`) because the callee doesn't need to know the argument count at compile time. In "pascal" or "stdcall"-style conventions, the callee removes them as part of its epilogue, which produces smaller code at each call site since the cleanup code is written once, in the callee, rather than duplicated at every call site. Modern register-heavy ABIs (like RISC-V's or ARM's) minimize this question by passing most arguments in registers, only falling back to stack passing when argument registers are exhausted.

How does this generalize toward exceptions and interrupts?

An interrupt or exception is, at its core, a procedure call the hardware forces onto the processor asynchronously: it must save enough state (at least the program counter, often flags and some registers) to resume the interrupted code later, then jump to a handler, and eventually restore that state to resume exactly where execution left off. The key difference from an ordinary call is that the interrupted code did not choose to make the call and doesn't know its registers might be touched, so the hardware and OS must save more state, often including registers the running code was actively using without any register being "caller-saved" by agreement. This deeper generalization of the save/jump/restore pattern is developed fully when studying Exceptions and Interrupts.

Why does the stack conventionally grow downward, toward lower addresses?

This is a historical and practical convention rather than a mathematical necessity: it allows the stack (growing down from high addresses) and the heap (growing up from low addresses, in traditional flat memory layouts) to occupy the same address space from opposite ends, using the available memory efficiently without a fixed a priori split. Many architectures (x86, ARM, RISC-V) adopt "stack grows down" as part of their standard calling convention, meaning `push` decrements `sp` and `pop` increments it. Some architectures historically supported both directions or configurable growth, but downward growth combined with an upward-growing heap remains the dominant convention in modern systems.

Before this