Why a function needs more than registers
In the last two guides we translated expressions, branches, loops, and pointer access into plain assembly. Every value lived in one of a small handful of registers — maybe thirty-two slots on RISC-V. That was fine for a single stretch of straight-line code. But a real program is a tree of functions that call one another: main calls parse, parse calls read, read calls a helper, and each of them wants to use those same thirty-two registers for its own work. The moment parse calls read, read will trample the very registers parse was still using. We need somewhere to set things down and pick them up again, intact, when control comes back.
There is a deeper problem hiding here, and it is what makes registers alone hopeless. Function calls nest like Russian dolls and unwind in a strict last-in, first-out order: the most recently called function is always the first to finish and return. Whatever structure we use to save and restore registers had better mirror that exact discipline — grow when we enter a function, shrink when we leave, and always release the most recent things first. That structure is the stack, and it is the single idea that turns a fixed pile of registers into a machine that can run programs of unbounded depth.
The stack: a spike of scratch paper
Picture an old-fashioned spike on a desk where you impale notes you might need back soon. You always add the newest note on top and always pull the topmost one off first — last in, first out. The stack is exactly this, carved out of ordinary memory. The hardware does nothing magic for it; the trick is that one register, the stack pointer, simply remembers the address of the top of the spike. To use the stack you only ever change that one register and read or write memory near where it points.
By convention the stack grows downward — toward smaller addresses. To make room for, say, 16 bytes of scratch space you subtract 16 from the stack pointer; to give that space back you add 16. "Pushing" means decrementing the pointer and writing; "popping" means reading and incrementing it back. It feels upside-down at first, but the rule never varies, and that single moving pointer is the whole bookkeeping system. Everything below it is live scratch space a function has claimed; everything above the original starting point is the caller's world, waiting untouched.
The return address: how a function finds its way home
When you call a function you are deliberately handing the program counter away — telling the machine to go run code somewhere else entirely. The obvious question is: how does it ever get back? The answer is the return address, the address of the instruction right after the call. A call does two things at once: it saves that return address, then sets the program counter to the start of the function. When the function is done, it loads the saved address back into the program counter, and execution resumes exactly where the caller left off — like a bookmark that lets you wander into a footnote and return to the precise word you were reading.
RISC-V's call instruction, jal (jump-and-link), captures this beautifully: it jumps to the target and writes the return address into a register (the "link" register) in one step. Returning is then just a jump back to whatever that register holds. But a register can hold only one return address — so what happens when the called function calls another function, which would overwrite it? This is the nesting problem again, and the cure is the stack: a function that will itself make calls must first push its precious return address onto the stack for safekeeping, then pop it back just before returning.
The stack frame: one function, one workbench
Each function call carves out its own contiguous chunk of stack — its stack frame — and treats it as a private workbench for the duration of the call. The frame is the natural home for everything the function needs to survive a nested call: the saved return address, any registers it must preserve, local variables that won't fit in registers (an array, say, or a struct), and arguments being passed onward. When the function returns, it adds the frame's size back to the stack pointer in one stroke and the whole workbench vanishes, leaving the caller's frame exactly as it was.
Often a second register, the frame pointer, is parked at the frame's fixed base on entry. Why bother, when the stack pointer already marks the top? Because the stack pointer may keep moving during the function (pushing arguments for further calls), so distances measured from it shift around. The frame pointer stays nailed to one spot, giving every local variable a fixed, never-changing offset — much easier for both the compiler and a human debugger to reason about. It is optional: optimizing compilers often drop it to free up a register, computing local offsets from the moving stack pointer instead.
high addresses +------------------------+ | caller's frame ... | +------------------------+ <- frame pointer (fixed base of this frame) | saved return address | | saved registers | | local array[ ] | | outgoing arguments | +------------------------+ <- stack pointer (top; moves during the call) low addresses (stack grows downward, toward smaller addresses)
Arguments and the caller/callee bargain
Calling a function means handing it inputs and getting back a result, and everyone must agree on where those values live — which register holds the first argument, which holds the return value. That agreement is part of the calling convention we will study in depth in the next guide. The fast path is registers: the first several arguments ride in designated registers, and the result comes back in a designated register, with no memory traffic at all. Only when there are more arguments than the agreed registers, or an argument is too big, does the caller spill the overflow onto the stack.
There remains the trampling problem: while a function runs, it freely uses registers, and some of those registers held values the caller still cares about. The convention solves this with a clean treaty that splits the registers into two camps. Caller-saved registers are fair game — a function may clobber them, so if the caller wants a value to survive a call, it must save it first. Callee-saved registers carry a promise: a function that uses one must restore its original value before returning, so the caller can trust those stay intact across the call. This division of labour means neither side has to save everything, only its agreed share — and the saving, when needed, happens on the stack frame.
Recursion: the same code, many frames
Now the payoff. A recursive function calls itself, and the stack makes this feel almost ordinary. Each call gets its own fresh frame — its own copy of the locals and the return address — stacked atop its parent's. Take factorial(3): the call to factorial(3) pushes a frame, then calls factorial(2) which pushes another, which calls factorial(1) which pushes a third. The stack now holds three independent frames of the same code, each remembering its own n and where to return. As each base-or-finished call returns, its frame pops off and the answer flows back up through the parents, exactly reversing the way they were pushed.
- Call factorial(3): push a frame holding return address and n=3; since 3 is not the base case, call factorial(2).
- Call factorial(2): push a second frame with n=2; not the base case, so call factorial(1). The stack now has two frames.
- Call factorial(1): push a third frame with n=1; this IS the base case, so return 1, and pop this frame.
- Back in factorial(2): compute 2 x 1 = 2, return 2, pop its frame. Back in factorial(3): compute 3 x 2 = 6, return 6, pop. The stack is empty and the answer is 6.
This is why recursion needs no special hardware: it is just the ordinary call-and-return mechanism applied to the same function, with the stack quietly holding one frame per live call. It also reveals the cost. Each call consumes more stack, and the stack is finite — runaway recursion with no reachable base case simply keeps pushing frames until it runs off the end, the dreaded stack overflow. The stack is the elegant, bounded resource that turns a fixed set of registers into a machine of unlimited depth, but bounded means bounded: depth still has a ceiling.