Digital Garden
Computer Science
Computer Architecture
RISC-V
Procedure Calls

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:

  1. Put the parameters in a place the procedure (callee) can access them.
  2. Transfer control to the procedure.
  3. Acquire the storage resources needed for the procedure.
  4. Perform the task.
  5. Put the result of the task in a place the caller can access them.
  6. 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.

Example

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 AA is called and the argument 3 is stored in x10 and return address in x1. If AA then wants to call the procedure BB 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.

Example
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