7/22/25, 11:48 AM RISC-V Assembler: Jump and Function - Project F
Project F
RISC-V Assembler: Jump and
Function
Published 30 Apr 2024 · Updated 04 Oct 2024
This RISC-V assembler post begins by examining the RISC-V jump instructions: jal
and jalr. Jump instructions are the basis of functions, so we’ll then dig into
function calls, the RISC-V ABI, calling convention, and how to use the stack. Jump
instructions are included in RV32I, the base integer instruction set.
In the last few years, we’ve seen an explosion of RISC-V CPU designs on FPGA and
ASIC, including the RP2350 found on the Raspberry Pi Pico 2. Thankfully, RISC-V is
ideal for assembly programming with its compact, easy-to-learn instruction set.
This series will help you learn and understand 32-bit RISC-V instructions and
programming.
RISC-V Assembler: Arithmetic | Logical | Shift | Load and Store | Branch and Set |
Jump and Function | Multiply and Divide | Compiler Explorer | Assembler Cheat
Sheet
Jump
The main operation of the jump instructions is to update the program counter
(PC).
The PC keeps track of the CPU’s location in the code. Usually, the CPU adds 4 to
the PC when executing an instruction, as each instruction is 4 bytes long. However,
with a jump instruction, the CPU updates the PC to point at the jump target
instead.
https://projectf.io/posts/riscv-jump-function/ 1/14
7/22/25, 11:48 AM RISC-V Assembler: Jump and Function - Project F
The jump instructions are unconditional. For conditional jumps, see my post on
branching.
Just the Two of Us
At one level, this post is about two instructions: jal (jump and link) and jalr (jump
and link register). But these two instructions are exciting because they enable
functions (also known as subroutines or procedure calls).
jal rd, imm # rd = pc+4; pc += imm
jalr rd, rs1, imm # rd = pc+4; pc = rs1+imm
Home
Before updating the PC, a jump instruction writes the address of the following
About
instruction into a register. By saving this return address, we can return to it and
continue execution where we left off.
Contact
jal uses a 20-bit signed immediate for the jump destination, while jalr uses a
register plus 12-bit signed offset in a similar way to the load and store
instructions. Sponsor
jal range is ±1MiB in units of two bytes for greater range with compressed
instruction support.
jalr range is ±2KiB, but you can combine jalr with lui or auipc to reach any 32-bit
address.
Jump!
Before we tackle functions (subroutines), let’s consider a plain old jump:
j imm # pc += imm
https://projectf.io/posts/riscv-jump-function/ 2/14
7/22/25, 11:48 AM RISC-V Assembler: Jump and Function - Project F
For example, use j in an infinite loop:
.L_forever:
la a0, message # load address of message
call printstr # call a function (discussed below)
j .L_forever # jump to .L_forever label
You could also use the j instruction in case/switch code.
The assembler translates j into jal with the return address register set to zero (x0).
Use j for your unconditional jumps in preference to branches. Jumps make your
Home
intent clear, have greater range, and avoid branch prediction.
About
Functions
To call a function, we must jump to aContact
new address while remembering where we
came from. The jal instruction can do this. We need to choose where to save the
return address. By convention, this is the x1 register, known in the ABI as the
return address register or ra.
Sponsor
The use of ra for the return address is part of the standard RISC-V ABI (application
binary interface). The ABI ensures programs written by different programmers and
with different tools can interoperate. For example, the ABI allows a program
written in C to call a function written in assembler.
How do we get back once we’ve finished executing our function? We have the
return address in ra, so jalr (jump and link register) can take us back.
Let’s take a look at a trivial example, calling a function that adds two integers:
https://projectf.io/posts/riscv-jump-function/ 3/14
7/22/25, 11:48 AM RISC-V Assembler: Jump and Function - Project F
li a0, 7 # 1st argument in a0
li a1, 8 # 2nd argument in a1
jal ra, add_int # save address in register ra (x1) and jump to label add_int
ebreak # stop execution
add_int:
add a0, a0, a1 # a0 = a0 + a1
jalr zero, 0(ra) # jump to address in register ra with 0 offset
Does this seem unnecessarily fiddly? If we always use the ra register for the return
address, why do we need to provide it? Plus, it’s not immediately obvious what the
purpose of these jump instructions is.
Home
Pseudoinstructions call and ret to the rescue!
About
call label # call function at 'label', saving return address in ra
ret # return from function using address in ra
Contact
This makes for simpler and clearer code:
Sponsor
li a0, 7 # 1st argument in a0
li a1, 8 # 2nd argument in a1
call add_int # call function
ebreak # stop execution
add_int:
add a0, a0, a1 # a0 = a0 + a1
ret # return from function
Far Calls
In the above example we used jal to call our function, but it’s limited to ±1MiB
relative to the PC. For far calls we can combine jalr with auipc to reach anywhere
https://projectf.io/posts/riscv-jump-function/ 4/14
7/22/25, 11:48 AM RISC-V Assembler: Jump and Function - Project F
in 32-bit memory space. Use the call pseudoinstruction and the assembler will
choose the correct instruction(s) for you.
In and Out
Most functions take arguments and return something: this is where the a0-a7
registers come in.
Before calling a function, you put the first argument in a0, the 2nd argument in a1,
etc. When it comes time to return our result, we put it in a0. Just like the
convention of using ra for the return address, this ensures different code can
easily work together. Home
We have already seen an example of arguments and return values with add_int
About
(above).
Contact
Functions Calling Functions
Sponsor
Functions that don’t call other functions are known as leaf functions. After a leaf
function executes it uses the return address in ra to return.
However, functions can easily call other functions and it’s here things get
interesting. When we call a function, the call instruction writes the return address
into ra, overwriting the previous return address!
A function that calls a function must save its own return address before making
the function call. We save the existing value of ra on the stack.
Stack
https://projectf.io/posts/riscv-jump-function/ 5/14
7/22/25, 11:48 AM RISC-V Assembler: Jump and Function - Project F
The stack is an area of memory set aside for use by functions and local variables
(not discussed here).
The stack pointer (sp) points to the bottom of the stack, which grows downwards
to lower addresses. When the CPU is reset, the stack pointer is typically set to the
very top of memory.
We allocate memory on the stack by decrementing the stack pointer sp. We can
then save registers onto the stack using the sw (store word) instruction. See Load
Store for coverage of RISC-V memory instructions.
For example, function fun_one calls function fun_two, so it must save its return
address on the stack: Home
fun_one:
About
addi sp, sp, -16 # allocate 16 bytes on stack
sw ra, 12(sp) # store return address on stack
# do some fun stuff
Contact
call fun_two # call another function
Sponsor
# do some more fun stuff
lw ra, 12(sp) # load return address from stack
addi sp, sp, 16 # restore stack pointer
ret # return from fun_one
Note how we store and then later load the return address from the same offset (12
) to the stack pointer.
Stack Alignment
Why did we allocate 16 bytes on the stack when our return address is only 4 bytes
long?
https://projectf.io/posts/riscv-jump-function/ 6/14
7/22/25, 11:48 AM RISC-V Assembler: Jump and Function - Project F
The RISC-V calling convention says:
The stack grows downwards (towards lower addresses) and the stack pointer
shall be aligned to a 128-bit boundary upon procedure entry.
This is another example of the ABI ensuring interoperability. We ensure all data
types are correctly aligned by aligning the stack pointer to 16 bytes.
Ignorance is Bliss
A function doesn’t know what happened before it was called or what will happen
Home
after it returns. A function caller doesn’t know what happens inside a function, just
what it passes in and gets returned. A well-written function is an example of a
black box. About
RISC-V gives us 32 general-purpose registers, but if every function used them
Contact
indiscriminately, they’d overwrite each other’s data. We can solve this problem by
pushing existing registers onto the stack before calling the function and popping
them off the stack after the function returns. However, pushing registers onto the
Sponsor
stack makes functions slower. A simple function could spend more CPU cycles
pushing and popping register from the stack than doing useful work.
The RISC-V ABI lets us have fast functions while preserving some register values.
There are three main categories of general-purpose registers:
saved registers: s0-s11 - keep their value across function calls (preserved)
argument registers: a0-a7 - for passing arguments and the return value (not
preserved)
temporary registers: t0-t6 - for internal function use (not preserved)
https://projectf.io/posts/riscv-jump-function/ 7/14
7/22/25, 11:48 AM RISC-V Assembler: Jump and Function - Project F
Understanding how to handle preserved and non-preserved registers is critical to
writing RISC-V assembler. I’d go so far as to say its the most important skill
beyond a basic knowledge of the instructions. Getting it right results in fast,
elegant code. Getting it wrong leads to subtle bugs and much frustration. A good
start is to always use ABI names for registers, otherwise it’s really difficult to
remember which registers you need to save!
A function using a preserved register must restore its original value before
returning.
A function using a non-preserved register must assume it’s changed by a
function call.
Home
Let’s look at both cases in a little more detail.
About
Preserved Registers
Preserved registers must be restored to their original value before returning from
Contact
a function call. If your function uses preserved registers, such as s0-s11, save them
on the stack before using them.
Sponsor
For example, fun_foo uses s1-s4, it saves them on the stack like this:
https://projectf.io/posts/riscv-jump-function/ 8/14
7/22/25, 11:48 AM RISC-V Assembler: Jump and Function - Project F
fun_foo:
addi sp, sp, -16 # allocate space on stack
sw s1, 12(sp) # store saved registers on stack
sw s2, 8(sp)
sw s3, 4(sp)
sw s4, 0(sp)
# we're now free to use s1-s4
# implement incredible algorithm here
lw s1, 12(sp) # restore saved registers from stack
lw s2, 8(sp)
lw s3, 4(sp)
lw s4, 0(sp)
addi sp, sp, 16 # restore stack pointer
ret
Home
About
Other Registers
With non-preserved registers, you can do what you want, but so can other
Contact
functions. After you call another function, you must assume the values of the a
and t registers have changed.
Sponsor
For example, I’ve written a function to initialize my graphics display. The
background colour is passed to gfx_setup in a0. However, before I set the
background colour I need to call frame_wait.
The frame_wait function could overwrite a0, so I preserve it on the stack. Of course,
I also need to save ra on the stack before calling another function, leading to this
design:
https://projectf.io/posts/riscv-jump-function/ 9/14
7/22/25, 11:48 AM RISC-V Assembler: Jump and Function - Project F
gfx_setup:
addi sp, sp, -16 # allocate space on stack
sw ra, 12(sp) # save return address onto stack
sw a0, 8(sp) # save a0 (background colour)
call frame_wait # wait for blanking before graphics setup
li t6, GFX_HWREG # graphics engine address
# background colour
lw a0, 8(sp) # load background colour (a0) from stack
sw a0, DISP_BGRD(t6) # set background colour
# other graphics setup here...
lw ra, 12(sp)
Home
# load return address from stack
addi sp, sp, 16 # restore stack pointer
ret
About
Functions that don’t call other functions (leaf functions), don’t have to worry about
Contact
non-preserved registers changing. When writing leaf functions, stick to t and a
registers, then you don’t have to save anything to the stack: simple and fast.
Sponsor
Many Arguments
In the rare event your function needs more than eight arguments, you can pass
them on the stack.
The RISC-V calling convention says:
The first argument passed on the stack is located at offset zero of the stack
pointer on function entry; following arguments are stored at correspondingly
higher addresses.
For example, a function with 10 arguments receives the first eight arguments in
a0-a7 and could handle the remaining arguments like this:
https://projectf.io/posts/riscv-jump-function/ 10/14
7/22/25, 11:48 AM RISC-V Assembler: Jump and Function - Project F
fun_ten:
lw t0, 0(sp) # load 9th argument off stack into t0
lw t1, 4(sp) # load 10th argument off stack into t1
# the first 8 arguments are in a0-a7
NB. We use an offset of 4 for the 10th argument because we’re loading a word (four
bytes).
64-bit Variables
Homeyou need to work with 64-bit values,
RV32 is a 32-bit architecture, but sometimes
such as file offsets or UNIX time. In this case, you can combine pairs of registers,
such as a0 and a1.
About
The following function performs 64-bit subtraction, including handling the carry
bit: Contact
Sponsor
# 64-bit integer subtraction
# arguments:
# a0: x lower 32 bits
# a1: x upper 32 bits
# a2: y lower 32 bits
# a3: y upper 32 bits
# return:
# a0: x-y lower 32 bits
# a1: x-y upper 32 bits
#
sub64:
sltu t0, a0, a2 # if a0 < a2 then set t1=1 (carry bit)
sub a1, a1, a3 # sub upper 32 bits
sub a1, a1, t0 # sub carry bit from upper 32 bits of answer
sub a0, a0, a2 # sub lower 32 bits
ret
https://projectf.io/posts/riscv-jump-function/ 11/14
7/22/25, 11:48 AM RISC-V Assembler: Jump and Function - Project F
Learn more about multi-word addition with set instructions.
RV32 ABI Registers
Let’s finish by taking a look at all 32 ABI registers.
ABI Name Register Description Preserved
zero x0 always 0 (zero) n/a
ra x1 return address no
sp x2 stack pointer yes
Home
gp x3 global pointer* n/a
tp x4 About
thread pointer* n/a
t0 x5 temporary no
Contact
t1 x6 temporary no
t2 x7 temporary no
Sponsor
fp (s0) x8 frame pointer† yes
s1 x9 saved register yes
a0 x10 function argument‡ no
a1 x11 function argument‡ no
a2 x12 function argument no
a3 x13 function argument no
a4 x14 function argument no
a5 x15 function argument no
a6 x16 function argument no
https://projectf.io/posts/riscv-jump-function/ 12/14
7/22/25, 11:48 AM RISC-V Assembler: Jump and Function - Project F
ABI Name Register Description Preserved
a7 x17 function argument no
s2 x18 saved register yes
s3 x19 saved register yes
s4 x20 saved register yes
s5 x21 saved register yes
s6 x22 saved register yes
s7 x23 saved register yes
s8 x24
Home
saved register yes
s9 x25 saved register yes
About
s10 x26 saved register yes
s11 x27 saved register yes
Contact
t3 x28 temporary no
t4 x29 Sponsor
temporary no
t5 x30 temporary no
t6 x31 temporary no
*Let the compiler/linker use the global gp and thread tp pointers; ignore them in
your own code.
†The frame pointer fp supports local variables but can be used as a regular saved
register.
‡Argument registers a0 and a1 also handle the function return value.
What’s Next?
https://projectf.io/posts/riscv-jump-function/ 13/14
7/22/25, 11:48 AM RISC-V Assembler: Jump and Function - Project F
The next post looks at RISC-V Multiply and Divide Instructions and RISC-V
extensions.
Check out the RISC-V Assembler Cheat Sheet.
References
RISC-V Technical Specifications (riscv.org)
asm riscv
Home
Project F: FPGA and RISC-V. Only hardware makes it possible! © 2025 Will Green.
About
Contact
Sponsor
https://projectf.io/posts/riscv-jump-function/ 14/14