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:
- Demand paging — pages are allocated lazily. A read to an unallocated page triggers a fault, the handler allocates a physical page and maps it, then resumes the instruction.
- Copy-on-write — when a process forks, both parent and child share the same physical pages (marked read-only). A write triggers a fault, the handler copies the page, makes both copies writable, and resumes.
- Stack growth — the initial stack is small. If the program touches below the mapped region, the fault handler extends the stack mapping downward.