This is part of a new series I am starting on the blog where we’ll explore RISC-V by breaking down real programs and explaining how they work. You can view all posts in this series on the RISC-V Bytes page.
When looking at the generated assembly for a function, you may have noticed that the first few instructions involve moving values from registers to the stack, then loading those values back into the same registers before returning. In this post we’ll explore why this is happening, why certain registers are used, and how behavior guarantees make life easier for compiler authors and enable software portability.
Defining Terms Link to heading
Before we dive into what is happening there, let’s define some terms and take a look at the 32 general purpose registers supported in the RISC-V instruction set.
- Caller: a procedure that calls one or more more subsequent procedure(s).
- Callee: a procedure that is called by another.
- Application Binary Interface (ABI): a standard for register usage and memory layout that allows for programs that are not compiled together to interact effectively.
- Calling Conventions: a subset of an ABI specifically focused on how data is passed from one procedure to another.
Importantly, a procedure may be both a caller and a callee.
Now let’s take a look at the RISC-V registers:
Name | ABI Mnemonic | Calling Convention | Preserved across calls? |
---|---|---|---|
x0 | zero | Zero | n/a |
x1 | ra | Return address | No |
x2 | sp | Stack pointer | Yes |
x3 | gp | Global pointer | n/a |
x4 | tp | Thread pointer | n/a |
x5-x7 | t0-t2 | Temporary registers | No |
x8-x9 | s0-s1 | Saved registers | Yes |
x10-x17 | a0-a7 | Argument registers | No |
x18-x27 | s2-s11 | Saved registers | Yes |
x28-x31 | t3-t6 | Temporary registers | No |
You’ll notice the second column refers to the Application Binary Interface (ABI) and the third refers to the Calling Convention, both of which we defined earlier. This likely makes intuitive sense: if we all agree to use certain registers for specific purposes, we can expect data to be there without having to explicitly say that it is.
The fourth column may be a bit more opaque. While this table uses Preserved across calls? as a designation, you will frequently see all of the registers with Yes in the column referred to as callee-saved and those with No as caller-saved. This once again is related to how procedures communicate. It is great to agree on the purpose of our registers, but we also need to define what responsibilites a procedure has when interacting with them. In order for a register to be preserved across calls, the callee must make sure its value is the same when it returns to the caller as it was when the callee was, well, called!
An Example Link to heading
The simplest example is the main
function. You may be tempted to think that
main
would be an example of a procedure that is only a caller. In reality, it
is called after some initial setup, which can very greatly depending on the
language and the compiler. Almost every procedure is a callee, and only leaf
procedures are not callers.
We’ll be using our program from last
post to show how
registers are preserved. In this case, main
is being called by _start
and it
calls printf
.
(gdb) disass main
Dump of assembler code for function main:
0x0000000000010158 <+0>: addi sp,sp,-32
0x000000000001015a <+2>: sd ra,24(sp)
0x000000000001015c <+4>: sd s0,16(sp)
0x000000000001015e <+6>: addi s0,sp,32
0x0000000000010160 <+8>: li a5,1
0x0000000000010162 <+10>: sw a5,-20(s0)
0x0000000000010166 <+14>: li a5,2
0x0000000000010168 <+16>: sw a5,-24(s0)
0x000000000001016c <+20>: lw a4,-20(s0)
0x0000000000010170 <+24>: lw a5,-24(s0)
0x0000000000010174 <+28>: addw a5,a5,a4
0x0000000000010176 <+30>: sw a5,-28(s0)
0x000000000001017a <+34>: lw a5,-28(s0)
0x000000000001017e <+38>: mv a1,a5
0x0000000000010180 <+40>: lui a5,0x1c
0x0000000000010182 <+42>: addi a0,a5,176 # 0x1c0b0
0x0000000000010186 <+46>: jal ra,0x10332 <printf>
0x000000000001018a <+50>: li a5,0
0x000000000001018c <+52>: mv a0,a5
0x000000000001018e <+54>: ld ra,24(sp)
0x0000000000010190 <+56>: ld s0,16(sp)
0x0000000000010192 <+58>: addi sp,sp,32
0x0000000000010194 <+60>: ret
End of assembler dump.
You may be thinking to yourself: why do we need so many instructions that just store a register into memory, then immediately load it back? Good question! We don’t! For simplicity here, we are compiling using
gcc
without any optimization. This essentially means that each source line is assembled in a vacuum without much consideration of the surrounding context. While this is inefficient and leads to a much larger program size, it can be useful for learning. Take a look at this program on Compiler Explorer and hover over the output to see which instructions map to each source line. We’ll explore how different optimization levels change code generation in a future post.
Let’s start from the top. The first thing you’ll notice is that we are
decreasing the value in sp
, our stack pointer register. Our first four
instructions here are commonly referred to as the function prologue. For
today’s post we are going to be primarily focusing on it and the function
epilogue because these sections are where we perform the bookkeeping operations
that are necessary to conform to calling conventions.
When we move the stack pointer, we are essentially incrementing or decrementing
the size of our stack. In RISC-V, the stack grows downwards, so addi sp, sp, -32
is increasing the size of our downward growing stack by changing the stack
pointer to contain an address 32 bytes lower.
A Caller-Saved Register Link to heading
Next we want to store the contents of the saved registers onto the stack. Let’s pause for a moment and think about why we need to do this. If the registers are designated as “saved”, can we not just leave them untouched throughout the body of our procedure, keeping them intact when we return to the procedure that called us?
This is true if we are not going to re-use those registers at any point in our
procedure we need to make sure we preserve their contents. For instance, take a
look at <+42>
where we call printf
. Here we are specifying that we want to
jump to the location of the printf
procedure and set the contents of register
ra
to the address of the program counter plus four (ra <- PC + 4
). This will
inform printf
to return to the address of the next instruction in our main
body (<+50>
). However, when printf
does return, we need to know how to
return to the procedure that called us (_start
).
If we hadn’t saved the contents of ra
in the prologue (<+2>
), we would have
lost that address, but because we stored it on the stack, we can load it back
into ra
in the epilogue (<+54>
) and return to _start
. Meanwhile, in the
rest of the procedure body, we are free to use the register as needed. If we
look at our table of general purpose registers above, we’ll notice that ra
is
designated as caller-saved (i.e. it is not preserved across calls). This
aligns with the behavior we see as main
, as the caller, saves ra
before
calling printf
and updating ra
with the address of the next instruction.
A Callee-Saved Register Link to heading
You’ll also notice that we are storing s0
on the stack in the prologue
(<+4>
). Besides being designated as a callee-saved register, s0
is used
as the frame pointer if one
exists.
The stack frame is the area of the stack reserved for the current procedure and
it stretches from the frame pointer to the stack pointer. Procedures may use
the frame pointer with an offset to store values on the stack, such as a
variable that is only in-scope for that procedure (e.g. <+10>
). In this way,
the frame pointer is a boundary, marking the beginning of the region of the
stack available for the procedure.
It is imperative that the frame pointer, or any other callee-saved register
that is modified in the procedure, is restored prior to returning to the caller.
Since _start
is expecting its frame pointer to be unmodified after calling
main
, we must:
- Store it in the stack frame for
main
(<+4>
). - Set the new frame pointer for
main
(<+6>
). You’ll notice the frame pointer now contains the address the stack pointer contained when our procedure began. - Restore it before returning (
<+56>
).
You’ll notice that we will also restore the stack pointer (<+58>
), as it is a
callee-saved register as well. However, unlike ra
, we don’t have to worry
about storing the contents of s0
or sp
on the stack prior to calling
printf
because it will adhere to the same conventions as a callee that main
does for _start
, ensuring that all of our callee-saved registers are
unmodified when it returns.
Concluding Thoughts Link to heading
While we have only scratched the surface of the benefits of ABI-compatibility in this post, we can already begin to see its value. In future posts, we’ll take a look at how a standardized ABI is even more important when depending on shared libraries, as well as examine some more complex examples of passing data between procedures. As always, these post are meant to serve as a useful resource for folks who are interested in learning more about RISC-V and low-level software in general. If I can do a better job of reaching that goal, or you have any questions or comments, please feel free to send me a message @hasheddan on Twitter!