R0KernelPath
module 040 / 262

RISC-V Assembly: The Open ISA

RISC-V is the clean, open, royalty-free ISA that finally lets you hold an entire instruction set in your head — and its Linux kernel port is the best place to start reading real architecture-specific kernel code.

~18h focused study10 concepts

Overview

RISC-V is a free and open instruction set architecture (ISA) born at UC Berkeley in 2010. Unlike x86, which carries 40+ years of backwards-compatibility baggage, or ARM, which is proprietary and licensed, RISC-V was designed from scratch by people who had already built and studied five previous RISC ISAs. The result is something rare: an ISA that is clean, extensible, and free for anyone to implement without paying royalties.

For a systems learner, RISC-V is nearly perfect. The base integer ISA (RV32I or RV64I) has exactly 47 instructions. You can hold the entire thing in your head. There are no historical accidents, no weird legacy modes, no privileged instruction sets you need a security clearance to understand. Every design decision was made deliberately, and the rationale is public. When you understand RISC-V, you understand why a RISC ISA looks the way it does — and that transfers directly to understanding ARM, MIPS, or any modern 64-bit architecture.

For the Linux kernel specifically, RISC-V is the newest and most rapidly growing architecture port. The arch/riscv/ subtree of the kernel is relatively small and clean compared to arch/x86/ or arch/arm64/, making it one of the best places to start reading architecture-specific kernel code. Understanding RISC-V assembly is a direct prerequisite for reading the kernel's boot code, exception handlers, context-switch routines, and system-call entry paths for this architecture.

In this module you will learn how RISC-V thinks about computation: how it encodes instructions, what its registers mean, how it handles function calls and memory, and how to write and run a real program from scratch. Everything here is grounded in the official RISC-V specification, which is freely downloadable — a habit of going to the primary source is one of the most important skills a kernel developer can have.

why this matters for the kernel

The Linux kernel's RISC-V port (arch/riscv/) is one of the most active new architecture ports in the kernel. Every single time the kernel boots on a RISC-V board — from SiFive development boards to the BeagleV Starlight to future RISC-V laptops — it starts by executing a handful of hand-written assembly routines in arch/riscv/kernel/head.S. Those routines set up the page table, initialize the stack, and call into C. If you want to contribute to the RISC-V port — fixing bugs, adding CPU features, optimizing context switches — you must be able to read and write this code fluently.

More broadly, learning one RISC ISA well means you can read any RISC ISA with modest effort. ARM64 (AArch64), the dominant mobile and server architecture, is philosophically close to RISC-V. MIPS and PowerPC follow similar patterns. x86_64, despite its CISC heritage, internally translates to micro-ops that look a lot like a RISC pipeline. Fluency in RISC-V assembly gives you a mental model of "what the machine is really doing" that no amount of high-level programming can substitute for — and that mental model is exactly what separates kernel contributors from application developers.

RISC-V Privilege Levels and Trap Flow

rendering diagram…

When a user-space process executes ecall or triggers a fault, the CPU automatically saves the PC to sepc, encodes the cause in scause, and jumps to the address stored in stvec — the kernel's trap entry point in entry.S. The kernel dispatches based on scause, handles the request, and returns with sret.

The concepts

01RISC vs CISC: The Philosophy That Shapes Everything

Before a single RISC-V register is introduced, you need to understand the design philosophy behind it, because the philosophy explains every design choice you will encounter.

CISC (Complex Instruction Set Computer) — exemplified by x86 — grew organically over decades. Instructions like ENTER, LOOP, SCASB, and REP MOVS encode complex behaviors in a single opcode. The reasoning was pragmatic: in the 1970s and 80s, memory was expensive and compilers were bad. A single ENTER instruction could replace 5-10 instructions, saving both memory and the programmer's effort. The hardware would decode and micro-sequence the complex instruction.

RISC (Reduced Instruction Set Computer) — the family to which RISC-V belongs — inverts this. Insights from the 1980s (Patterson, Hennessy, and colleagues) showed that real programs only used a small fraction of CISC instructions anyway, that a simple regular instruction set could be pipelined far more efficiently, and that good compilers could generate tight code from simple primitives. The key RISC principles are:

  1. Fixed-width instructions — every instruction is the same size (32 bits in RISC-V base). This makes fetch-and-decode trivially parallel.
  2. Load/store architecture — only LOAD and STORE instructions touch memory. All computation happens on registers. No ADD [mem], reg as in x86.
  3. Few addressing modes — RISC-V has essentially one: base register + immediate offset. Compare to x86's [base + index*scale + displacement].
  4. Plenty of registers — 32 general-purpose registers (x86 has historically had only 8; even x86-64 has 16). More registers = fewer memory spills = faster code.
  5. Hardwired zero — register x0 is always zero. Writing to it is a no-op. This elegantly eliminates need for a MOVE-immediate-zero instruction and makes many pseudo-instructions free.

The net effect: RISC-V is remarkably regular. Once you know the pattern — opcode rd, rs1, rs2 or opcode rd, rs1, imm — almost every instruction fits it. Your brain can focus on what the program does rather than decoding the instruction syntax.

# x86-64: add two memory values (CISC style — memory operand directly)
# add the value at [rbx] to the value at [rax]
addq (%rbx), %rax

# RISC-V RV64I: must load first, then add, then store (load/store style)
# lw  = load word, add = add registers, sw = store word
lw   t0, 0(a1)     # t0 = Memory[a1+0]
lw   t1, 0(a2)     # t1 = Memory[a2+0]
add  t2, t0, t1   # t2 = t0 + t1  (register-only operation)
sw   t2, 0(a0)    # Memory[a0+0] = t2

02RISC-V Modularity: A Menu, Not a Monolith

One of RISC-V's most distinctive features is that it is modular by design. The ISA is not one big spec but a small mandatory base plus a precisely defined set of optional extensions. This is not an afterthought — it is a core design goal, enabling RISC-V to scale from a 32-bit microcontroller with 16KB of flash all the way to a 64-bit supercomputer node.

The naming convention encodes the selected modules:

  • RV — RISC-V
  • 32 / 64 / 128 — address width (32-bit, 64-bit, or 128-bit)
  • I — base integer ISA (mandatory; 47 instructions for RV32I)
  • M — integer multiply/divide (MUL, DIV, REM)
  • A — atomic operations (LR/SC, AMO*) — required for OS kernels and multi-core
  • F — single-precision floating point (32-bit)
  • D — double-precision floating point (64-bit; requires F)
  • C — compressed instructions (16-bit encodings for common instructions; improves code density ~25-30%)
  • Zicsr — control and status register instructions (required for privileged software)
  • Zifencei — instruction-fetch fence (required if you ever write self-modifying code or a JIT)

A standard Linux-capable RISC-V CPU is specified as RV64GC where G is a shorthand for IMAFD (the 'general purpose' combination) plus Zicsr and Zifencei, and C adds compressed instructions. When you look at a Linux kernel config or a toolchain flag like -march=rv64gc, that is exactly what it means.

This modularity matters for the kernel. The kernel must know at compile time (and sometimes at runtime via the riscv_isa_* functions) which extensions are available. Code in arch/riscv/ has #ifdef CONFIG_RISCV_ISA_A guards for atomics, separate FPU save/restore paths, etc. Understanding extensions is not optional for kernel work — it is the first thing you check when reading any RISC-V kernel file.

# Compile a bare-metal program for RV32IM (base + multiply, no FPU)
riscv64-unknown-elf-gcc -march=rv32im -mabi=ilp32 -o prog prog.c

# Compile for a full Linux-capable RV64GC system
riscv64-linux-gnu-gcc -march=rv64gc -mabi=lp64d -o prog prog.c

# The kernel's own Makefile for RISC-V:
# arch/riscv/Makefile sets:
#   KBUILD_CFLAGS += -march=rv64imac_zicsr_zifencei
# (atomics and CSR instructions are mandatory for a kernel)

03Registers: The CPU's Scratch Pad

RISC-V has 32 integer registers named x0 through x31, each 64 bits wide on an RV64 system (32 bits on RV32). The hardware does not attach meaning to most of these names — the meaning comes from a calling convention agreed upon by compilers, linkers, and OS code. This calling convention for Linux is the RISC-V psABI (processor-specific ABI).

Knowing the ABI names (aliases) is mandatory for reading compiler output and OS code:

RegisterABI NameRole
x0zeroHardwired zero. Reads always return 0; writes are discarded.
x1raReturn address (where a function should jump back to after it finishes).
x2spStack pointer (points to the top of the current stack frame).
x3gpGlobal pointer (used by some ABIs for fast access to global variables).
x4tpThread pointer (points to thread-local storage; the kernel uses this for the current task).
x5–x7t0–t2Temporary registers (caller-saved; a function may clobber them).
x8s0/fpSaved register / frame pointer (callee-saved).
x9s1Saved register (callee-saved).
x10–x11a0–a1Function arguments / return values (caller-saved).
x12–x17a2–a7Function arguments (caller-saved).
x18–x27s2–s11Saved registers (callee-saved — a function that uses these must restore them).
x28–x31t3–t6Temporary registers (caller-saved).

Two concepts from this table are critical for kernel work:

Caller-saved vs callee-saved: When function A calls function B, caller-saved registers (t*, a*) may be destroyed by B. If A needs them after the call, A must push them to the stack first. Callee-saved registers (s*) are guaranteed to be the same after any function call — if B wants to use them, B must save and restore them.

The zero register: Because x0 always reads as 0, the assembler can implement useful pseudo-instructions without new hardware: mv rd, rs is addi rd, rs, 0; not rd, rs is xori rd, rs, -1; beqz rs, label is beq rs, zero, label. This is one of RISC-V's most elegant design choices.

The tp register in the kernel: On Linux/RISC-V, the kernel stores a pointer to the current thread's task_struct in tp (x4) when running in kernel mode. This is how code in arch/riscv/include/asm/current.h implements get_current() — it simply reads tp.

# Illustrating caller/callee save in RISC-V assembly
# Suppose we want to preserve a0 across a call to some_func()

    addi   sp, sp, -16    # grow stack by 16 bytes
    sd     ra, 8(sp)      # save return address (ra is caller-saved across calls we make)
    sd     a0, 0(sp)      # save a0 (caller-saved; some_func may clobber it)
    call   some_func      # a0..a7, t0..t6 may be destroyed
    ld     a0, 0(sp)      # restore our a0
    ld     ra, 8(sp)      # restore return address
    addi   sp, sp, 16     # shrink stack back
    ret                   # jump to ra (pseudo for jalr x0, ra, 0)

04Instruction Formats: The Six Shapes of Every RISC-V Instruction

Every base RISC-V instruction is exactly 32 bits wide (the C extension adds 16-bit compressed variants, but we start with the base). Rather than having one encoding per instruction, RISC-V groups instructions into six formats based on what operands they need. Understanding the formats means you can decode any RISC-V instruction by hand — a skill that pays off when staring at a raw hex dump in a crash report.

R-type — Register–Register operations (e.g., add, sub, and, or, sll):

[31:25] funct7 | [24:20] rs2 | [19:15] rs1 | [14:12] funct3 | [11:7] rd | [6:0] opcode

Both source operands are registers. funct7 and funct3 together select the specific operation within the opcode group.

I-type — Immediate operand (e.g., addi, lw, jalr, ecall):

[31:20] imm[11:0] | [19:15] rs1 | [14:12] funct3 | [11:7] rd | [6:0] opcode

The 12-bit immediate is sign-extended to 64 bits before use.

S-type — Store instructions (e.g., sw, sd):

[31:25] imm[11:5] | [24:20] rs2 | [19:15] rs1 | [14:12] funct3 | [6:0] opcode

The immediate is split across two fields (a quirk that keeps rs1 and rs2 at fixed bit positions across all formats — making decode logic simpler in hardware).

B-type — Branch instructions (e.g., beq, bne, blt, bge):

[31] imm[12] | [30:25] imm[10:5] | [24:20] rs2 | [19:15] rs1 | [14:12] funct3 | [11:8] imm[4:1] | [7] imm[11] | [6:0] opcode

The immediate encodes a PC-relative byte offset. Note imm[0] is always 0 (instructions are at least 2-byte aligned), so it is implicit.

U-type — Upper immediate (e.g., lui, auipc):

[31:12] imm[31:12] | [11:7] rd | [6:0] opcode

Loads a 20-bit value into the upper 20 bits of a register. Combined with I-type instructions, this lets you construct any 32-bit constant (or address) in two instructions.

J-type — Jump (e.g., jal):

[31] imm[20] | [30:21] imm[10:1] | [20] imm[11] | [19:12] imm[19:12] | [11:7] rd | [6:0] opcode

The immediate bits are scrambled (like B-type) to keep rs1/rs2 at fixed positions in other formats, simplifying the decode datapath.

The key insight: rs1 is always bits [19:15], rs2 is always [24:20], rd is always [11:7] across all formats where they appear. The CPU can begin reading registers from the register file before fully decoding which operation to perform. This is a deliberate hardware optimization baked into the ISA design.

# Decoding a 32-bit RISC-V instruction by hand
# Hex: 0x00A58533
# Binary: 0000 0000 1010 0101 1000 0101 0011 0011

# [6:0]   opcode = 0110011  -> R-type ALU (OP)
# [11:7]  rd     = 01010    -> x10 = a0
# [14:12] funct3 = 000      -> ADD (not SUB, since funct7 = 0)
# [19:15] rs1    = 01011    -> x11 = a1
# [24:20] rs2    = 01010    -> x10 = a0
# [31:25] funct7 = 0000000  -> confirms ADD

# So: 0x00A58533 = add a0, a1, a0
# Meaning: a0 = a1 + a0

05Core Instruction Set: The 47 Instructions You Need to Know

RV32I has 47 instructions total. Rather than memorizing them as a list, group them by what job they do. This mental model maps directly to how compilers use them.

Arithmetic and Logic (R-type and I-type):

  • add rd, rs1, rs2 / addi rd, rs1, imm — add; immediate variant is the workhorse for pointer arithmetic and loop counters
  • sub rd, rs1, rs2 — subtract (no subi; use addi with a negative immediate)
  • and, or, xor + immediate variants — bitwise ops; fundamental for bit manipulation in kernel code (setting/clearing flags, masking IRQ bits)
  • sll/srl/sra + immediate variants — logical left/right shift, arithmetic right shift (sign-extending); slli rd, rs, 3 multiplies by 8
  • slt/sltu + immediate variants — set-less-than (signed/unsigned); returns 1 if rs1 < rs2, else 0; the basis for comparison in branches

Load/Store:

  • lw rd, offset(rs1) / sw rs2, offset(rs1) — load/store word (32-bit)
  • ld/sd — doubleword (64-bit, RV64 only)
  • lh/lhu/sh — halfword (16-bit), signed/unsigned load
  • lb/lbu/sb — byte (8-bit), signed/unsigned load The suffix u on loads means zero-extend rather than sign-extend.

Control Flow:

  • beq, bne — branch if equal / not equal
  • blt, bge, bltu, bgeu — branch if less-than / greater-or-equal (signed and unsigned variants)
  • jal rd, offset — jump and link; jal ra, func calls func, saving return address in ra; jal x0, label is an unconditional jump (no return saved)
  • jalr rd, rs1, offset — jump and link register; jalr x0, ra, 0 is ret

Upper Immediate / Address Construction:

  • lui rd, imm — load upper immediate; puts a 20-bit value in bits [31:12] of rd, zeroing bits [11:0]
  • auipc rd, imm — add upper immediate to PC; crucial for position-independent code

To load a 32-bit constant like 0xDEAD_BEEF into a register:

lui  t0, 0xDEADB   # t0 = 0xDEADB000
addi t0, t0, 0xEEF # WRONG if 0xEEF is negative when sign-extended!

This is the classic RISC-V constant-loading gotcha: addi's 12-bit immediate is sign-extended, so if bit 11 of the lower 12 bits is set, it sign-extends to a large negative number. The fix is to bias the upper immediate up by 1: lui t0, 0xDEADC then addi t0, t0, -0x111. The assembler's li (load immediate) pseudo-instruction handles this automatically.

System Calls (ecall / ebreak):

  • ecall — environment call; transfers control to the next privilege level (from user to kernel, or kernel to machine-mode firmware). This is how system calls work on Linux/RISC-V: load syscall number into a7, arguments into a0–a5, then ecall.
  • ebreak — breakpoint; used by debuggers (GDB) to insert software breakpoints.
# Canonical RISC-V system call: write(1, buf, len)
# Linux/RISC-V ABI: syscall number in a7, args in a0-a5

    .section .text
    .globl _start
_start:
    la   a1, msg        # a1 = address of message (pseudo: auipc+addi)
    li   a2, 13         # a2 = length of message
    li   a0, 1          # a0 = fd 1 (stdout)
    li   a7, 64         # a7 = syscall number 64 = __NR_write on RISC-V Linux
    ecall               # invoke kernel

    li   a0, 0          # exit code 0
    li   a7, 93         # syscall 93 = __NR_exit
    ecall

    .section .rodata
msg:
    .ascii "Hello, RISC-V\n"

06The Program Counter and PC-Relative Addressing

The Program Counter (PC) is a special register that holds the address of the currently executing instruction. After each instruction, it automatically increments by 4 (the instruction size). Branches and jumps modify it to a different address.

RISC-V strongly favors PC-relative addressing: instead of encoding an absolute address (which would be different every time the program loads at a different base address), you encode an offset from the current PC. This is the foundation of Position-Independent Code (PIC) and Position-Independent Executables (PIE) — modern Linux loads executables at random base addresses (ASLR) for security, which requires all branches and address loads to be PC-relative.

auipc rd, imm is the key instruction here. It sets rd = PC + (imm << 12). Combined with addi, it can construct an address anywhere within ±2GB of the current instruction — this ±2GB range is called the PC-relative addressing window. The pseudoinstruction la rd, symbol (load address) expands to exactly this auipc+addi pair.

For function calls, jal ra, offset encodes a 21-bit PC-relative offset (covering ±1MB). For longer calls, the compiler uses auipc + jalr, which can reach anywhere in the 64-bit address space by constructing the full address in a register first.

Why does this matter for the kernel? When the kernel boots, before the MMU is enabled, it runs at a physical address that may differ from the virtual address it was linked for. The kernel's very early boot code (arch/riscv/kernel/head.S) uses PC-relative addressing exclusively during this phase — absolute addresses would point to the wrong physical memory. After the MMU is enabled and the kernel is mapped at its expected virtual address, normal absolute references work. Reading head.S with this in mind makes the code much less mysterious.

# What the assembler generates for:  la a0, my_variable
# (assuming my_variable is 0x1234 bytes ahead of the auipc)

auipc  a0, 0x0       # a0 = PC + 0  (upper 20 bits of offset are 0 here)
addi   a0, a0, 0x1234  # a0 = PC_of_auipc + 0x1234

# For a longer-range case where my_variable is 0x12345678 bytes ahead:
# auipc a0, 0x12346   # note: +1 because addi's lower part is sign-extended from 0x678
# addi  a0, a0, 0x678-0x1000  # assembler handles the bias automatically

07Function Calls and the Stack Frame

Functions in assembly are just agreed-upon conventions about how to call code and get a result back. RISC-V's calling convention (the psABI) specifies every detail. Understanding it lets you read compiler output, write hand-optimized routines, and understand what the kernel's switch_to() macro actually does.

The call sequence:

  1. Caller places arguments in a0–a7 (up to 8 arguments; larger structs are passed via pointer or on the stack).
  2. Caller executes jal ra, callee (or call callee pseudo-instruction), which stores PC+4 (the return address) in ra and jumps.
  3. Callee executes its body. If it calls further functions (it is not a leaf function), it must save ra on the stack first, or it will be overwritten.
  4. Callee places return value in a0 (and a1 for 128-bit returns).
  5. Callee executes ret (= jalr x0, ra, 0), jumping back to the saved return address.

The stack frame: The stack on RISC-V (and all mainstream architectures) grows downward — toward lower addresses. sp points to the lowest address in use. To allocate a new stack frame:

addi sp, sp, -N   # grow stack by N bytes (N must be 16-byte aligned per the ABI)

To deallocate:

addi sp, sp, N    # shrink stack

Callee-saved registers: If a function wants to use s0–s11 (the saved registers), it must save them to the stack at function entry and restore them before returning. The caller is then guaranteed they are unchanged. This is not enforced by hardware — it is a software contract. Breaking it causes subtle, hard-to-debug corruption.

Frame pointer (s0/fp): When the ABI's frame pointer convention is enabled (-fno-omit-frame-pointer, which the kernel uses), s0 points to the top of the current stack frame. This creates a linked list of frames — each frame's s0 points to the previous frame's s0 — enabling stack unwinding for backtraces, perf, and kernel panics.

# C function: int add(int a, int b) { return a + b; }
# Compiled RISC-V assembly (leaf function, no stack frame needed):
add:
    addw  a0, a0, a1   # a0 = a0 + a1 (addw = 32-bit add on RV64)
    ret                # return (jalr x0, ra, 0)

# Non-leaf function that calls add():
int outer(int x) { return add(x, 42); }
outer:
    addi  sp, sp, -16  # allocate stack frame
    sd    ra, 8(sp)    # save return address (ra will be overwritten by call)
    li    a1, 42       # second argument = 42
    call  add          # a0 already has x; this overwrites ra
    ld    ra, 8(sp)    # restore return address
    addi  sp, sp, 16   # deallocate frame
    ret

08Privileged Architecture and CSRs: How the Kernel Talks to Hardware

Everything described so far is unprivileged RISC-V — the instructions that user-space programs use. But kernels need to do things user programs cannot: configure the MMU, handle interrupts, set up CPU features. This is done through the privileged architecture, which adds a second layer of registers called Control and Status Registers (CSRs).

RISC-V defines three privilege levels:

  • M-mode (Machine mode) — the highest; fully controls all hardware. Firmware like OpenSBI runs here.
  • S-mode (Supervisor mode) — where the Linux kernel runs.
  • U-mode (User mode) — where userspace programs run.

CSRs are a separate 4096-entry register file, addressed by a 12-bit number. They are accessed via the Zicsr extension instructions:

  • csrr rd, csr — read CSR into rd
  • csrw csr, rs — write rs into CSR
  • csrrw rd, csr, rs — atomically read old value into rd and write rs
  • csrrs/csrrc — read CSR, then set/clear bits using rs as a mask

Key CSRs for S-mode (the kernel's domain):

  • sstatus — supervisor status; contains the SIE (supervisor interrupt enable) bit, SPP (previous privilege before trap), and others.
  • sepc — supervisor exception program counter; when a trap occurs, the hardware saves the faulting PC here. sret returns execution to sepc.
  • scause — encodes why a trap occurred (interrupt vs. exception, cause code). The kernel's trap handler (arch/riscv/kernel/entry.S) reads this to dispatch to the correct handler.
  • stval — supplemental trap value; for page faults, this holds the faulting virtual address.
  • stvec — supervisor trap vector; the kernel writes the address of its trap entry point here during boot so the CPU knows where to jump when an exception occurs.
  • satp — supervisor address translation and protection; writing to this enables/switches the MMU's page table. Contains the page table root physical address, the ASID, and the paging mode (Sv39/Sv48).

This is where RISC-V kernel code becomes truly exciting: arch/riscv/kernel/entry.S has a direct dialogue with these registers on every system call, interrupt, and page fault.

/* From Linux arch/riscv/kernel/head.S (simplified) */
/* During boot, set up the trap vector before enabling the MMU */

    la   t0, handle_exception  /* address of trap handler C function */
    csrw stvec, t0             /* tell CPU to jump here on any trap */

    /* Enable supervisor interrupts */
    li   t0, SR_SIE            /* SR_SIE = (1 << 1) in sstatus */
    csrs sstatus, t0           /* set the SIE bit */

/* To read the cause of a trap in the handler: */
    csrr a0, scause            /* a0 = cause code */
    csrr a1, stval             /* a1 = faulting address (for page faults) */
    csrr a2, sepc              /* a2 = PC that caused the trap */

09Pseudo-Instructions: The Assembler's Kindness

The RISC-V assembler (GNU as, LLVM llvm-mc) defines a set of pseudo-instructions — mnemonics that expand to one or more real instructions but look like single operations. They exist purely for human readability and programmer convenience. Learning the most common ones lets you read both compiler output and hand-written assembly without confusion.

Pseudo-instructionExpands toMeaning
li rd, immlui+addi or just addi x0Load any immediate into a register
la rd, symbolauipc+addiLoad the address of a symbol (PC-relative)
mv rd, rsaddi rd, rs, 0Copy a register
retjalr x0, ra, 0Return from function
call funcauipc ra, offset_hi + jalr ra, ra, offset_loCall a function
j labeljal x0, labelUnconditional jump (no return saved)
jr rsjalr x0, rs, 0Jump to address in register
nopaddi x0, x0, 0No operation
not rd, rsxori rd, rs, -1Bitwise NOT
neg rd, rssub rd, x0, rsArithmetic negation
seqz rd, rssltiu rd, rs, 1Set if equal to zero
snez rd, rssltu rd, x0, rsSet if not equal to zero
beqz rs, labelbeq rs, x0, labelBranch if zero
bnez rs, labelbne rs, x0, labelBranch if not zero
bgt rs, rt, labelblt rt, rs, labelBranch if greater-than (args swapped)

Note that pseudo-instructions do not add any new hardware capability — they are purely syntactic sugar. When you see them in disassembler output (objdump -d) most disassemblers will show the expanded form, though modern versions of objdump for RISC-V will show the pseudo-instruction form where it exists. Compiler Explorer (godbolt.org) is invaluable here — paste C code, select RISC-V gcc, and watch which pseudo-instructions appear.

# These two blocks are identical in machine code:

# Using pseudo-instructions (human-readable):
    li    a0, 42
    mv    a1, a0
    beqz  a1, done
    ret
done:
    nop

# Expanded to real instructions:
    addi  a0, x0, 42       # li a0, 42
    addi  a1, a0, 0        # mv a1, a0
    beq   a1, x0, done     # beqz a1, done
    jalr  x0, ra, 0        # ret
done:
    addi  x0, x0, 0        # nop

10A Complete Program: From Source to Running on QEMU

Theory becomes real when you can write, assemble, link, and run something. RISC-V has excellent toolchain support, and QEMU's user-mode emulation (qemu-riscv64) lets you run RISC-V binaries directly on an x86 or ARM machine without any hardware.

The workflow is:

  1. Write assembly (.s file) or C (.c file)
  2. Assemble/compile with riscv64-linux-gnu-gcc (cross-compiler)
  3. Run with qemu-riscv64 ./program

Let's write a program that computes the factorial of 5 using a loop — demonstrating registers, arithmetic, branching, and the Linux system call interface.

Key toolchain packages on Ubuntu/Debian: gcc-riscv64-linux-gnu, qemu-user. On macOS via Homebrew: brew install riscv-gnu-toolchain qemu.

Once comfortable with assembly, exploring Compiler Explorer (godbolt.org) with the RISC-V GCC target is invaluable: you can write C, see the generated assembly, and hover over instructions to see their binary encoding. This tight feedback loop — C source, compiler output, manual inspection — is exactly how kernel developers learn to write optimized architecture-specific code.

# factorial.s — compute 5! = 120 and print it
# Build:  riscv64-linux-gnu-gcc -static -o factorial factorial.s
# Run:    qemu-riscv64 ./factorial

    .section .text
    .globl _start
_start:
    li    s0, 5          # n = 5 (loop counter, callee-saved so it survives calls)
    li    s1, 1          # result = 1

loop:
    beqz  s0, done       # if n == 0, we're done
    mul   s1, s1, s0     # result *= n  (requires M extension; use MUL)
    addi  s0, s0, -1     # n--
    j     loop

done:
    # Convert integer in s1 to ASCII decimal and write to stdout
    # For simplicity, s1 = 120 = 0x78; we'll write the decimal digits
    # (Production code would call a helper; here we hardcode for clarity)

    # Store "120\n" in stack memory
    addi   sp, sp, -8
    li     t0, 0x0A303231   # ASCII "120\n" in little-endian: '1'=0x31,'2'=0x32,'0'=0x30,'\n'=0x0A
    sw     t0, 0(sp)

    li     a0, 1            # fd = 1 (stdout)
    mv     a1, sp           # buf = sp
    li     a2, 4            # len = 4 bytes
    li     a7, 64           # syscall __NR_write = 64
    ecall

    addi   sp, sp, 8

    li     a0, 0            # exit code
    li     a7, 93           # syscall __NR_exit = 93
    ecall

Key insights

  • ✦The hardwired-zero register (x0) is not a trick — it is a fundamental design principle that eliminates entire categories of instructions by making 'zero' a first-class operand.
  • ✦RISC-V's modularity is not cosmetic: the kernel must handle CPUs that lack the M, A, or F extensions at runtime, making extension awareness a real kernel programming concern.
  • ✦All RISC-V instruction formats keep rs1 at bits [19:15] and rs2 at [24:20]. This is a deliberate hardware optimization — register reads start before full decode completes.
  • ✦PC-relative addressing (auipc) is not just convenient — it is mandatory for the kernel's early boot code, which runs at a physical address that differs from its linked virtual address.
  • ✦The calling convention (psABI) is a software contract, not a hardware rule. Breaking it produces silent, non-deterministic corruption — the worst kind of bug.
  • ✦CSRs are where user-space assembly ends and kernel assembly begins. The stvec/sepc/scause/satp quartet controls interrupts, exceptions, and virtual memory — every OS feature flows through these registers.
  • ✦RISC-V's clean design means the arch/riscv/ kernel subtree is significantly smaller and more readable than arch/x86/ or arch/arm64/ — it is the best on-ramp for reading architecture-specific kernel code.

Common pitfalls

  • ⚠The sign-extension trap with lui+addi: if the lower 12 bits of your constant have bit 11 set, addi sign-extends and subtracts 4096. Always bias the upper immediate up by 1, or use the 'li' pseudo-instruction and let the assembler handle it.
  • ⚠Forgetting to save ra before calling a non-leaf function: your function's return address gets silently overwritten, and the function appears to jump to a garbage address. This is one of the most common beginner assembly bugs.
  • ⚠Confusing caller-saved and callee-saved registers: storing important values in t0–t6 across a function call will lose them. Use s0–s11 for values that must survive calls, but remember to save and restore them at function entry/exit.
  • ⚠Stack alignment: the RISC-V ABI requires the stack pointer to be 16-byte aligned at all times (even on RV32). Violating this causes subtle failures, especially when the called function uses vector or FPU instructions that require alignment.
  • ⚠Using the wrong load instruction for bytes/halfwords: lb sign-extends to 64 bits, lbu zero-extends. Loading a value like 0xFF with lb gives you -1, not 255. When in doubt about the sign semantics, use the unsigned variant.
  • ⚠Assembling for RV64 but forgetting to use 'w'-suffix instructions for 32-bit arithmetic: on RV64, 'add' operates on 64-bit values. Use 'addw' when you want 32-bit wraparound semantics (e.g., int arithmetic in C). Mixing them causes hard-to-spot sign-extension bugs.
  • ⚠Assuming QEMU user-mode emulation perfectly matches real hardware: QEMU does not emulate pipeline hazards, cache effects, or memory-ordering subtleties. Always test on real hardware or full-system QEMU before declaring something correct for the kernel.

Practice

  1. 01
    easy

    Install a RISC-V cross-compiler and QEMU user-mode emulation on your machine. Write a RISC-V assembly program that computes the nth Fibonacci number (where n is hardcoded as 10) using a loop (not recursion), stores the result, and prints it to stdout via the Linux write syscall. Assemble, link statically, and run it with qemu-riscv64.

  2. 02
    easy

    Write a RISC-V assembly function 'strlen' that takes a pointer to a null-terminated string in a0 and returns the length (not including the null byte) in a0. Then write a _start that calls strlen on a string literal and uses the returned length in a write syscall — without hardcoding the length. This forces you to manage the call convention correctly.

  3. 03
    medium

    Use Compiler Explorer (godbolt.org) with 'RISC-V (64-bit) gcc' and '-O2 -march=rv64imac'. Write a C function that does a binary search on a sorted integer array. Study the generated assembly: identify every register used, classify each as caller-saved or callee-saved, find the branch instructions and match them to the C conditionals, and locate any use of auipc. Write a 1-page explanation of what you found.

  4. 04
    medium

    Write a hand-optimized RISC-V assembly implementation of 'memcpy' that copies 8-byte-aligned buffers using 'ld'/'sd' pairs (64-bit loads/stores) for the bulk of the copy, with a byte-by-byte fallback for the tail. Compare its performance against the glibc memcpy by timing both on a large buffer inside qemu-riscv64. Report the speedup (or lack thereof) and explain why.

  5. 05
    hard

    Clone the Linux kernel source. Navigate to 'arch/riscv/kernel/entry.S'. Find the trap entry point (look for the 'stvec' setup or the label referenced in head.S when csrw stvec is called). Read the first 50 lines of the trap handler. Write a detailed line-by-line commentary explaining: what each instruction saves, why it is saved in that order, and what would go wrong if any save were omitted. Submit your annotated version.

Resources

📄The RISC-V Instruction Set Manual, Volume I: Unprivileged ISA
docsfree

The official spec — dense but authoritative. Chapters 2-5 cover the base ISA and extensions. Read alongside code, not in isolation.

📄The RISC-V Instruction Set Manual, Volume II: Privileged Architecture
docsfree

Covers M/S/U privilege modes, CSRs, traps, and virtual memory — required reading before touching arch/riscv/ kernel code.

📕Computer Organization and Design RISC-V Edition
bookpaidPatterson and Hennessy, Morgan Kaufmann. The canonical undergraduate textbook, now with a RISC-V edition. Chapters 2-4 are essential.

Written by two of the people who invented RISC. The explanations of pipelining and datapath design are unmatched.

📄RISC-V Assembly Programmer's Manual
docsfree

Practical guide to the assembler syntax, pseudo-instructions, and calling convention. Bookmark this.

⚡Compiler Explorer (Godbolt) — RISC-V GCC/Clang
interactivefree

Write C, see RISC-V assembly instantly. Indispensable for building intuition about what the compiler does and why.

📄RISC-V Linux Kernel Port — arch/riscv on kernel.org
docsfree

The real thing. Start with head.S, entry.S, and include/asm/ptrace.h. Small enough to read fully — unlike arch/x86/.

✎RISC-V Bytes — blog series by Daniel Mangum
articlefree

Exceptionally clear deep-dives into specific RISC-V topics: trap handling, virtual memory, SBI. Bridges spec and kernel code.

✎An Introduction to RISC-V Boot Flow — LWN.net
articlefree

Explains OpenSBI, U-Boot, and the Linux kernel boot sequence on RISC-V. Places the privileged architecture in real context.

Checkpoints — you can now…

  • ✓You can explain, in your own words, why RISC architectures prefer load/store over memory operands and what hardware benefit this delivers.
  • ✓You can name all 32 RISC-V registers by their ABI aliases, state whether each is caller-saved or callee-saved, and explain the special role of x0, ra, sp, tp, and a0-a7.
  • ✓You can decode a 32-bit RISC-V instruction from its hex representation by hand, identifying opcode, rd, rs1, rs2, and immediate fields.
  • ✓You can write a RISC-V assembly function that correctly saves and restores callee-saved registers and the return address, and explain what would break if you omitted either.
  • ✓You can explain what auipc does and why PC-relative addressing is mandatory for the kernel's early boot code before the MMU is enabled.
  • ✓You can write a minimal RISC-V assembly program that makes a Linux system call (write + exit), compile it with the cross-toolchain, and run it under qemu-riscv64.
  • ✓You can name five key S-mode CSRs (stvec, sepc, scause, stval, satp), state what each holds, and describe what the CPU writes to them when a trap occurs.
  • ✓You can open arch/riscv/kernel/entry.S in the Linux kernel source and identify the trap entry label, the pt_regs save sequence, and the csrr instructions that read scause and sepc.