Functions & the stack

Functions & the stack

A function is a named block of instructions that you can use more than once. You jump to it, it carries out its task, and it returns control to the place you called it from. This lets you write a piece of logic once and use it from many places instead of repeating the same lines throughout your program.

Use to call a function: it jumps to the label and stores the return location in ra. The function finishes with , which jumps back to the address in ra. and are pseudo-instructions: convenient names that the assembler expands into the actual instructions, and .

Arguments are placed in a0, a1, and the registers that follow. The return value is placed in a0. This arrangement is the calling convention, the shared agreement that lets separate pieces of code work together.

A square() function
li a0, 6 # argument
call square # call, and ra remembers where to come back
li a7, 1 # square left its result in a0
ecall
li a7, 10
ecall
square:
mul a0, a0, a0 # a0 = a0 * a0
ret # jump back to ra

Follow the call. places the argument 6 in a0. jumps to the square label and stores the return address in ra. Inside the function, computes 6 * 6 = 36 and leaves the result in a0, the register used for return values. jumps back to the instruction right after the call, where and print 36, and then the program exits.

Function call and return
caller
call A
...
A
addi sp, sp, -4
sw ra, 0(sp)
call B
lw ra, 0(sp)
addi sp, sp, 4
ret
B
mul a0, a0, a0
ret
STACK
sp →
0x7ffffff0
0x7fffffec

The caller is about to call A.

Watch the highlighted line make the whole round trip. caller executes , which automatically saves the return point to caller in the ra register. Before A can execute , it runs into a problem: the instruction is about to overwrite ra with a new return address, destroying the one A needs to get back home. To fix this, A makes room on the stack () and saves the original ra there (). Now it is safe to . B runs and returns with an ordinary , landing back inside A. Finally, A restores the original ra from the stack (), cleans up the stack pointer (), and its own uses that restored value to successfully jump back to caller. If A skipped this step, the instruction would have permanently overwritten ra, and A's final would jump to the wrong place instead of returning home.

The stack

Only a handful of registers exist, so functions that call other functions need scratch space. The stack is memory you borrow via the stack pointer sp: subtract to make room, store what you must keep, restore it before .

The stack is a Last-In, First-Out (LIFO) structure: the most recently stored value is the first one you retrieve. In RISC-V, the stack grows downwards through memory. Subtracting from sp reserves new space that is safe to use. When you take values back out, you must do so in the reverse order of how you stored them, or the program's execution flow will be corrupted.

Save and restore ra across a call
addi sp, sp, -4 # make room for one word
sw ra, 0(sp) # save the return address
# ... call other functions here ...
lw ra, 0(sp) # restore it
addi sp, sp, 4 # give the room back
ret

Every / overwrites ra, so if your function calls another function, the inner call destroys your return address and your own jumps to the wrong place. To prevent this, reserves 4 bytes on the stack, and saves ra there. After the inner calls finish, restores the return address, and gives the space back before .

⚠
If you called anything, restore ra before , and always return sp to the value it had at the start. A stack that does not match its starting state is the usual cause of a crash in assembly programs.