September 2025 · 8 min read

Sv39 Paging on RISC-V

Virtual memory turns a flat physical address space into isolated per-process worlds. Here's how FRISC OS implements RISC-V's Sv39 paging scheme.

Sv39 overview

Sv39 uses three-level page tables with 39-bit virtual addresses, giving each process 512 GB of virtual address space. Each page is 4 KB. The satp CSR holds the root page table pointer and the ASID (Address Space Identifier).

Page table structure

struct PageTableEntry {
    flags: u64,  // V, R, W, X, U, G, A, D bits
}

impl PageTableEntry {
    fn is_valid(self) -> Bool { self.flags & 0x1 != 0 }
    fn is_leaf(self) -> Bool { self.flags & 0xE != 0 }
    fn ppn(self) -> u64 { (self.flags >> 10) & 0xFFFFFFFFFFF }
}

Mapping pages

We walk the three-level table, allocating intermediate tables as needed:

fn map_page(root: *PageTable, vaddr: UInt, paddr: UInt, flags: UInt) {
    let vpn = [
        (vaddr >> 30) & 0x1FF,  // Level 2
        (vaddr >> 21) & 0x1FF,  // Level 1
        (vaddr >> 12) & 0x1FF,  // Level 0
    ]
    // Walk and allocate...
}

TLB management

RISC-V requires explicit TLB flushes via the sfence.vma instruction. We flush per-page on map/unmap and do a full flush on context switch (or use ASIDs to avoid it).

User-mode isolation

With paging enabled, each process gets its own page table. Kernel memory is mapped in the upper half (shared across all tables). User pages have the U bit set. A user process cannot access kernel memory — the hardware traps any attempt.

Sv39 page table structure

RISC-V Sv39 uses a three-level page table with 9 bits per level, mapping 39-bit virtual addresses to physical addresses:

// Virtual address breakdown (39 bits):
// [38:30] VPN[2] — Level 2 index (512 entries)
// [29:21] VPN[1] — Level 1 index (512 entries)
// [20:12] VPN[0] — Level 0 index (512 entries)
// [11:0]  Offset — 4 KB page offset

struct PageTableEntry {
    ppn: u64,     // Physical Page Number (bits 53:10)
    flags: u8,    // VRWXUGAD bits
}

// V=Valid, R=Read, W=Write, X=Execute
// U=User-accessible, G=Global, A=Accessed, D=Dirty
const PTE_V: u8 = 1 << 0;
const PTE_R: u8 = 1 << 1;
const PTE_W: u8 = 1 << 2;
const PTE_X: u8 = 1 << 3;
const PTE_U: u8 = 1 << 4;

Each page table is exactly one 4 KB page containing 512 8-byte entries. Three levels means three memory accesses per translation (the TLB caches frequent translations).

Kernel vs user space mapping

FRISC uses a simple split: the upper half of the virtual address space is kernel, the lower half is user:

// Memory layout:
// 0x0000_0000_0000_0000 — 0x0000_003F_FFFF_FFFF : User space (256 GB)
// 0xFFFF_FFC0_0000_0000 — 0xFFFF_FFFF_FFFF_FFFF : Kernel space (256 GB)

fn create_user_page_table(program: &ElfBinary) -> PageTable {
    let pt = PageTable::new()
    
    // Map kernel into upper half (shared across all processes)
    pt.map_range(
        virt: KERNEL_BASE, 
        phys: 0x8000_0000,
        size: kernel_size,
        flags: PTE_R | PTE_W | PTE_X  // No PTE_U — kernel only
    )
    
    // Map user program into lower half
    for section in program.sections() {
        pt.map_range(
            virt: section.vaddr,
            phys: alloc_pages(section.size),
            size: section.size,
            flags: PTE_R | PTE_U | section.elf_flags_to_pte()
        )
    }
    
    // Map user stack (grows downward from 0x3F_FFFF_F000)
    pt.map_range(
        virt: USER_STACK_TOP - STACK_SIZE,
        phys: alloc_pages(STACK_SIZE),
        size: STACK_SIZE,
        flags: PTE_R | PTE_W | PTE_U
    )
    
    pt
}

The buddy allocator

The first-boot page allocator was a linear bitmap scan. For virtual memory, we need something faster. FRISC uses a buddy allocator:

struct BuddyAllocator {
    // Free lists for each order (0 = 4KB, 1 = 8KB, ... 9 = 2MB)
    free_lists: [LinkedList; 10],
}

fn alloc_pages(alloc: &mut BuddyAllocator, count: u64) -> u64 {
    let order = log2_ceil(count)
    
    // Find the smallest available block
    for o in order..10 {
        if let Some(block) = alloc.free_lists[o].pop() {
            // Split larger blocks down to the requested size
            while o > order {
                o -= 1
                let buddy = block + (1 << o) * PAGE_SIZE
                alloc.free_lists[o].push(buddy)
            }
            return block
        }
    }
    panic("Out of physical memory")
}

Buddy allocation is O(log n) for both allocation and deallocation. When a block is freed, we check if its buddy is also free and merge them back into a larger block. This prevents fragmentation over time.

Page faults

With virtual memory comes page faults. FRISC handles three types:

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