Procedure Calls and the ABI

A CPU instruction set gives you arithmetic, loads, stores and branches — but nowhere in the RISC-V hardware is there a built-in notion of a "function". Functions, arguments, return values, local variables, recursion — all of it is a convention layered on top of a few simple instructions. That convention is the ABI (Application Binary Interface): the agreement about how code calls code. It is the ISA contract's social contract — the reason a function your compiler emits can call a function from a library compiled years earlier by a different compiler, and they cooperate perfectly.

The link register: how to come back

The heart of a call is a question: after the function finishes, where do we resume? RISC-V answers with the jump-and-link instruction, jal. It does two things at once: it jumps to the function, and it saves the address of the following instruction — the return address — into a register, by convention ra (which is x1). To return, the callee just jumps back to whatever is in ra, using jalr x0, 0(ra) (aliased ret).

; Caller wants to invoke square(), then keep going. li a0, 7 ; put argument 7 in a0 jal ra, square ; jump to square; ra <- address of the NEXT line ; ...execution resumes HERE when square returns, result in a0... square: mul a0, a0, a0 ; a0 = a0 * a0 (result goes in the return register) ret ; == jalr x0, 0(ra): jump back to the saved return address

For a leaf function like square — one that calls nothing else — that is the entire story: no memory needed. The trouble starts when a function must call another function, because the second jal would overwrite ra, erasing the way home. The fix is the stack.

The stack frame

Each active function call gets a slab of memory called a stack frame (or activation record). The stack pointer sp marks the current top; a call pushes a new frame by moving sp down (stacks grow toward lower addresses), and a return pops it by moving sp back up. Inside a frame the function keeps everything it must remember across an inner call: the saved return address, saved registers, and its local variables — each at a fixed displacement from sp.

A non-leaf function's job at entry (its prologue) is to carve out this frame and stash anything precious; at exit (its epilogue) it restores those values and gives the space back:

sumTo: ; int sumTo(n): returns n + sumTo(n-1), recursively addi sp, sp, -16 ; PROLOGUE: push a 16-byte frame sw ra, 8(sp) ; save return address (we will make an inner call) sw s0, 0(sp) ; save callee-saved s0 so we can use it ; ... body uses s0, recurses with jal ra, sumTo ... lw s0, 0(sp) ; EPILOGUE: restore s0 lw ra, 8(sp) ; restore return address addi sp, sp, 16 ; pop the frame ret ; return to caller

Caller-saved vs callee-saved: dividing the labour

Here is the ABI's cleverest idea. Registers are shared by everyone, so when A calls B, some register B clobbers might hold a value A still needs. Who is responsible for saving it? Saving every register on every call would be wasteful. So the ABI splits the 32 registers into two camps by convention:

ClassRISC-V namesRulePreserved across a call?
Caller-saved (temporaries)t0t6, a0a7callee may freely clobber; caller must save if it caresNo
Callee-saved (saved)s0s11, spcallee must restore before returningYes
Arguments / returna0a7 (a0/a1 also return)first 8 args here; results in a0/a1No (they carry the payload)
Return addressraset by jal; save it before an inner callcaller-saved by convention

The names encode the deal. If you keep a value in a temporary and then make a call, expect it to be gone — save it yourself (you are the caller). If you keep it in a saved register, you may trust it survives — but if you want to use an s register you must save the previous owner's copy first (you are now the callee). Every register is somebody's responsibility, and no value is ever silently lost.

Recursion is just stacked frames

Recursion needs no special hardware — it falls out for free. Each recursive call pushes its own frame, with its own saved ra and its own locals, so a hundred nested calls simply means a hundred frames stacked in memory. When each returns, it pops its frame and restores the caller's state exactly. The simulation below models the stack explicitly for \text{fact}(n) = n \times \text{fact}(n-1), pushing a frame on the way down and popping on the way up, so you can watch the stack pointer dive and climb back.

// Model the call stack for fact(n) = n * fact(n-1), fact(0) = 1. // Each frame records n and the saved return address; sp moves in 16-byte steps. interface Frame { n: number; ra: string; } const stack: Frame[] = []; let sp = 1000; // stack pointer, grows DOWN const FRAME = 16; function callFact(n: number, ra: string): number { sp -= FRAME; // prologue: push frame stack.push({ n, ra }); console.log(`push fact(${n}) sp=${sp} depth=${stack.length}`); let result: number; if (n <= 1) { result = 1; // base case } else { result = n * callFact(n - 1, "fact+8"); // recurse: inner call } stack.pop(); // epilogue: pop frame sp += FRAME; console.log(`pop fact(${n})=${result} sp=${sp}`); return result; } console.log("fact(4) =", callFact(4, "main")); console.log("stack is empty again:", stack.length === 0);

Every recursive call pushes a frame and moves sp down; nothing pushes it back up until a call returns. Infinite (or just too-deep) recursion therefore marches sp relentlessly downward until it runs off the end of the region the OS reserved for the stack — often bumping into a deliberately unmapped guard page. The load or store into that forbidden page faults, the OS sees it, and your program dies with "stack overflow" (the website was named after the error). It is the physical shadow of an unbounded recursion: each missing base case is another 16 bytes closer to the cliff. This is also why deep recursion is dangerous in systems code and why compilers prize tail-call optimisation, which reuses one frame instead of pushing a new one.

Two symmetric mistakes. A leaf function (calls nobody) does not need to save ra or build a frame at all — doing so is pure overhead, and good compilers skip it. But a non-leaf function that forgets to save ra before its inner jal is a disaster: the inner call overwrites ra, so when the outer function runs ret it jumps to the wrong place and the program derails. The rule is crisp: save ra to the stack if and only if you make a call. Mixing this up is the single most common bug in hand-written assembly.

Why the ABI matters beyond one program

The calling convention is not a law of physics — the hardware would happily let you pass arguments in any registers you liked. Its value is that everyone agrees on the same one. That agreement is what lets separately compiled objects link together, lets your code call the C standard library, lets a debugger unwind the stack to show you a backtrace, and lets one language call another. The ABI is the ISA contract extended into a society of cooperating code. With calls in hand, the final lesson of this module widens the ISA in a different direction — exposing data parallelism through vector and SIMD instructions.