FRISC OS: First Boot on RISC-V
FRISC OS started with a single goal: print "Hello" to a UART on a RISC-V virtual machine. Here's how we got there.
The setup
QEMU's virt machine gives you a RISC-V hart, 128 MB of RAM, and a 16550-compatible UART at 0x10000000. No BIOS, no bootloader — your ELF binary gets loaded directly into memory at 0x80000000.
The boot sequence
We start in M-mode (machine mode), the highest privilege level. The first instructions set up a stack pointer and jump to our Lateralus entry point:
// boot.s - RISC-V assembly entry point
.section .text.entry
.global _start
_start:
la sp, _stack_top // Set stack pointer
call ltl_kernel_main // Jump to Lateralus
j . // Halt if we return
First output
The UART is memory-mapped. Writing a byte is one store instruction:
const UART_BASE: UInt = 0x10000000
fn uart_putc(c: Byte) {
let ptr = UART_BASE as *mut Byte
unsafe { *ptr = c }
}
fn uart_puts(s: String) {
s |> bytes() |> each(uart_putc)
}
The shell
Once UART TX and RX worked, we built a minimal shell. Type a command, get a response. It only understood help, info, and reboot, but it proved the system was alive.
What came next
After first boot: interrupt handling, timer setup, physical memory allocation, and then the big one — Sv39 virtual memory. Each step is documented in its own blog post.
Setting up the UART
The first thing any OS needs is a way to tell you it's alive. On QEMU's virt machine, that's the NS16550A UART at address 0x10000000:
const UART_BASE: u64 = 0x1000_0000
fn uart_init() {
let ptr = UART_BASE as *mut u8
// Enable FIFO, clear buffers, 14-byte threshold
unsafe { ptr.offset(2).write_volatile(0xC7) }
// Enable receive interrupts
unsafe { ptr.offset(1).write_volatile(0x01) }
// Set baud rate to 38400 (divisor = 3)
unsafe { ptr.offset(3).write_volatile(0x80) } // DLAB on
unsafe { ptr.offset(0).write_volatile(0x03) } // Divisor low
unsafe { ptr.offset(1).write_volatile(0x00) } // Divisor high
unsafe { ptr.offset(3).write_volatile(0x03) } // DLAB off, 8N1
}
fn uart_putc(c: u8) {
let ptr = UART_BASE as *mut u8
// Wait for transmit buffer empty
while unsafe { ptr.offset(5).read_volatile() } & 0x20 == 0 {}
unsafe { ptr.write_volatile(c) }
}
Those magic numbers are register offsets from the NS16550A datasheet. Every OS developer has this code memorized — it's the "hello world" of bare-metal programming.
Memory detection
QEMU passes a Device Tree Blob (DTB) that describes available hardware. We parse it to find usable memory regions:
fn detect_memory(dtb_addr: u64) -> Vec<MemoryRegion> {
let dtb = DeviceTree::parse(dtb_addr)
dtb.nodes()
|> filter(|n| n.name |> starts_with("memory"))
|> flat_map(|n| {
let reg = n.property("reg").as_u64_pairs()
reg |> map(|(base, size)| MemoryRegion { base, size })
})
|> filter(|r| r.size > 0)
}
// On QEMU virt with 128MB:
// [MemoryRegion { base: 0x80000000, size: 0x08000000 }]
The page allocator
With memory detected, we set up a simple page allocator. FRISC uses 4 KB pages (the RISC-V Sv39 default):
const PAGE_SIZE: u64 = 4096
struct PageAllocator {
bitmap: Vec<u8>, // 1 bit per page: 0=free, 1=used
base: u64,
total_pages: u64,
}
fn alloc_page(alloc: &mut PageAllocator) -> Option<u64> {
for i in 0..alloc.total_pages {
let byte = i / 8
let bit = i % 8
if alloc.bitmap[byte] & (1 << bit) == 0 {
alloc.bitmap[byte] |= 1 << bit
return Some(alloc.base + i * PAGE_SIZE)
}
}
None // Out of memory
}
This linear scan is O(n) and slow for large memory. In the virtual memory post, we upgrade to a buddy allocator that's O(log n). But for first boot, simplicity wins.
Trap handling
RISC-V uses a single trap vector for all exceptions and interrupts. We set it up early so crashes produce useful information instead of silent hangs:
fn trap_handler(cause: u64, epc: u64, tval: u64) {
match cause {
2 => panic("Illegal instruction at {epc:#x}: {tval:#x}"),
5 => panic("Load access fault at {epc:#x}, addr: {tval:#x}"),
7 => panic("Store access fault at {epc:#x}, addr: {tval:#x}"),
8 | 9 | 11 => syscall_handler(cause, epc),
_ if cause & (1 << 63) != 0 => interrupt_handler(cause),
_ => panic("Unknown trap: cause={cause}, epc={epc:#x}"),
}
}
The highest bit of the cause register distinguishes interrupts (bit set) from exceptions (bit clear). This single match expression handles both, routing to the appropriate handler.