Procedure Calls
A procedure or function is one of the key building blocks for programmers as it allows them to create understandable and reusable code. It also a way to add abstraction and simplify a program. In simple a procedure works as follows:
- Put the parameters in a place the procedure (callee) can access them.
- Transfer control to the procedure.
- Acquire the storage resources needed for the procedure.
- Perform the task.
- Put the result of the task in a place the caller can access them.
- Return control to the caller.
For the first and fifth point, we have the registers x10-x17
. So that we know where to return to in step 6 we store the caller address in x1
, this would be done when transferring control to the procedure with the jal x1, Label
instruction.
Using More Registers
But what if the 8 registers for the arguments are not enough to complete our task. We can use other registers as long as we clean up after ourselves, meaning we can spill registers to memory and then restore the registers before returning control. This leads us to the idea of the stack. In RISC-V we keep track of a stack pointer in x2
and push and pop data to the stack. Important here is however that the stack grows from high to low addresses meaning when updating the stack pointer we need to subtract.
For this we also remember that the registers x5-x7
and x28-x31
are temporary registers and do not need to be restored before returning control but the registers x8-x9
and x18-x27
are saved registers and do need to be restored.
In the below example we could just use the temporary registers to store the temporary values but instead, we will spill some registers to the stack to demonstrate how this could be done.
// g in x10, h in x11, i in x12, j in x13
int leaf( int g, int h, int i, int j) {
int f;
f = (g + h) – (i + j);
return f;
}
leaf:
addi sp, sp, -12 # make space on stack for 12 bytes 3x 32 bits
sw x5,8(sp) # save x5
sw x6,4(sp) # save x6
sw x7,0(sp) # save x7
add x5,x10,x11 # x5 <- g + h
add x6,x12,x13 # x6 <- i + j
sub x7,x5,x6 # x7 <- x5 - x6
addi x10,x7,0 # write result to x10 <- x7
lw x7,0(sp) # restore x7
lw x6,4(sp) # restore x6
lw x5,8(sp) # restore x5
addi sp,sp,12 # adjust stack
jalr x0,0(x1) # return to caller
Nested Procedures
Procedures that do not call other procedures are called leaf procedures. But these are very rarely seen in programs, much more often we see nested procedures or even recursive procedures which need a lot of care when working with registers. For example, imagine the Procedure is called and the argument 3 is stored in x10
and return address in x1
. If then wants to call the procedure the argument in x10
and return address in x1
must be overwritten. So to prevent these collisions we must carefully push data to the stack and retrieve it again at a later time.
To aid this tricky task of keeping track of the local data of a procedure some RISC-V compilers use a frame pointer fp
which is stored in the register x8
. As the stack pointer can always change the frame pointer offers a stable base register for local memory references.
int fact(int n)
{
if (n < 1)
return 1;
else
return n * fact(n-1);
}
fact:
addi sp, sp, -8 # make space for 8 bytes
sw x1, 4(sp) # save return address
sw x10, 0(sp) # save n
addi x11, x10, -1 # x11 <- n - 1
bge x11, zero, L1 # if (x11 >= 0), goto L1
addi x10, zero, 1 # x10 <- 1 (retval)
addi sp, sp, 8 # adjust stack
jalr zero, 0(x1) # return
L1:
addi x10, x10, -1 # x10 <- n - 1
jal x1, fact # call fact(n-1)
addi t1, x10, 0 # t1 <- fact(n-1)
lw x10, 0(sp) # restore n
lw x1, 4(sp) # restore return address
addi sp, sp, 8 # adjust stack pointer
mul x10, x10, t1 # x10 <- n * t1 (retval)
jalr zero, 0(x1) # return