Hands-On RISC-V

The Stack: Saving Values Across a Call

intermediateRISCVLesson 2 of 3

execution trace
  1. addi sp, sp, -16 # make room: grow the stack down
  2. sw ra, 12(sp) # save return address
  3. sw s0, 8(sp) # save callee-saved s0
  4. addi s0, zero, 42 # ... do work, clobbering s0
  5. lw s0, 8(sp) # restore s0
  6. lw ra, 12(sp) # restore return address
  7. addi sp, sp, 16 # release the frame

registers

memory / stack

pc & flags

0 / 0

Registers are fast but few. The moment you call another function, you face a problem: it might overwrite the registers you were using. The stack is the shared scratch space that solves this.

The shape of a stack frame

The stack pointer sp marks the top of the stack, and on RISC-V the stack grows downward — toward lower addresses. So you reserve space by subtracting:

addi sp, sp, -16      # allocate 16 bytes
...                   # use the frame
addi sp, sp, 16       # free it — must balance exactly

sw (store word) writes a register to memory; lw (load word) reads it back:

sw  ra, 12(sp)        # mem[sp + 12] = ra
lw  ra, 12(sp)        # ra = mem[sp + 12]

Caller-saved vs callee-saved

RISC-V’s calling convention splits registers into two camps:

Kind Examples Rule
callee-saved s0s11, sp, ra If you use one, you must restore it before returning.
caller-saved t0t6, a0a7 Free to clobber; the caller saves them if it cares.

This lesson’s trace saves ra and s0 because they’re callee-saved — watch the memory panel light up as each is pushed, then the registers panel as each is restored. By the final step, sp is back exactly where it began: a balanced frame is the contract.

Try this

  • What breaks if you forget the final addi sp, sp, 16? (The stack “leaks” — every call drifts sp further down.)
  • Why save ra at all? (A nested call would overwrite it, and you’d lose your way home.)