April 2025 · 8 min read

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.

Lateralus is built by bad-antics. Follow development on GitHub or try the playground.