Physical Memory Protection
Physical Memory Protection (PMP) is the optional RISC-V hardware unit that lets machine-mode (M-mode) software fence off ranges of physical memory with read, write, and execute permissions, with no address translation involved. Up to 64 PMP entries, each a comparator plus an 8-bit configuration byte, are checked in parallel with the Physical Memory Attribute (PMA) checks on every load, store, and instruction fetch performed from S-mode or U-mode (per the privileged spec, section “Physical Memory Protection”). The unit is cheap (a few hundred gates per entry), trap-precise, and exactly the right primitive for a small microkernel or a separation-kernel firmware: it isolates processes from each other on a CPU that has no Memory Management Unit (MMU) at all, and it isolates M-mode firmware from a Linux kernel on a CPU that does have one. This note walks the CSR layout, the four address-matching modes, the priority and lock semantics, the Smepmp extension, the relationship to paging, and a worked microkernel example. It closes with a comparison to the ARM Cortex-M Memory Protection Unit (MPU), which solves the same problem with a different encoding.
PMP belongs to the memory protection and virtual memory family of mechanisms. Where an MMU translates virtual addresses through page tables, PMP filters already-resolved physical addresses; it adds permission checking without adding indirection. For the definitely-not-esp32 v1.0 microkernel, PMP is the sole isolation primitive; the design is sized so each user process maps to a small bank of PMP entries that M-mode reprograms on context switch.
Mental Model
Think of PMP as a small content-addressable bank of “physical-address tripwires.” Each entry holds an address pattern plus three permission bits. On every memory access from a less-privileged mode, the hardware presents the physical address to all enabled entries simultaneously; the lowest-numbered entry that matches all bytes of the access decides whether the access is permitted, denied, or trapped. The entries with lower indices win; entries with no match at all fall through.
flowchart LR CPU["Load / store / fetch<br/>(S or U mode)"] -->|"physical address"| CHK{"PMP check<br/>(parallel, all entries)"} CHK -->|"lowest matching idx<br/>has R/W/X for op"| OK["Access proceeds"] CHK -->|"lowest matching idx<br/>denies, or all match-fail in S/U"| FAULT["Access-fault exception<br/>raises to M-mode"] subgraph BANK["64 PMP entries (per hart)"] E0["pmp0cfg + pmpaddr0"] E1["pmp1cfg + pmpaddr1"] E2["pmp2cfg + pmpaddr2"] ED["..."] E63["pmp63cfg + pmpaddr63"] end CHK -.->|"first-match priority"| BANK
The PMP decision path. What it shows: every memory access from S/U mode is compared in parallel against all enabled PMP entries; the lowest-indexed match supplies the R/W/X verdict. The insight to take: PMP is a tiny fully-associative permission lookup that runs alongside, not in series with, address translation. It costs nothing on hit and traps precisely on miss.
The cost model matters. PMP adds one parallel compare per entry per access. With 16 entries, that is 16 comparators in the load-store path; with 64 entries, 64 comparators. On a small in-order RV32 core this is a measurable area cost but not a critical-path cost: the checks finish inside the existing memory-access cycle. This is why PMP scales down to microcontrollers in a way that a full MMU does not.
CSR Layout
Each PMP entry is described by two CSRs: an 8-bit configuration byte and an address register that holds bits [33:2] of a 34-bit physical address on RV32, or bits [55:2] of a 56-bit physical address on RV64 (per machine.adoc, section “Physical Memory Protection CSRs”). The bottom two bits of any PMP address are implicit zero, fixing the granularity at four bytes minimum.
The configuration bytes are packed for efficiency. On RV32, sixteen CSRs pmpcfg0 through pmpcfg15 hold the configurations for the 64 entries, four bytes per CSR. On RV64, eight even-numbered CSRs (pmpcfg0, pmpcfg2, …, pmpcfg14) hold the same data, eight bytes per CSR; the odd-numbered configuration CSRs are illegal on RV64. The address registers are flat: pmpaddr0 through pmpaddr63, MXLEN bits wide.
Each 8-bit pmpcfg byte is laid out (per the spec’s bit-field figure, pmpcfg.edn):
bit: 7 6 5 4 3 2 1 0
+---+---+---+---+---+---+---+---+
| L | 0 0 | A1 A0| X | W | R |
+---+---+---+---+---+---+---+---+
- R, W, X (bits 2, 1, 0 are R, W, X). When set, the entry grants read, write, or execute permission to the matching range. The combination
R=0, W=1is reserved (per the WARL rules in machine.adoc). - A (bits 4:3). The address-matching mode:
00=OFF,01=TOR,10=NA4,11=NAPOT. - L (bit 7). The lock bit; once set, both
pmpcfgandpmpaddrfor that entry become read-only until the hart resets, and the R/W/X permissions then also apply to M-mode (more in the lock section below). - Bits 5 and 6 are reserved-as-zero.
An implementation may implement zero, sixteen, or sixty-four PMP entries; the lowest-numbered are filled first. All PMP CSR fields are Write-Any-Read-Legal (WARL), which means software discovers what is actually implemented by writing a probe value and reading it back. OpenSBI’s sbi_hart.c uses exactly this trick at boot: writing PMP_ADDR_MASK to pmpaddr0, then reading it back, reveals both the count of implemented entries and the platform’s effective granularity.
The Four Matching Modes
The A field selects how pmpaddr[i], and sometimes pmpaddr[i-1], define the byte range that entry i covers.
A=0, OFF. The entry is disabled and matches nothing. This is the reset state.
A=1, TOR (Top Of Range). The current entry’s pmpaddr register is the exclusive upper bound of the range; the previous entry’s pmpaddr[i-1] is the inclusive lower bound. The spec states it directly: “If PMP entry i’s A field is set to TOR, the entry matches any address y such that pmpaddr[i-1] <= y < pmpaddr[i]” (per machine.adoc). Entry 0 in TOR mode takes zero as its lower bound. TOR is the only PMP mode that can describe an arbitrary-size region; it costs two consecutive entries to express one region.
A=2, NA4 (Naturally Aligned 4-byte). A special case of NAPOT: the address register names exactly a 4-byte word. NA4 only exists when the platform granularity G equals one (4 bytes); larger grain platforms drop NA4 from the encoding.
A=3, NAPOT (Naturally Aligned Power-of-Two). This is the clever one. The size of the region is encoded in the trailing-ones pattern of the address register. The spec gives the table directly (per machine.adoc, table “NAPOT range encoding”):
pmpaddr low bits | meaning |
|---|---|
...yyyy yyy0 | 8-byte NAPOT |
...yyyy yy01 | 16-byte NAPOT |
...yyyy y011 | 32-byte NAPOT |
| … | … |
0111 1111 | 2^9-byte NAPOT |
1111 1111 | 2^10-byte NAPOT |
The leading y bits are the base address (shifted right by 2 because pmpaddr encodes physical-address bits [33:2]); the trailing-ones pattern says “this region is naturally aligned and is 2^(k+3) bytes wide for k trailing ones.” NAPOT lets a single entry describe a power-of-two region of any size from 8 bytes to the full address space. This is why a microkernel can fit four user processes into four PMP entries, one NAPOT region each, instead of paying eight TOR entries.
The granularity G is the same across all entries on a given platform. It is 2^(G+2) bytes; an implementation that uses 4 KiB pages will typically set G such that the grain is 4 KiB or finer. Software discovers G by writing 0 to pmp0cfg, writing all-ones to pmpaddr0, and noting the lowest bit that reads back as one; that bit position is G. OpenSBI does exactly this in hart_pmp_get_allowed_addr() (see sbi_hart.c).
Priority, Default-Deny, and Lock
PMP’s matching rule is lowest-numbered match wins, irrespective of permission outcome (per machine.adoc, section “Priority and Matching Logic”). If entry 5 grants read/write to a region that entry 0 has disabled, an S-mode access to that region fails: entry 0 matches first and supplies its (none) verdict. This priority discipline gives M-mode an unambiguous override pattern: lock down a critical region with a low-numbered entry, then layer per-process regions on top.
The matching is also all-or-nothing: “The matching PMP entry must match all bytes of a memory operation, or the operation fails” (per machine.adoc). A four-byte store that straddles two NAPOT regions, even if both grant write, fails because no single entry covers all four bytes. The spec gives the canonical example: an entry covering 0xC..0xF cannot satisfy an 8-byte access to 0x8..0xF.
The default behavior splits cleanly by privilege:
- No PMP entry matches an M-mode access. The access succeeds. M-mode has full access by default; PMP only revokes permissions from M-mode (and only when entries are locked, see below).
- No PMP entry matches an S- or U-mode access, but at least one entry is implemented. The access fails (per machine.adoc). Default deny.
The asymmetry is deliberate: in S/U mode you must explicitly grant access; in M-mode you must explicitly deny it. The L (lock) bit is the bridge. Setting L on an entry locks the entry against further writes until the hart is reset, and it additionally makes the R/W/X permissions enforced even on M-mode accesses to that range. Without L, a matching entry’s permissions only affect S/U; M-mode passes through.
This is how M-mode firmware protects itself. OpenSBI (per docs/domain_support.md) sets up at boot a locked PMP entry covering its own code and data with R/W/X all clear, which traps any S/U attempt to touch firmware memory; the lock means even a compromised M-mode driver running later cannot easily reach in either. Once locked, that entry stays until reset.
A PMP violation always traps precisely at the offending instruction (per machine.adoc, “PMP violations are always trapped precisely at the processor”). The resulting exception is instruction access-fault (cause 1), load access-fault (cause 5), or store access-fault (cause 7), distinguishing PMP violations from MMU page-faults (causes 12, 13, 15), so the M-mode trap handler can tell them apart.
Mechanical Walk-through: A Microkernel Context Switch
Imagine a small RV32IMC microkernel with two user processes, P0 and P1, each given a 64 KiB chunk of RAM. Process P0 occupies 0x8001_0000..0x8001_FFFF; process P1 occupies 0x8002_0000..0x8002_FFFF. The kernel sits at 0x8000_0000..0x8000_FFFF. The kernel reserves three PMP entries:
- Entry 0, locked, NAPOT covering
0x8000_0000..0x8000_FFFF, no R/W/X. Protects kernel image from any user access; locked so it survives malicious or buggy kernel code. - Entry 1, unlocked, NAPOT covering one user process’s RAM, R/W (no X, since the kernel may make code pages a separate entry).
- Entry 2 reserved for the second NAPOT user code region.
The NAPOT encoding for a 64 KiB region at 0x8001_0000 is constructed as follows. pmpaddr encodes physical bits [33:2], so the base is right-shifted by 2, giving 0x2000_4000. A 64 KiB region is 2^16 bytes, so the NAPOT tag wants (k+3)=16, i.e. 13 trailing ones below the size bit. The bits below the size bit must all be ones, and the size bit (the next bit up) must be zero. Concretely the value is 0x2000_4000 | ((0x10000 >> 3) - 1) = 0x2000_5FFF. Writing this to pmpaddr1, and writing 0b00011111 (L=0, A=NAPOT, X=0, W=1, R=1) to the first byte of pmpcfg0, enables read/write for entry 1.
On context switch from P0 to P1, the kernel:
- Writes the new NAPOT
pmpaddr1for P1’s RAM. - (Optionally) updates the per-process
pmpcfgbyte if P1’s RAM has different permissions. - Executes
sfence.vma x0, x0to synchronize the PMP cache, since “when the PMP settings are modified, M-mode software must synchronize the PMP settings with the virtual memory system and any PMP or address-translation caches” (per machine.adoc, “Physical Memory Protection and Paging”). - Executes
mretto return to U-mode at P1’s saved PC.
The full switch is a handful of CSR writes plus a fence. There is no page-table walk, no TLB miss, no shootdown across cores (PMP is per-hart and there is only one in this design). This is the whole appeal of PMP for a small system: a cheap, deterministic isolation primitive that supports the operations a microkernel actually performs.
The Smepmp Extension
The base PMP design has a known weakness in the “M-mode is its own OS” use case. On a hart with no S-mode, the OS runs in M-mode and U-mode runs the applications. Without Smepmp, every memory region that is not covered by a locked PMP entry is fully accessible to M-mode, including any RAM containing user code, so a successful confused-deputy attack on the M-mode OS can be coerced into executing user-controlled code or reading user data through a high-privilege channel. The Smepmp specification document spells out this threat directly: “without the Smepmp extension, it is not possible for a PMP rule to be enforced only on non-Machine modes and denied on Machine mode” (per smepmp.adoc).
Smepmp (“ePMP”) adds the mseccfg CSR with three bits, MML, MMWP, RLB:
- MML (Machine Mode Lockdown), when set, re-interprets every PMP entry’s L/R/W/X combination according to a new truth table (reproduced in smepmp.adoc) where unlocked rules become S/U-only and locked rules carry stricter M-mode semantics. The encoding
R=0, W=1(reserved in base PMP) becomes a “shared region” indicator;L=1, R=1, W=1, X=1becomes a locked shared-data region readable by all modes. The effect is to give M-mode software a deny mode for U-mode memory. - MMWP (Machine Mode Whitelist Policy) turns the M-mode default from “allow if no entry matches” to “deny if no entry matches.” Once set, M-mode software must explicitly cover every region it intends to touch.
- RLB (Rule Locking Bypass) lets early M-mode firmware temporarily ignore the L bit so it can rewrite locked rules during boot, then clear RLB to make the lockdown final.
MML and MMWP are sticky-set: once you write a 1, the bit is locked at 1 until reset. RLB is sticky-clear: once you write a 0, it stays at 0. This asymmetry is the discovery mechanism. As the spec notes, “BootROM will set mseccfg.MMWP and/or mseccfg.MML during early boot … so that the firmware will be able to determine the presence of Smepmp by reading mseccfg” (per smepmp.adoc). OpenSBI uses Smepmp where available; its sbi_hart_smepmp_configure() in sbi_hart_pmp.c makes two passes over domain regions, programming M-only regions first under mseccfg.RLB, then shared and S/U-only regions after setting mseccfg.MML.
For the definitely-not-esp32 v1.0 microkernel, Smepmp is desirable but not in scope; the simpler base PMP suffices for two user processes plus a kernel.
PMP and Paging
PMP composes with virtual memory (Sv32 on RV32, Sv57 on RV64). When paging is enabled, the page-table walk itself generates implicit memory accesses to fetch PTEs; PMP applies to each of those implicit accesses, with effective privilege “S.” This is critical for correctness: it means M-mode firmware can deny S-mode access to the firmware’s own data even when the OS attempts to use it as a page-table.
The page-walker is also permitted to translate speculatively and to cache PMP outcomes alongside translations, which is why modifying any PMP CSR requires an sfence.vma x0, x0 to invalidate caches (per machine.adoc, “PMP settings … must be synchronized … by executing an SFENCE.VMA instruction with rs1=x0 and rs2=x0, after the PMP CSRs are written”). On a hart with no paging, PMP checks are synchronous; the fence is unnecessary, since there is no translation cache to invalidate.
A subtle interaction: misaligned accesses may be decomposed into multiple memory operations, and each operation is PMP-checked independently. A misaligned store that straddles a permitted region and a denied region can leave a partial result visible before the access-fault exception is taken (per machine.adoc). The implication for software is that exception handlers cannot assume the store had no effect at all; recovery must be designed to tolerate partial visibility.
Configuration Example
A worked snippet showing how OpenSBI writes a NAPOT entry, distilled from the inline assembly in sbi_hart_pmp.c:
/* set a PMP NAPOT region covering [base, base+(1<<order))
with permissions pmp_flags (R/W/X bits, plus A=NAPOT, L) */
static int pmp_set(unsigned int n, unsigned long pmp_flags,
unsigned long base, unsigned long order)
{
unsigned long addr;
/* base must be aligned to 2^order; order >= log2(grain) */
if (base & ((1UL << order) - 1))
return -1;
/* NAPOT encoding: addr = (base >> 2) | ((1 << (order-3)) - 1)
The (-3) accounts for the (-2) shift built into pmpaddr
(low two physical-address bits implicit zero) plus the
trailing-ones convention starting at the third-lowest bit. */
addr = (base >> PMP_SHIFT) | ((1UL << (order - PMP_SHIFT - 1)) - 1);
/* Write address first, then config; A=NAPOT in pmp_flags */
csr_write_num(CSR_PMPADDR0 + n, addr);
pmp_csr_set_bits(n, pmp_flags << ((n & 3) * 8));
/* synchronize PMP/TLB caches */
asm volatile("sfence.vma x0, x0");
return 0;
}The shift arithmetic is the one error-prone piece. PMP_SHIFT = 2 (the implicit low-bit drop). order is log2(region_size); for a 64 KiB region, order = 16, so (1UL << (16-2-1)) - 1 = 0x1FFF, the trailing-ones pattern that says “16-byte-shifted base, 64 KiB span.” Get this wrong and the region either covers half what you intended or covers far more.
Failure Modes
Forgetting the fence. A modified PMP entry that takes effect “later” usually means the page-walker cached the old PMP result for an in-flight translation, and the new entry is not yet enforced. The fix is the unconditional sfence.vma x0, x0 after any PMP CSR write on a hart with paging enabled.
Trying to grant W without R. R=0, W=1 is reserved (per machine.adoc). The WARL behavior is implementation-defined; some implementations clear W on the write, some leave the entry effectively disabled.
Region not naturally aligned for NAPOT. A NAPOT region’s base address must be a multiple of its size. Writing a misaligned base will read back with the low bits masked off; the resulting region is not what was intended. Always assert (base & (size - 1)) == 0 before encoding.
Locking out the bootloader. Setting L on an entry that excludes the M-mode code region itself is a permanent foot-gun: until reset, M-mode cannot fetch from its own code. The lock cannot be cleared; only a hart reset clears it. Smepmp’s RLB bit exists partly to mitigate this during early boot.
Counting PMP entries wrong. The number of implemented PMP entries is platform-specific (0, 16, or 64). Software that hard-codes “use entries 7 through 15” works on the SiFive U54’s 8-entry PMP unit (SiFive U54 manual documents “8 regions and a minimum granularity of 4 bytes”) only by accident: entries 7 through 15 do not exist there. Always discover the count by probing.
Alternatives and When to Choose Them
Full MMU with page tables. A MMU gives per-process virtual address spaces, demand paging, copy-on-write, file-backed mmap, swap. The cost is large: a multi-level page table per process, a TLB, TLB-flush logic, page-fault handling in software, area for the walker. For a microkernel without virtual memory ambitions, PMP gets you the isolation half of that without the rest. For a Unix-style kernel, an MMU is unavoidable.
No isolation (everything in one address space). Some bare-metal RTOS systems run all threads with full access to all memory. This is fine for trusted firmware and small, single-purpose devices; it is not safe for running mutually untrusted user code. PMP is the smallest step up from “no isolation” that preserves the ability to run untrusted user processes.
IOPMP and SiFive Shield. PMP only governs accesses by the CPU itself. DMA-capable devices bypass it. To extend the same per-region permission model to bus masters, RISC-V defines IOPMP, and SiFive ships a proprietary equivalent (Shield). These are out of scope for a single-core hobby SoC but matter for any real product. OpenSBI mentions them in docs/domain_support.md as alternative or supplementary isolation primitives.
ARM Cortex-M MPU (PMSAv7 / PMSAv8). The closest cousin in the ARM ecosystem. Architecturally similar: a small bank of region descriptors checking physical (or in Cortex-M, the single-address-space) accesses. The differences are illuminating.
| Feature | RISC-V PMP | ARM Cortex-M MPU (PMSAv7) |
|---|---|---|
| Region count | 0, 16, or 64 (implementation choice) | typically 8, sometimes 16 (per Cortex-M user guides) |
| Region encoding | NAPOT, NA4, TOR, OFF | Base + size, naturally aligned power-of-two |
| Granularity (min) | 4 bytes | 32 bytes (per Cortex-M user guides) |
| Overlap priority | Lowest index wins | Highest index wins |
| Privilege protection | L bit promotes rule to M-mode | Privileged/unprivileged AP encoding |
| Address space | Physical | Physical (no virtual addressing in M-profile) |
The priority polarities are inverted. On ARM, you put the most specific region last so it overrides the broader background regions; on RISC-V, you put the most specific region first. This is a frequent source of porting bugs.
PMSAv8 (Cortex-M23, M33, M55) revises the design: regions are encoded as base + limit (not base + size), the alignment constraint is dropped, and an indirection register (MAIR) decouples cacheability attributes from the access-permission encoding. This is closer in spirit to RISC-V PMP’s TOR mode than PMSAv7’s strict NAPOT analog.
Uncertain
Verify: the exact PMSAv7/PMSAv8 register-level bit layouts (RBAR, RASR, MAIR field positions) given in the comparison table above. Reason: every fetch of
developer.arm.comreturned only a generic page title; the comparison details come from secondary sources (Feabhas blog, the Wadix Medium articles, Wikipedia’s MPU page) rather than the Armv7-M or Armv8-M Architecture Reference Manual itself. To resolve: consult ARM DDI 0403 (Armv7-M ARM) and ARM DDI 0553 (Armv8-M ARM) directly.
Production Notes
OpenSBI as the reference. Every shipping RISC-V Linux system goes through OpenSBI, which programs PMP at boot to fence the firmware from S-mode. The docs/domain_support.md document is the canonical guide to the “domain” abstraction: each domain is a set of memory regions plus a set of harts, and the firmware reprograms PMP whenever a hart migrates between domains.
ESP32-C3. Espressif’s RISC-V microcontroller (the reference point for definitely-not-esp32) uses PMP plus a vendor-specific PMA (called “World Controller”) to support a dual-world model for secure boot. The PMP entry count on the C3 is implementation-defined but documented in the ESP32-C3 Technical Reference Manual.
SiFive U54-MC (HiFive Unleashed). The U54-MC’s PMP unit has 8 regions per hart and 4-byte granularity (SiFive U54-MC manual). Linux on this board relies on OpenSBI to set up an outer fence for firmware; everything else runs inside paged S-mode.
definitely-not-esp32 v1.0. The plan is 16 PMP entries, NAPOT-encoded, with one locked low-numbered entry for the kernel image, then up to eight per-process regions reprogrammed on context switch. Smepmp is not implemented in v1.0; revisiting it is a Phase 6 stretch.