RISC-V Assembly #
From the CPU to the Instruction Set #
Last week we looked at what a CPU does with a program: it holds a pointer to the current instruction, fetches it, decodes it, gathers arguments, executes, stores results, and updates the instruction pointer.
The instruction set architecture (ISA) is the contract that defines what those instructions are. It’s the boundary between the software (compilers, assemblers, our code) and the hardware (the circuits we’ll study later).
RISC-V is a modern, open-standard ISA. The SG2000’s main core (the C906 running Linux) is 64-bit RISC-V, which we write as RV64.
Registers (RV64) #
There are 32 general-purpose registers, each 64 bits (8 bytes) wide. We use
ABI names rather than hardware names (x10 is a0).
| Category | ABI Names | Description | Preserved? |
|---|---|---|---|
| Zero | zero |
Always 0. Writes are ignored. | n/a |
| Return Address | ra |
Holds the return address for calls. | Caller |
| Stack Pointer | sp |
Points to the top of the stack. | Callee |
| Arguments / Return | a0–a1 |
Function arguments and return values. | Caller |
| Arguments | a2–a7 |
More function arguments. | Caller |
| Temporaries | t0–t6 |
“Scratch” registers for intermediate math. | Caller |
| Saved Registers | s0–s11 |
Registers that must be restored if used. | Callee |
| Frame Pointer | s0/fp |
Often used to track the stack frame. | Callee |
Unlike AMD64, there is no flags register. Comparisons happen directly in
branch instructions (blt a0, a1, label), not in a separate condition-code
register.
Calling Convention #
This is the RISC-V ABI, mapped against the AMD64 convention we’ve seen:
| Purpose | AMD64 (AT&T) | RISC-V (RV64) |
|---|---|---|
| 1st Argument | %rdi |
a0 |
| 2nd Argument | %rsi |
a1 |
| 3rd Argument | %rdx |
a2 |
| Return Value | %rax |
a0 |
| Stack Pointer | %rsp |
sp |
| Return Address | (on stack) | ra |
Rules to remember:
- Arguments go in
a0–a7; anything past that goes on the stack. - The return value comes back in
a0. rais where acallstores the return address, so any function that calls another function must saverain its prologue.- The stack pointer must stay 16-byte aligned.
Full reference: RISCV-64 Cheat Sheet.
Initial Demo: add2
#
Let’s start with the smallest complete program: a function that adds 2 to its
argument, plus a main that prints the result.
C version:
long add2(long x) {
return x + 2;
}
int main(int argc, char* argv[]) {
long x = 5;
long y = add2(x);
printf("%ld\n", y);
return 0;
}
RISC-V assembly (add2.S):
.global main
.section .text
# long add2(long x)
# argument x comes in a0, result goes in a0.
add2:
addi a0, a0, 2
ret
main:
# Prologue: save ra, keep sp 16-byte aligned.
addi sp, sp, -16
sd ra, 8(sp)
# long x = 5;
li a0, 5
# long y = add2(x);
call add2
# printf("%ld\n", y);
mv a1, a0
la a0, long_fmt
call printf
# return 0;
li a0, 0
ld ra, 8(sp)
addi sp, sp, 16
ret
.section .data
long_fmt: .string "%ld\n"
Points to notice:
add2is a leaf function (it calls nothing), so it needs no stack frame and never touchesra.maincallsadd2andprintf, so it savesrafirst.li(load immediate),mv(move),la(load address),call, andretare pseudo-instructions the assembler expands for us.- The argument to
add2is already ina0because we put 5 there. printf’s second argument is the value ina1; the format string address goes ina0.
Build and run it natively on the board:
gcc -no-pie -o add2 add2.S
./add2
# 7
The Recipe #
For anything bigger than add2, we want a repeatable process instead of
guessing. That’s the assembly recipe: a fixed sequence of steps for turning
a C function into working RISC-V assembly.
The full write-up is here: Design Recipe for RISC-V ASM.
The six steps:
- Make sure you have C code or at least pseudocode.
- Setup the function — a
.globallabel in.section .text. - The prologue — allocate stack space (rounded to 16 bytes), save
raand anysregisters you’ll use. - Map your variables — arguments in
a0–a7; long-lived values ins0–s11; short-lived scratch int0–t6. - Translate the body — line by line. Constants with
li, moves withmv, arithmetic withadd/sub/addi. Branch past anifblock when its condition is false. Loops are just a label plus a conditional branch back. - Function calls — args in
a0,a1, …;call; result ina0. Savetregisters you still need across the call. - The epilogue — result into
a0, restoreraand thesregisters, deallocate the stack,ret.
Recipe Demo: Collatz #
Here’s a program with an if/else, a loop, a helper function, and calls to
printf — enough to exercise every step of the recipe.
C version:
long iterate(long x) {
if (x % 2 == 0) {
return x / 2;
} else {
return x * 3 + 1;
}
}
int main(int argc, char* argv[]) {
long x = 27;
long i = 0;
while (x > 1) {
printf("%ld\n", x);
x = iterate(x);
i++;
}
printf("i = %ld\n", i);
return 0;
}
Step 1: Setup #
.global main
.section .text
iterate:
# ...
Step 2: The prologue #
iterate calls nothing, so it’s a leaf and needs no frame:
iterate:
# (no prologue needed)
main calls iterate and printf, and needs x and i to survive those
calls. Two s registers plus ra is 24 bytes, rounded up to 32:
main:
addi sp, sp, -32
sd ra, 24(sp)
sd s0, 16(sp)
sd s1, 8(sp)
Step 3: Map the variables #
iterate’s argumentxarrives ina0; its result goes ina0.main’sx->s0,i->s1. These are callee-saved, so they survive the calls toiterateandprintf.
li s0, 27 # long x = 27;
li s1, 0 # long i = 0;
Step 4: Translate the body #
The if/else becomes a branch that skips to the else when the condition is
false. rem gives us x % 2:
iterate:
li t0, 2
rem t1, a0, t0 # t1 = x % 2
bnez t1, iterate_odd # if (x % 2 != 0) goto the else branch
div a0, a0, t0 # return x / 2;
ret
iterate_odd:
li t0, 3 # return x * 3 + 1;
mul a0, a0, t0
addi a0, a0, 1
ret
The while loop is a label at the top and a conditional branch back to it:
loop_start:
li t0, 1
ble s0, t0, loop_end # while (x > 1): exit if x <= 1
# ... loop body ...
j loop_start
loop_end:
Step 5: Function calls #
# printf("%ld\n", x);
la a0, long_fmt
mv a1, s0
call printf
# x = iterate(x);
mv a0, s0
call iterate
mv s0, a0 # result comes back in a0
Step 6: The epilogue #
li a0, 0 # return 0;
ld s1, 8(sp)
ld s0, 16(sp)
ld ra, 24(sp)
addi sp, sp, 32
ret
Putting it together #
Full program: collatz.S.
.global main
.section .text
# long iterate(long x) -- leaf function, no stack frame needed.
# x in a0, result in a0.
iterate:
li t0, 2
rem t1, a0, t0 # t1 = x % 2
bnez t1, iterate_odd
div a0, a0, t0 # return x / 2;
ret
iterate_odd:
li t0, 3
mul a0, a0, t0 # return x * 3 + 1;
addi a0, a0, 1
ret
main:
addi sp, sp, -32
sd ra, 24(sp)
sd s0, 16(sp)
sd s1, 8(sp)
li s0, 27 # long x = 27;
li s1, 0 # long i = 0;
loop_start:
li t0, 1
ble s0, t0, loop_end # while (x > 1)
la a0, long_fmt # printf("%ld\n", x);
mv a1, s0
call printf
mv a0, s0 # x = iterate(x);
call iterate
mv s0, a0
addi s1, s1, 1 # i++;
j loop_start
loop_end:
la a0, iter_fmt # printf("i = %ld\n", i);
mv a1, s1
call printf
li a0, 0 # return 0;
ld s1, 8(sp)
ld s0, 16(sp)
ld ra, 24(sp)
addi sp, sp, 32
ret
.section .data
long_fmt: .string "%ld\n"
iter_fmt: .string "i = %ld\n"
Build and run it on the board:
gcc -no-pie -o collatz collatz.S
./collatz
Exercise #
On the board, starting from add2.S and collatz.S:
- Change
collatz.Sso the starting value comes fromargv[1](look upatol;argcis ina0andargvis ina1inmain). - Write
long square(long x)that returnsx * x, call it frommain, and print the result. - Use
gdbtobreakatiterateand inspecta0withp $a0. Compile with-gfirst.