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.
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?|
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!
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
(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
gccwithout 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
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
<+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
printf to return to the address of the next instruction in our
<+50>). However, when
printf does return, we need to know how to
return to the procedure that called us (
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
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
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
printf and updating
ra with the address of the next instruction.
A Callee-Saved Register
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
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.
_start is expecting its frame pointer to be unmodified after calling
main, we must:
- Store it in the stack frame for
- Set the new frame pointer for
<+6>). You’ll notice the frame pointer now contains the address the stack pointer contained when our procedure began.
- Restore it before returning (
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
sp on the stack prior to calling
printf because it will adhere to the same conventions as a callee that
_start, ensuring that all of our callee-saved registers are
unmodified when it returns.
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!