Memory Management Unit
A Memory Management Unit (MMU) is the hardware that translates the virtual addresses a CPU emits into the physical addresses that go out on the bus. On every load, store, and instruction fetch, the MMU consults a per-process tree of page tables, checks the access against per-page permissions, and produces a translated address or a fault. The MMU is what lets a modern operating system give every process its own private 32-bit or 64-bit address space, share read-only pages between processes (copy-on-write), back virtual memory by files (mmap) or swap, and detect bad pointers as page faults instead of silent corruption. RISC-V defines the MMU contract in the privileged spec (supervisor.adoc, “Supervisor Address Translation and Protection”) with four paging modes selectable through the
satpCSR:Bare(no translation),Sv32(32-bit),Sv39,Sv48, andSv57(64-bit, increasing virtual-address widths). This note explains what an MMU is, what services it enables for the OS, how a page-table walk works mechanically, the role of the TLB as a translation cache, the page-fault path, and the difference between an MMU and an MPU. The RISC-V variants are introduced at the end; each gets its own dedicated note.
The MMU sits in the memory protection and virtual memory section of the architecture map. In the definitely-not-esp32 project, an MMU is a stretch goal for Phase 7; v1.0 ships PMP only. The two mechanisms solve overlapping problems with very different costs, and this note is partly motivation for choosing the cheaper one first.
Mental Model
A CPU without an MMU emits the address the program computed. The bus sees that address; RAM, ROM, and devices see it. Every program shares one flat address space. A wild pointer in process A can clobber process B.
A CPU with an MMU emits a virtual address. The MMU intercepts it, looks up a translation in a per-process table, and emits the physical address. Each process gets its own table; each process therefore gets its own private interpretation of the address space. The same virtual address 0x4000_1000 in process A and process B can resolve to different physical pages, or to the same physical page (sharing), or to no physical page at all (a page fault).
flowchart LR P0["Process A<br/>load 0x40001000"] -->|"virtual"| MMU P1["Process B<br/>load 0x40001000"] -->|"virtual"| MMU MMU{"MMU<br/>walks per-process<br/>page table"} MMU -->|"hit"| TLB[("TLB<br/>cache of recent<br/>translations")] TLB -->|"phys"| BUS["Bus"] MMU -->|"miss"| WALK["Page-table walk<br/>(implicit memory accesses<br/>through satp root)"] WALK --> TLB MMU -->|"unmapped or<br/>permission denied"| FAULT["Page fault trap"] FAULT --> KERN["Kernel handler<br/>(fix up, kill, COW, demand-page)"]
The MMU’s place in the memory-access path. What it shows: virtual addresses from each process are filtered through the MMU; cached translations live in the TLB, missed translations trigger a page-table walk, and unmapped or permission-denied accesses trap to the kernel. The insight to take: the MMU adds an indirection (and therefore a latency, and therefore a cache called the TLB) on the critical path of every memory access. Everything else, COW, mmap, swap, follows from this one indirection.
What an MMU Buys You
Every service a modern OS provides over the underlying hardware reduces, somewhere, to the MMU.
Per-process address spaces. The kernel switches MMU state (on RISC-V, by writing the root page-table pointer and address-space ID into satp) on context switch, and process B now sees its own mappings. The same virtual address means different things in different processes. Without this, every process is in the same address space and can read or write any other process’s memory.
Memory protection at page granularity. Each PTE carries permission bits: readable, writable, executable, user-accessible. The MMU checks these on every access and raises a fault on violation. A page can be R-only to prevent writes to constants; X-only to prevent self-modifying code; U-clear to keep user mode out of kernel pages.
mmap and file-backed memory. A program calls mmap() to make a file appear at a virtual range. The kernel sets up PTEs that are not yet present (the V bit clear); on first access, the page fault gives the kernel a chance to read the file page into RAM and install a valid PTE. The program sees memory; the OS does the file I/O lazily.
Demand paging. A process is allocated 10 MB of zero-initialized BSS. Without paging, the kernel must hand it 10 MB of RAM at startup. With paging, the kernel installs PTEs that point at a single physical zero page, all marked read-only. The process can read all 10 MB at no RAM cost; writes fault, get a fresh zero page, and continue.
Copy-on-write (COW). fork() is supposed to duplicate a process. Naively, the kernel would copy every page. With COW, the kernel instead installs the parent’s PTEs into the child’s page table, marking both copies read-only. A read works fine; a write faults, the kernel allocates a fresh physical page, copies the original contents, installs the new mapping, and resumes. Most forks are followed by exec(), which discards the child’s address space, so most of the would-be copying is avoided.
Swap. When RAM is scarce, the kernel can write a page out to disk and clear its PTE’s V bit (or repurpose it as a swap-file index, which is what Linux does with the reserved-for-software bits in the PTE). A subsequent access faults, and the kernel reads the page back in. The process is unaware.
Shared libraries. The same physical pages holding libc text can be mapped read-execute into many processes simultaneously, saving RAM and improving I-cache use. The MMU makes this transparent to the program.
Address-space layout randomization (ASLR). Because every process’s virtual address space is independent, the kernel can place stack, heap, mmap region, and binary at randomized virtual offsets; a memory-corruption exploit that relied on hard-coded addresses no longer works.
None of these are intrinsic to the MMU’s hardware; they are all software policies that the kernel enacts by manipulating page tables. The MMU is the substrate that makes them possible.
Page Tables as Multi-Level Tries
The naive idea would be a flat array of PTEs, one per virtual page. With 4 KiB pages and a 32-bit address space, this is 2^32 / 2^12 = 1 048 576 PTEs, four bytes each, four MiB per process. For a 39-bit virtual address space (the smallest 64-bit RISC-V mode), it is two GiB per process, which is absurd. The fix is to make the page table itself paged: organize PTEs as a tree, allocating only the subtrees that have non-empty mappings.
A multi-level page table is a trie keyed on slices of the virtual page number. For Sv32 on RISC-V (per supervisor.adoc, “Sv32: Page-Based 32-bit Virtual-Memory Systems”), a 32-bit virtual address is split into:
VPN[1]: bits 31..22 (10 bits)VPN[0]: bits 21..12 (10 bits)offset: bits 11..0 (12 bits)
Walking the table is interpreting these slices as nested array indices. The root page-table address is held in satp. The CPU reads the entry at root[VPN[1]]; if it is a pointer PTE (R=W=X=0), the CPU follows it to a second-level page table and reads secondtable[VPN[0]]. That entry, if valid, is the leaf PTE; it holds the physical page number, the permission bits, and the accessed/dirty flags. The leaf’s PPN, concatenated with the original 12-bit offset, is the physical address.
This is the structure for every RISC-V paging mode. Sv32 has two levels and 4 MiB megapages (a leaf at level 1). Sv39 has three levels (per supervisor.adoc, “Sv39: Page-Based 39-bit Virtual-Memory System”). Sv48 has four. Sv57 has five. The split width (9 bits per level on Sv39/48/57, 10 bits per level on Sv32) is chosen so each page table fits in one 4 KiB physical page.
Multi-level tables save space when address spaces are sparse, which they always are. A typical process has code low, heap above it, stack high, mmap regions scattered. Most of the 2^32 (or 2^48) virtual addresses are unmapped. The kernel only allocates a second-level (or deeper) page table for VPN slices that actually contain mappings; the rest of the root table stays invalid. The cost is paid at walk time: every memory access becomes 1 + (levels) memory accesses if the TLB misses.
Linux applies the same idea uniformly across architectures with its generic page-table abstraction: “Linux defines page tables as a hierarchy which is currently five levels in height. The architecture code for each supported architecture will then map this to the restrictions of the hardware.” Architectures that need fewer levels fold the unused ones at compile time. RISC-V’s arch/riscv/include/asm/pgtable-32.h configures Sv32 with PGDIR_SHIFT = 22 (matching the 10-bit VPN[1]); the higher levels (P4D, PUD, PMD) are folded away.
The Page-Table Walk
When the CPU presents a virtual address and the TLB does not already have the translation, the MMU performs an implicit walk through main memory. The RISC-V spec defines the walk as a precise algorithm (per supervisor.adoc, “Virtual Address Translation Process”); the Sv32 note reproduces it step by step. Conceptually, for an N-level tree:
- Start at the root page table whose physical base is
satp.PPN * PAGESIZE. - Index into the current table by the highest-order VPN slice. Load the PTE from
(table_base + slice * PTESIZE). PMP and PMA checks apply to this load; failure yields an access-fault. - Check the PTE’s V bit; if invalid, raise a page-fault. Check reserved-bit constraints; reserved bits set means page-fault. Check
R=0, W=1(always reserved); page-fault if so. - If the PTE is a leaf (R=1 or X=1), check page-level permissions against the access type (load/store/fetch). Mismatch yields a page-fault. Otherwise this is the translation.
- If the PTE is a pointer (R=W=X=0), descend: take its PPN as the next-level table base, take the next VPN slice, and loop to step 2. Running out of levels without finding a leaf is a page-fault.
- On success, concatenate the leaf PTE’s PPN with the original offset to produce the physical address. For superpages (a leaf at a non-bottom level), the page-offset is taken from the corresponding VPN slices of the virtual address, not from the PTE.
The walk is hardware-managed on RISC-V; the spec explicitly justifies this in supervisor.adoc: “We have architected page table layouts to support a hardware page-table walker. Software TLB refills are a performance bottleneck on high-performance systems.” (Software-managed alternatives, as on MIPS, raise a trap on every TLB miss and run the walker as a kernel routine.) Implementations may walk speculatively, may cache intermediate PTEs in any structure they choose, and may set the A and D bits as a side effect (per the Svadu extension; the Svade extension instead traps to software). The walker is the major source of memory traffic when the TLB is small or the workload is irregular.
The translation latency budget matters for design. A TLB hit completes in one cycle. An L1-cache-resident page-table walk over three levels (Sv39) takes three cache accesses, plus the original load: typically tens of cycles. An L2-resident or DRAM-resident walk balloons to hundreds. On a 39-bit address space with a hot 1 GiB working set, a 64-entry TLB covers 256 KiB of 4 KiB pages; everything else relies on the walker hitting the cache hierarchy.
Page Faults: Exceptions That the Kernel Handles
A page fault is a precise trap raised by the MMU when an access cannot be completed. Causes include:
- Invalid PTE: V=0 along the walk (or off the end of the table) means there is no mapping. Could be unmapped memory; could also be lazily-allocated memory the kernel is willing to materialize.
- Permission denied: PTE’s R/W/X bits do not satisfy the access type, or U bit conflicts with current privilege mode.
- Misaligned superpage: a non-leaf level produces a leaf, but the PTE’s PPN low bits are not aligned to the implied superpage size (per supervisor.adoc, Sv32 walk step 5).
- A/D bit set required, Svade in force: an access would set A or D; under Svade the hardware raises a page-fault instead.
RISC-V distinguishes faults by access type: instruction page fault (cause 12), load page fault (cause 13), store page fault (cause 15). These differ from PMP-driven access-faults (causes 1, 5, 7), letting the kernel’s trap handler dispatch on cause and know exactly what to do.
The kernel handler can:
- Materialize the page (demand paging, COW, swap-in) and resume the instruction with
sret. - Decide the access is illegal, deliver SIGSEGV to the process, and not resume.
- Recognize the access as part of a syscall pattern (some kernels rely on faults from copy-from-user) and re-dispatch.
Resuming the faulting instruction is the whole reason faults must be precise: when the handler returns, the CPU re-executes the load or store, the new PTE is now valid, the access succeeds. Imprecise faults would leave the CPU somewhere downstream and require the kernel to roll back state, which is intractable in general.
TLB: The Translation Cache
Every memory access cannot afford a multi-level walk. The Translation Lookaside Buffer (TLB) is the small, fast, fully-associative cache of recent virtual-to-physical translations that absorbs the cost. On a hit, the TLB returns the physical page number in one cycle. On a miss, the page-table walk runs (in hardware on RISC-V).
The TLB lives logically inside the MMU but typically physically beside the L1 caches. Sizes are tiny by cache standards: SiFive’s U54 core has 32-entry fully associative ITLB and DTLB (SiFive U54-MC manual); Intel Skylake has 64-entry L1 DTLB plus a unified 1536-entry L2 STLB (WikiChip: Intel Skylake). Sizing is dictated by the timing of the lookup, which must finish inside an L1-cache access window.
Two design points that interact heavily with the rest of the kernel:
- ASID (Address Space ID) tagging. Each TLB entry carries the ASID of the page table that produced it. On context switch, the kernel writes the new ASID into
satp; old entries do not have to be flushed because they are tagged for a different address space. Without ASIDs, every context switch costs a TLB flush. - TLB shootdown. A page table can be modified on one CPU while other CPUs have cached entries from it. RISC-V’s
sfence.vmainvalidates local TLB entries; an inter-processor interrupt is required to invalidate remote ones (per the Linux RISC-V tlbflush code). The cost scales with core count and is a real bottleneck in large systems.
The full TLB story lives in Translation Lookaside Buffer.
MMU Versus MPU
The MMU is often confused with an MPU (Memory Protection Unit). They are different mechanisms with overlapping goals.
| Feature | MMU | MPU (PMP or ARM Cortex-M MPU) |
|---|---|---|
| Translates addresses? | Yes (virtual to physical) | No |
| Per-process | Yes (each process its own table) | No (single physical address space) |
| Granularity | Page (typ. 4 KiB) | Region (typ. 4 B to many MiB) |
| Permission checking | Per page | Per region |
| Demand paging / mmap | Yes | No |
| Hardware cost | Page-table walker, TLB, fault path | Comparator bank, no walker, no TLB |
| Typical use | Unix, Windows, large RTOS | Microkernels, RTOSes, MCU isolation |
An MPU is cheaper and simpler. It does not let you give every process its own virtual address space; it does let you fence chunks of physical memory from other code. For a small microkernel that does not need virtual memory or demand paging, an MPU (or RISC-V’s PMP) is enough. For a Unix kernel, an MMU is unavoidable. RISC-V cores ship in both flavors: M-mode-only cores like the SiFive E31 have PMP and no MMU; cores like the SiFive U54 have both, with PMP enforcing M-mode firmware isolation and the MMU running the S-mode OS. The Wikipedia Memory protection unit article puts it succinctly: the MPU “allows the privileged software to define memory regions and assign memory access permissions … without virtual memory support.”
The RISC-V MMU Modes: Sv32, Sv39, Sv48, Sv57
RISC-V defines a family of paging modes selected by the MODE field of the satp CSR. The encoding (per supervisor.adoc, table “Encoding of satp MODE field”) is:
- SXLEN=32: MODE=0 means
Bare(no translation); MODE=1 meansSv32. That is the entire choice for RV32; there is only one paging mode. - SXLEN=64: MODE=0 means
Bare; MODE=8 meansSv39; MODE=9 meansSv48; MODE=10 meansSv57; MODE=11 is reserved for the futureSv64.
Each mode trades virtual-address width against page-table walk depth.
Sv32 is the only paged option on RV32 systems. Two-level page table, 10+10+12 bit address split, 4 KiB base pages, 4 MiB megapages. 32-bit virtual address space; 34-bit physical address space (the PPN in satp is 22 bits, encoding 22+12 = 34 bits of physical address). Linux supports Sv32 for 32-bit RISC-V kernels (Linux RISC-V vm-layout docs notes “32-bit: SV32 mode”). Full details in Sv32 Virtual Memory.
Sv39 is the smallest 64-bit mode and the most common in shipping hardware. Three-level page table, 9+9+9+12 bit address split, 4 KiB / 2 MiB / 1 GiB page sizes. 39-bit virtual address space (the upper 25 bits must sign-extend bit 38, or the access faults; per supervisor.adoc, “Sv39: Page-Based 39-bit Virtual-Memory System”). 56-bit physical address space. SiFive’s U54 implements Sv39 (SiFive U54-MC manual: “The MMU supports the Bare and Sv39 modes”). The Linux kernel’s standard RISC-V configuration uses Sv39.
Sv48 extends Sv39 with a fourth level, 48-bit virtual addresses (256 TiB user space, per Linux’s vm-layout doc), and 4 KiB / 2 MiB / 1 GiB / 512 GiB pages.
Sv57 is the largest standardized mode: five levels, 57-bit virtual addresses, mirroring x86-64’s 5-level paging. Useful for systems with very large physical memory or for memory-mapped storage.
A given core implements one or more of these; the spec mandates that writing an unsupported MODE has no effect. Linux’s RISC-V port boots in Sv48 if available and falls back to Sv39, with optional Sv57 support. RV32 Linux uses Sv32. The definitely-not-esp32 project’s stretch goal is Sv32 (the only RV32 option).
Configuration: satp and the Kernel’s View
On RISC-V, the kernel installs a page table by writing the root table’s physical page number, plus the address-space ID, plus the mode, into satp. The Sv32 layout is:
bit: 31 30..22 21..0
+---+-----------+----------------+
|M0 | ASID(9) | PPN(22) |
+---+-----------+----------------+
Bit 31 is MODE (1 bit on RV32: 0=Bare, 1=Sv32). Bits 30..22 are the ASID (up to 9 bits implemented). Bits 21..0 are the physical page number of the root page table, divided by 4 KiB (per supervisor.adoc, “Supervisor Address Translation and Protection (satp) Register”; diagram in rv32satp.edn).
Writing satp is how the kernel enters paging and how it switches address spaces on context switch. Crucially, the spec notes that writing satp does not automatically invalidate TLB entries; if the new address space’s page tables have been modified, or if an ASID is being reused, an sfence.vma must be executed (per supervisor.adoc). The full ASID and shootdown protocol lives in Translation Lookaside Buffer.
Resolved 2026-08-08
No RISC-V profile mandates Svadu. Every application profile to date mandates Svade, and RVA23 adds Svadu only as an optional expansion option. Verified by reading the profiles specification sources directly (
riscv/riscv-profiles,main, read 2026-08-08):
Profile Privileged base Svade (software trap) Svadu (hardware update) Source file RVA20S64 Ss1p11 (Priv 1.11) Mandatory not mentioned — extension did not yet exist src/profiles.adocRVA22S64 Ss1p12 (Priv 1.12) Mandatory not mentioned src/profiles.adocRVA23S64 Ss1p13 (Priv 1.13) Mandatory (carried over: “The following privileged extensions were also mandatory in RVA22S64 … Svade”) Optional (“The following are new privileged expansion options in RVA23S64 … Svadu Hardware A/D bit updates”) src/rva23-profile.adocThe RVA20/RVA22 document is
profiles.adocv1.1 (:revdate: April 2, 2023), marked “This document is in the Ratified state.” The RVA23 document is the one taggedrva23-rvb23-ratifiedin the same repository.Why “Svade mandatory and Svadu optional” is not a contradiction. They are not mutually exclusive schemes selected at design time; they are two behaviours arbitrated at run time by one bit. Per the privileged spec, “If the Svadu extension is implemented, the ADUE bit controls whether hardware updating of PTE A/D bits is enabled … When ADUE=0, the implementation behaves as though Svade were implemented for S-mode and G-stage address translation. If Svadu is not implemented, ADUE is read-only zero” (
src/priv/machine.adoc,menvcfg). So an RVA23 hart must always be able to fall back to trapping (that is the Svade mandate), and may additionally offer hardware updating behindmenvcfg.ADUE. Portable supervisor software must therefore still carry a page-fault handler that sets A and D by hand, and treat hardware updating as an optimisation it discovers, never as a guarantee. See Page Table Entry for the bit-level mechanics and the naming history.
Failure Modes
Stale TLB after page-table edit. The kernel updates a PTE; a subsequent access still uses the old translation because the TLB has a cached entry. The fix is sfence.vma after every PTE modification. On a multi-core system, the kernel must also send a TLB shootdown IPI to the cores that may have cached the old entry.
ASID aliasing. The kernel recycles an ASID for a new process without flushing the TLB entries from the previous user of that ASID. The new process sees stale translations. The fix is to flush the ASID’s entries (sfence.vma x0, asid) when reassigning.
Page-table memory placed in non-cacheable or non-coherent memory. The walker may not observe page-table writes. RISC-V requires page tables to be in memory with hardware page-table write access and RsrvEventual PMA (per supervisor.adoc).
Forgetting U-bit semantics. A user-mode process attempting to access a kernel page (U=0) raises a page-fault. A kernel attempting to access a user page (U=1) also faults, unless sstatus.SUM=1 (Supervisor User Memory access). The SUM bit is the explicit “yes, kernel intends to touch user memory” flag.
Self-modifying code without fence.i. The MMU does not connect to the I-cache; modifying a page’s contents and then trying to execute from it requires fence.i to invalidate the I-cache, on top of any MMU work.
Production Notes
Linux on RISC-V. The kernel’s RISC-V port supports Sv32 (32-bit) and Sv39/Sv48/Sv57 (64-bit). The vm-layout.html document (docs.kernel.org/arch/riscv/vm-layout.html) describes the per-mode address-space split: kernel above, user below, with a large unmapped chasm enforced by the privileged spec’s “bits 63..48 must sign-extend bit 47” rule for Sv48. The TLB shootdown path in arch/riscv/mm/tlbflush.c chooses between SBI-based remote fences and IPI-based fences depending on platform.
CVA6 / Ariane MMU. The open-source CVA6 core (cva6 user manual MMU chapter) implements Sv32 (RV32 config), Sv39, and Sv48. It is one of the few publicly documented open MMU implementations that a hobby SoC can study; the walker is a small FSM, the TLB a fully-associative bank with ASID tags.
ESP32-C3. Espressif’s RISC-V microcontroller (the reference point for definitely-not-esp32) ships with no MMU at all. Isolation is PMP-only; user “processes” share one physical address space.
definitely-not-esp32 roadmap. v1.0 has no MMU; PMP is the sole protection mechanism, and there is no notion of virtual addresses. Phase 7 (stretch) adds Sv32; the kernel acquires a real page allocator and starts speaking process-private address spaces.