Table of Contents >> Show >> Hide
- What This Manual Covers
- Prerequisites: Your Toolbelt
- RISC-V Privilege Modes: The Three-Story House
- The Boot Chain: Firmware, SBI, Bootloader, Kernel
- Device Discovery: Device Tree (FDT) and the “Don’t Hardcode Addresses” Rule
- Virtual Memory on RISC-V: Sv39 Without Tears
- Traps and Interrupts: When the CPU Taps You on the Shoulder (Hard)
- “Hello Kernel” on QEMU: A Practical Run Recipe
- Writing Your Own Minimal RISC-V Kernel: A Step-by-Step Blueprint
- Porting an Existing OS to RISC-V: What Usually Breaks First
- Debugging Playbook: When Nothing Prints and Your Soul Leaves Your Body
- Security and Isolation: The “SUM Bit” and Other Subtle Guardians
- Recommended Learning Path: Build Confidence in Layers
- Experiences from the Trenches (About )
Welcome to the part of computing where your “Hello, World” can fail because a single bit in a control register
has feelings. This manual is a practical, OS-developer-friendly guide to getting an operating system (or a tiny
kernel) running on RISC-V, understanding the boot chain, setting up memory, handling traps, and
talking to hardwarewithout turning your weekend into a bug-shaped crater.
The goal: by the end, you’ll know the moving pieces well enough to (1) boot something real (Linux, BSD, or your
own kernel) on QEMU or hardware, and (2) debug the early bring-up when nothing prints and the CPU seems to be
judging you silently. (It is.)
What This Manual Covers
- RISC-V privilege modes (U/S/M) and why operating systems care
- The boot path: firmware → SBI → bootloader → kernel
- Device discovery via Device Tree (and where ACPI fits)
- Virtual memory on RISC-V (Sv39 focus): page tables, permissions, TLB rules
- Trap/interrupt handling: CSRs, vectors, delegation, timers
- Practical build + run workflow with QEMU, cross toolchains, and GDB
- Specific examples and “don’t do this unless you enjoy pain” notes
Prerequisites: Your Toolbelt
You don’t need a lab full of boards to start. QEMU gives you a solid virtual platform, and modern toolchains can
target RISC-V cleanly. You’ll generally want:
1) A Cross-Compiler Toolchain
You have two common “flavors,” depending on what you’re building:
- Bare-metal / kernel-only:
riscv64-unknown-elf-(no Linux ABI assumptions) - Linux userland target:
riscv64-linux-gnu-(glibc/musl ABI expectations)
If you prefer LLVM/Clang, you can target RISC-V with a triple like
--target=riscv64-unknown-linux-gnu and specify -march/-mabi.
Typical baseline for 64-bit general-purpose is:
2) QEMU (Your Virtual Test Bench)
QEMU’s virt machine for RISC-V is the go-to generic platform. It models a UART, a platform interrupt
controller, a timer source, and VirtIO devicesenough to boot serious OSes and your own experiments.
3) A Debugger Setup
Early boot bugs rarely come with friendly stack traces. You’ll want:
gdb-multiarchor a RISC-V GDB- QEMU’s built-in GDB stub (
-s -S) - Symbols in your kernel image (compile with debug info; don’t strip too early)
RISC-V Privilege Modes: The Three-Story House
RISC-V systems are organized around privilege modes. Think of them as floors in a building with increasing access:
- U-mode (User): applications; least privilege
- S-mode (Supervisor): the OS kernel typically lives here
- M-mode (Machine): firmware and the most privileged control
The key practical idea: on many RISC-V systems, your OS kernel is not the top boss. M-mode often hosts platform
firmware that provides services to S-mode through the SBI (Supervisor Binary Interface). That separation is
deliberate: it keeps the core ISA clean and pushes platform specifics into a well-defined interface.
CSR Reality Check
Control and Status Registers (CSRs) are how the CPU exposes privilege behavior:
- Status:
sstatus/mstatusinterrupt enable bits, privilege flags - Trap setup:
stvec/mtvec,sscratch/mscratch - Trap info:
scause,sepc,stval - Interrupts:
sie/sipand machine equivalents - MMU:
satpselects translation mode and points at your page-table root
The Boot Chain: Firmware, SBI, Bootloader, Kernel
On a typical Unix-capable RISC-V platform, the boot flow looks like this:
- Reset → M-mode firmware initializes essential hardware
- SBI runtime becomes available (often via OpenSBI)
- Bootloader (e.g., U-Boot) loads your kernel and passes a Device Tree
- Kernel starts in S-mode, sets up paging, traps, drivers, then userspace
What SBI Actually Does (and Why You Care)
SBI is the “call platform services” layer for supervisor software. Instead of your S-mode kernel poking random
machine-mode registers (which may not even exist consistently across vendors), it uses ecall to ask
the SBI implementation to do privileged or platform-specific tasks.
Common SBI services you’ll bump into early:
- Console I/O (handy for early logging on some setups)
- Timer programming (setting the next timer interrupt)
- Hart management (bringing cores up/down, querying state)
- System reset/shutdown
Device Discovery: Device Tree (FDT) and the “Don’t Hardcode Addresses” Rule
If you’ve ever written bare-metal code where UART is “always at 0x10000000,” take a breath. On modern RISC-V
systems, the OS is expected to discover hardware via a hardware descriptionmost commonly a Device Tree Blob (DTB).
In QEMU’s virt machine, QEMU can generate the device tree and hand it to the guest. Your kernel should
read that description to find UART, interrupt controllers, VirtIO devices, memory layout, CPU count, and so on.
Boot Register Convention (Conceptual)
A typical RISC-V kernel entry convention includes:
a0: hart IDa1: pointer to the device tree blob (physical address)
This detail matters because the first thing your kernel needsbefore drivers, before “pretty boot logos,” before
existential dreadis knowing where RAM is and which devices exist.
Virtual Memory on RISC-V: Sv39 Without Tears
RISC-V supports multiple paging schemes. For 64-bit OS work, Sv39 is the common starting point: it provides a
39-bit virtual address space using a 3-level page table with 4 KiB pages.
Sv39 Address Anatomy
A Sv39 virtual address is effectively:
- 27 bits of page-table indexing (3 levels × 9 bits each)
- 12 bits of page offset
One subtlety: addresses are “canonical.” Upper bits must match the sign bit of the implemented VA range, or you
fault. This is not the CPU being dramatic; it’s the CPU preventing you from pretending you have more address space
than you do.
PTE Flags You’ll Meet Immediately
Page Table Entries (PTEs) include permission and state bits. The “classic set” you’ll care about in an OS bring-up:
- V: valid
- R/W/X: readable / writable / executable
- U: user accessible
- G: global mapping
- A/D: accessed / dirty (used by OS for paging policies; may be hardware-managed or trapped)
Turning the MMU On: satp + sfence.vma
Enabling paging usually follows this rhythm:
- Build initial page tables in physical memory
- Write
satpwith mode (Sv39) and the physical page number of the root table - Flush translations with
sfence.vma - Jump to code mapped in the new virtual layout
The classic beginner mistake: enabling paging and immediately executing code from an address that isn’t mapped in
the new page table. That’s like stepping onto a staircase you forgot to install.
Traps and Interrupts: When the CPU Taps You on the Shoulder (Hard)
“Traps” include exceptions (page faults, illegal instructions) and interrupts (timer, external devices). On RISC-V,
trap handling is highly explicit: the CPU records what happened in CSRs and jumps to a vector you configured.
Supervisor Trap Setup (S-mode)
In S-mode, you typically configure:
stvec: trap vector base address (direct or vectored)sscratch: scratch storage pointer (often per-hart/per-thread)sie: which interrupts are enabledsstatus: global S-mode interrupt enable bit plus mode flags
Delegation: Who Handles What?
Many events can be delegated from M-mode to S-mode using delegation CSRs (medeleg, mideleg).
If delegation isn’t set, an exception/interrupt might go to M-modegreat if you’re writing firmware, confusing if
you’re writing an OS and wondering why your S-mode handler never fires.
Timer Interrupts: The Heartbeat of Scheduling
On a lot of platforms (including QEMU virt setups), the OS programs the timer through SBI calls rather than poking
hardware registers directly. Conceptually:
Once timer interrupts arrive reliably, you can implement preemptive multitasking, timekeeping, and the kind of
“my kernel is alive” milestones that deserve celebratory snacks.
External Interrupts: PLIC in Practice
Many RISC-V platforms use a Platform-Level Interrupt Controller (PLIC) for external device interrupts.
Your OS will typically:
- Enable supervisor external interrupts
- Configure PLIC priorities and enable bits for devices
- On interrupt, claim an interrupt ID from the PLIC
- Dispatch a driver handler, then complete the interrupt
“Hello Kernel” on QEMU: A Practical Run Recipe
The fastest way to learn the boot chain is to boot something known-good and then replace parts. Here’s a conceptual
QEMU invocation that boots a kernel with a VirtIO disk and serial console:
Notes:
-nographicroutes the UART to your terminal (so logs show up where you can actually see them).-bios defaultoften loads a firmware/SBI stack appropriate for the virtual platform.- VirtIO devices are your friend: simple, well-supported, and great for OS bring-up.
Early Console Debugging Tip
If your kernel supports early console via SBI or UART, enable it as soon as possible. The first 50 lines of boot
output are where most existential mysteries are solved.
Writing Your Own Minimal RISC-V Kernel: A Step-by-Step Blueprint
If you’re building your own kernel (or a teaching OS), here’s the “instruction manual” order that tends to keep
you sane.
Step 1: Start with a Known Memory Map
Before virtual memory, define a simple physical layout: kernel image region, heap, stacks, device MMIO regions.
On QEMU virt, you can rely on the device tree to tell you RAM base/size instead of hardcoding it.
Step 2: Get One Output Path Working
Pick a single output strategy:
- UART driver (polling first, interrupts later)
- SBI console calls (handy in early stages on some environments)
Do not attempt “full logging framework” on day one. A single putchar() that works is worth more than
a thousand lines of elegant code that never runs.
Step 3: Set Up Traps in Direct Mode
Set stvec to a known-good handler. Implement:
- Save registers
- Read
scause,sepc,stval - Print a minimal diagnostic
- Advance
sepcwhen appropriate (e.g., for certain synchronous traps)
This becomes your “black box recorder.” When something explodes, it should at least tell you how.
Step 4: Bring Up Sv39 Paging
Start with a minimal mapping strategy:
- Identity-map enough physical memory to keep executing during transition
- Map the kernel higher-half (optional but common for Unix-like layouts)
- Map UART and interrupt controller MMIO regions
After writing satp and fencing, immediately jump to a mapped virtual address. If you “enable paging
and hope,” the CPU will teach you humility.
Step 5: Timer Ticks + Basic Scheduler
Implement:
- A periodic timer interrupt (SBI timer is a common route)
- A per-hart tick counter
- A trivial scheduler (round-robin is fine)
The moment your kernel can switch between two threads repeatedly without dying, you’re no longer “boot code.”
You’re an operating system (small, grumpy, but real).
Step 6: Interrupt-Driven UART and External Interrupts
Once trap handling is stable, move your UART from polling to interrupts. Then add PLIC support for external
interrupts. Expect a few “why is the interrupt line stuck” momentsthose are normal and character-building.
Step 7: Storage and Filesystem (VirtIO Makes This Easier)
With VirtIO block devices, you can:
- Read sectors
- Build a buffer cache
- Implement a minimal filesystem
- Launch user programs from disk
Teaching kernels often use simple Unix-like filesystems because they’re conceptually clean and great for learning.
Production OSes will use more advanced stacks, but the bring-up principles are the same.
Porting an Existing OS to RISC-V: What Usually Breaks First
If you’re porting rather than writing from scratch, here are the classic friction points:
1) Assumptions About “The Highest Privilege”
Some architectures assume the OS kernel runs at the top privilege. On many RISC-V systems, M-mode firmware exists
and must be treated as a layer you call through SBI. That changes how you do timers, power management, and some
low-level features.
2) MMU and Cache/TLB Rules
Missing an sfence.vma after changing page tables can cause “works once, fails randomly” behavior.
Also, canonical addressing rules can expose sloppy pointer assumptions.
3) Device Enumeration
OSes that rely on hardcoded device addresses will suffer. Device tree parsing and a clean driver model make RISC-V
ports dramatically smoother.
Debugging Playbook: When Nothing Prints and Your Soul Leaves Your Body
Here’s a practical workflow that saves time:
Use QEMU + GDB Stub
In another terminal:
Check the “Big Four” CSRs on Trap
If you trap unexpectedly, inspect:
scause: what happenedsepc: where it happenedstval: extra info (often the faulting address)sstatus: mode/interrupt context flags
Most early bring-up issues are:
- Bad page table mapping
- Jumping to unmapped code after enabling paging
- Incorrect device MMIO address (because DT parsing is wrong or absent)
- Interrupt enabled before handler is ready
Security and Isolation: The “SUM Bit” and Other Subtle Guardians
RISC-V includes mechanisms to enforce isolation between kernel and user memory. For example, supervisor-mode has
controls that affect whether it can access pages marked user-accessible. This is the kind of detail that becomes
critically important once you implement user processes and system calls.
Practical advice: when bringing up userspace, start strict (fault early, loudly) and loosen only when you
understand the implications. “It boots if I disable protections” is not a victory; it’s a suspense novel.
Recommended Learning Path: Build Confidence in Layers
If you’re new to OS work on RISC-V, a reliable progression is:
- Boot Linux/FreeBSD on QEMU RISC-V (learn the boot pipeline)
- Read a small RISC-V teaching kernel’s memory + trap chapters (learn the core mechanics)
- Implement a tiny kernel: UART output → traps → paging → timer → scheduler
- Add drivers (VirtIO is a great first target)
- Bring up userspace and syscalls
The secret: RISC-V isn’t “hard.” It’s explicit. It makes you confront the reality of boot and privilege early, but
once you learn the rules, it becomes refreshingly straightforward.
Experiences from the Trenches (About )
Developers who work on a RISC-V operating system often describe the first successful boot as equal parts triumph
and disbeliefbecause the path to that moment is a parade of tiny, high-impact details. Early on, it’s common to
spend an hour “debugging the kernel” only to discover the real issue was a missing console route: the kernel was
running, but talking into the void. QEMU helps here, because you can funnel serial output into your terminal and
quickly iterate. Still, the first time you hit a trap with no output, you learn a universal OS truth: a trap
handler that prints diagnostics is not a luxury; it’s life support.
Paging bring-up is another rite of passage. Sv39 isn’t conceptually complicated, but it is unforgiving about
assumptions. A typical failure mode looks like this: you build a page table, write satp, fence, and
then immediately execute an instruction from an address that isn’t mapped. The CPU responds with a page fault, and
if your trap vector also isn’t mapped correctly, you get a dramatic cascade. The lesson most people learn is to
transition in steps: keep a minimal identity map while switching, ensure the trap vector is reachable under the
current page table, and only then “graduate” to your intended virtual memory layout.
Timer interrupts tend to feel magical the first time they worksuddenly your kernel has a heartbeat. But they also
expose scheduling bugs fast. It’s common to set the timer too aggressively and accidentally create an interrupt
storm that makes the system look frozen. Or you enable interrupts before your handler saves registers correctly,
and the machine “works” right up until it doesn’t, usually at the worst possible moment. Many teams adopt a simple
rule: do not enable global interrupts until (1) the trap vector is installed, (2) you can reliably decode
scause, and (3) you can return from a trap without corrupting state.
Device discovery can be humbling too. People coming from microcontroller work sometimes want to hardcode MMIO
addresses. On RISC-V platforms, device trees encourage a healthier habit: ask the system what hardware exists.
When DT parsing is wrong, the symptoms can be oddly theatricallike probing a UART that isn’t there and wondering
why reads return nonsense. Once DT parsing is correct, driver work becomes dramatically cleaner because your kernel
stops guessing and starts knowing.
Finally, there’s a special kind of joy in using QEMU’s GDB stub to single-step early boot code. It’s slow, it’s
meticulous, and it’s occasionally hilarious (especially when you discover the bug is a single incorrect shift).
But it builds real intuition: you stop treating the OS as “a big program” and start seeing it as a careful
choreography between privilege levels, page tables, and interrupts. And once you’ve booted on QEMU, moving to real
silicon feels less like jumping off a cliff and more like climbing a slightly taller ladderstill scary, but at
least you brought a map.
