Skip to main content
  1. /classes/
  2. Classes, Fall 2026/
  3. CS 4250 Fall 2026: Course Site/

cs4250 Notes: 09-16 RISC-V ASM

·1422 words·7 mins·

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.
  • ra is where a call stores the return address, so any function that calls another function must save ra in 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:

  • add2 is a leaf function (it calls nothing), so it needs no stack frame and never touches ra.
  • main calls add2 and printf, so it saves ra first.
  • li (load immediate), mv (move), la (load address), call, and ret are pseudo-instructions the assembler expands for us.
  • The argument to add2 is already in a0 because we put 5 there.
  • printf’s second argument is the value in a1; the format string address goes in a0.

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:

  1. Make sure you have C code or at least pseudocode.
  2. Setup the function — a .global label in .section .text.
  3. The prologue — allocate stack space (rounded to 16 bytes), save ra and any s registers you’ll use.
  4. Map your variables — arguments in a0–a7; long-lived values in s0–s11; short-lived scratch in t0–t6.
  5. Translate the body — line by line. Constants with li, moves with mv, arithmetic with add/sub/addi. Branch past an if block when its condition is false. Loops are just a label plus a conditional branch back.
  6. Function calls — args in a0, a1, …; call; result in a0. Save t registers you still need across the call.
  7. The epilogue — result into a0, restore ra and the s registers, 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 argument x arrives in a0; its result goes in a0.
  • main’s x -> s0, i -> s1. These are callee-saved, so they survive the calls to iterate and printf.
    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:

  1. Change collatz.S so the starting value comes from argv[1] (look up atol; argc is in a0 and argv is in a1 in main).
  2. Write long square(long x) that returns x * x, call it from main, and print the result.
  3. Use gdb to break at iterate and inspect a0 with p $a0. Compile with -g first.

Refs
#

Nat Tuck
Author
Nat Tuck