RISC-V Privilege Modes
The RISC-V Privileged Architecture defines three privilege levels: Machine (M), Supervisor (S), and User (U), in decreasing order of trust (RISC-V Privileged ISA, 2024-11-26 release). Every hart must implement M-mode (it is the reset state and the only mode that exists unconditionally); U and S are optional. The spec sanctions exactly three combinations: M only (tiniest embedded), M+U (typical microcontroller with isolation, what the ESP32-C3 and this project use), and M+S+U (the Unix-class profile). Privilege transitions happen only on traps (privilege goes up, mode is captured in a per-level CSR stack) and on the corresponding return instructions
mret/sret(privilege goes back down, the stack is popped). The optional Hypervisor (H) extension layers an orthogonal virtualization dimension on top: it introduces a Virtual Supervisor mode (VS) and two-stage address translation, but does not add a fourth privilege number.
This note covers the M/S/U privilege model itself: what each mode owns, how transitions work, why this project uses M+U, and how H slots in. The full trap-entry sequence (saving registers, looking at mcause, dispatching) is in RISC-V Trap Handling; the catalog of machine CSRs is in Control and Status Registers; the CSR-access instructions are in Zicsr Extension; physical-memory isolation in M+U systems uses Physical Memory Protection.
Mental Model
flowchart TB subgraph BOOT["At reset"] M0["Hart starts in M-mode"] end subgraph MODES["Privilege stack (during steady state)"] M["M-mode (level 3, encoding 11)<br/>owns reset, all CSRs, PMP, trap vectors"] S["S-mode (level 1, encoding 01)<br/>owns OS kernel, MMU (satp), page tables"] U["U-mode (level 0, encoding 00)<br/>owns user processes"] end M0 --> M M -- "trap delegation<br/>(medeleg, mideleg)" --> S M -- "mret (drop to MPP)" --> U S -- "sret (drop to SPP)" --> U U -- "ecall / interrupt / exception" --> S S -- "ecall / interrupt / exception" --> M U -- "ecall / interrupt / exception<br/>(if not delegated)" --> M
Privilege transitions in a fully populated M+S+U system. What it shows: the hart starts in M-mode at reset; M-mode firmware sets up CSRs and PMP, then drops to S-mode by writing mstatus.MPP=01 and executing mret. The S-mode kernel runs the OS, drops to U-mode by sret to run user processes, and re-enters S-mode on any ecall/exception/interrupt. M-mode delegates the common traps to S-mode via medeleg/mideleg so most events do not even round-trip through M-mode. The insight to take: privilege only goes up via traps and down via the matching xret instructions, and the per-level CSR stack (MPP/MPIE for M, SPP/SPIE for S) is how the destination level remembers where the source was.
What Each Mode Owns
Machine mode (M) is the privileged mode that always exists. It is the only mode that can execute mret, write the machine-level CSRs (mstatus, mtvec, mepc, mcause, mtval, mip, mie, mscratch, medeleg, mideleg, pmpcfg*, pmpaddr*, mhartid, mvendorid, etc., listed in Control and Status Registers), and write PMP regions. The trap vector at reset always points at M-mode (mtvec), so all interrupts and exceptions enter M-mode unless delegated. M-mode is also the only mode that can configure the PMP regions that constrain what every other mode (including itself, optionally) can access. On chips without S-mode, M-mode also acts as the kernel.
The mode is encoded as 11 in the two-bit privilege fields throughout the spec (RISC-V Privileged ISA). All hardware threads are required to support M-mode; “All RISC-V systems must implement M mode, but may optionally support S mode and U mode” (Daniel Mangum, RISC-V Bytes: Privilege Levels).
Supervisor mode (S) is the level where a Unix-class operating system kernel runs. It owns sstatus, stvec, sepc, scause, stval, sscratch, sip, sie, and (critically) satp, the supervisor address translation and protection register. satp is what activates the MMU: it holds the physical page-table base address and the mode (Bare, Sv32, Sv39, Sv48, Sv57); see Memory Management Unit and Sv32 Virtual Memory. Writing satp is restricted to S-mode. S-mode also has its own trap vector (stvec) and its own privilege stack (sstatus.SPP/SPIE), so it can handle delegated traps and dispatch to U-mode without round-tripping through M-mode.
S-mode is encoded as 01. “S mode is the level that owns satp / the MMU” is the load-bearing reason this level exists; without it, supervisor-level state would have to be threaded through M-mode and isolation between OS and applications would degrade to PMP-region juggling. The S-mode encoding leaves a 10 privilege code free; the spec keeps it reserved (briefly used to encode Hypervisor mode in older drafts before H was redesigned).
User mode (U) is where applications run. It has access to only the user-visible CSRs (the cycle/time/instret counters, the floating-point control word) and the unprivileged ISA. It is encoded as 00. U-mode has no trap vector of its own; any exception or unmasked interrupt in U-mode transitions either to S-mode (if S exists and the trap is delegated) or M-mode.
A standalone proposal called the N extension would have given U-mode its own trap-vector machinery (utvec, uepc, ucause, ustatus, uret) so user-mode interrupts could be handled without leaving user mode. The extension was specified at one point in the privileged manual but never reached ratification and was eventually removed from the spec; the design “likely needs to be redesigned” per the comment that accompanied the removal (GCC patches archive, Remove user-level interrupts). The withdrawal means uret is not a ratified instruction, and U-mode in 2026 has no privilege-stack of its own; traps from U always enter M (or delegated S) mode.
Uncertain
Verify: the exact removal version of the N extension and any current revival proposal. Reason: secondary sources agree the extension was withdrawn from the privileged manual but the date and current status are referenced only indirectly. To resolve: check the privileged ISA manual’s full change log and the RISC-V International ratified-extensions ledger. The substantive content (uret is not currently ratified; user-level interrupts not in the standard) is uncontested. uncertain
The Three Legal Combinations
The spec sanctions three implementation combinations (RISC-V Privileged ISA, 2024-11-26 release):
- M only. Every instruction runs at maximum privilege; there is no isolation between kernel and application code. Used for the simplest microcontrollers and for the M-mode firmware boot stage of larger chips. ESP-IDF on the ESP32-C3 originally ran like this; the M-only profile is still common.
- M + U. Two modes: privileged kernel in M and unprivileged applications in U. There is no separate supervisor; the kernel is M-mode. Isolation between kernel and applications is enforced by PMP (the kernel sets up PMP regions when dropping to U, restricting U’s access to a sandbox; see Physical Memory Protection). This is what the ESP32-C3 implements (per the Espressif TRM; see the cross-reference section below for provenance) and what the definitely-not-esp32 project’s v1.0 ships. The combination is sometimes called “relatively secure embedded systems” in the spec.
- M + S + U. The full three-level model. M-mode runs the SBI firmware (OpenSBI, Rust-SBI), S-mode runs the OS kernel (Linux, FreeBSD, Zephyr’s S-mode mode), U-mode runs applications. The MMU is active under S-mode’s control; PMP under M-mode’s control. This is the profile for any Linux-capable RISC-V chip.
Illegal combinations. S without U is forbidden: if S exists, U must exist (a supervisor with no user-mode would have no one to supervise). U without M is impossible (M is mandatory). U without S is fine. M-only is fine.
The encoding leaves room for nothing else; the privilege field is two bits and only three codes are used (00, 01, 11) with 10 reserved. The hypervisor extension does not add a fourth privilege number; instead it adds virtualization on top of S, making S behave like a virtualized layer (VS) with the H-mode being a modified HS-mode rather than a numerically separate level. See below.
Why ESP32-C3 and definitely-not-esp32 are M+U
The choice of M+U is forced by what the chip is for. The ESP32-C3 is a single-hart microcontroller running WiFi/BLE workloads. The application is a single C or Rust binary plus an RTOS; there is no concept of “a different OS kernel ported on top of the firmware”. With only one process at a time and a single kernel, an S-mode kernel layer would be redundant: M-mode firmware is the kernel. The cost of supporting S would be additional CSR state (sstatus/stvec/sepc/scause/…), a separate trap vector, and an MMU or at least the option of one. For a $1 SoC, that cost is unjustified.
PMP fills the role the MMU would otherwise fill: it lets M-mode declare “U-mode can only execute these bytes, only read those, only write to these others”, giving real isolation between the kernel and an untrusted-ish application. The ESP32-C3 implements 16 PMP regions (per the Espressif TRM; see provenance caveat below), which is enough to fence off kernel text, kernel data, kernel stack, the peripheral region, and a small handful of application regions.
The definitely-not-esp32 project follows the same logic: implement M+U with PMP in v1.0, leaving S-mode and a Sv32 MMU as Phase 6 stretch goals. The pedagogical advantage is that M+U is the simplest privilege model that still shows real isolation; it makes traps, syscalls, and PMP feel earned rather than hand-waved.
Mode Transitions: Trap Entry
A trap (exception or interrupt) is the only mechanism that raises privilege; software cannot voluntarily promote itself. On a trap into M-mode the hardware atomically does the following (RISC-V Privileged ISA):
- Save the privilege stack.
mstatus.MPP <- current privilege mode(so on return, the hart knows where to go back).mstatus.MPIE <- mstatus.MIE(saves the previous interrupt-enable state). - Disable interrupts at the new level.
mstatus.MIE <- 0. This ensures the trap handler runs with interrupts masked until it explicitly re-enables them. - Save the cause.
mcause <- (interrupt-or-exception flag) | cause-code. The high bit (mcause[XLEN-1]) is 1 for interrupts, 0 for exceptions; the low bits hold the standard cause code (instruction-address-misaligned = 0, illegal-instruction = 2, breakpoint = 3, load-misaligned = 4, store-misaligned = 6, environment-call-from-U = 8, environment-call-from-S = 9, environment-call-from-M = 11, instruction-page-fault = 12, load-page-fault = 13, store-page-fault = 15, etc.). The full table is in the privileged manual. - Save the faulting PC.
mepc <- PC of the trapping instruction(for exceptions) orPC of the next-to-execute instruction(for interrupts; the spec is precise here). - Save the trap value.
mtval <- faulting address(for memory faults),<- instruction bytes(for illegal instructions),<- 0(for environment calls, breakpoints). - Promote privilege. Current mode becomes M.
- Jump.
PC <- mtvec.BASE(direct mode) orPC <- mtvec.BASE + 4 * cause(vectored mode, for interrupts only).mtvec.MODE = 0is direct;mtvec.MODE = 1is vectored. ESP32-C3 supports only vectored mode (per the Espressif TRM; provenance caveat below).
The M-level CSRs are not pushed onto a stack in memory; they are simply overwritten. If a trap handler can itself trap (rare but possible during PMP-violation recovery), the handler must save the current mepc/mcause into its own scratch before doing anything that might trap.
Mode Transitions: Trap Return
The mret instruction reverses the trap entry (RISC-V Privileged ISA):
- Restore privilege. Current mode becomes whatever
mstatus.MPPsays (typically U-mode, sometimes M). - Restore interrupt enable.
mstatus.MIE <- mstatus.MPIE.mstatus.MPIE <- 1(re-armed for the next trap). - Set MPP to lowest implemented privilege.
mstatus.MPP <- (lowest implemented; U if U exists, else M). This is a defensive default so a forgotten configuration does not silently leave the system at M-mode. - Jump.
PC <- mepc.
sret does the analogous thing using sstatus.SPP/SPIE and sepc. Both mret and sret are themselves privileged: mret requires M-mode, sret requires S-mode (or M-mode, which can do anything S-mode can).
The instruction uret would have been the user-level analog, but as noted above the N extension was withdrawn; uret is not a ratified instruction in 2026.
Trap Delegation: Skipping the M-mode Round Trip
In an M+S+U system, the naive flow has every U-mode trap go to M-mode, which then forwards to S-mode. That extra hop is wasteful: nine out of ten times M-mode just wants to hand the trap straight to the kernel. The privileged spec adds two delegation CSRs that let M-mode pre-route specific traps directly to S-mode (RISC-V Privileged ISA):
medeleg(Machine Exception Delegation), bits indexed by exception cause. A1at bit N says “exception with cause N from S or U mode goes straight to S”.mideleg(Machine Interrupt Delegation), analogous for interrupts.
When a delegated trap fires, the hardware writes to sstatus.SPP/SPIE, sepc, scause, stval and jumps to stvec instead of mstatus/mepc/mcause/mtval and mtvec. M-mode never sees the trap.
The convention is that M-mode delegates every routine kernel trap (page faults, syscalls, timer interrupts) to S-mode and keeps only the things that are genuinely its job: machine-mode-external interrupts, illegal-instruction emulation (when needed), and the periodic timer interrupt that drives the OS scheduler (handled in M for SBI’s account). The full delegation flow is its own note: RISC-V Trap Delegation.
In the M+U project profile there is no S-mode to delegate to; medeleg and mideleg are typically read-as-zero. Every trap goes to M-mode.
The mstatus Register and Privilege Stack
mstatus (CSR 0x300) is the most heavily trafficked privileged CSR. The relevant fields for this note (RISC-V Privileged ISA):
| Field | Bits | Meaning |
|---|---|---|
MIE | 3 | global M-mode interrupt enable |
SIE | 1 | global S-mode interrupt enable |
MPIE | 7 | prior MIE before trap |
SPIE | 5 | prior SIE before trap |
MPP | 12:11 | prior privilege mode before M-trap (00=U, 01=S, 11=M) |
SPP | 8 | prior privilege mode before S-trap (0=U, 1=S) |
SUM | 18 | allow S-mode to access U-pages |
MPRV | 17 | use prior privilege for loads/stores |
TSR | 22 | trap on sret in S-mode |
TW | 21 | trap on wfi if waiting > some bounded time |
TVM | 20 | trap on satp access |
The MPP/MPIE/SPP/SPIE pair is the “one-level privilege stack”: exactly one prior privilege state is remembered per privilege level. Two nested M-mode traps would clobber the saved state (which is fine because the handler is allowed to save it elsewhere first), so the spec deliberately bans recursive mret while a trap is in flight; nested M-traps require explicit software handling.
The Hypervisor Extension (H)
The H extension, ratified at version 1.0 in the 2021 timeframe and bundled into the 20240411 / 20241101 / 20250508 manuals (RISC-V Privileged ISA, 2024-11-26 release; Wikipedia, RISC-V), adds a virtualization layer orthogonal to M/S/U. It does not introduce a new privilege level number; instead it duplicates S into two flavors:
- HS-mode (Hypervisor-extended Supervisor). S-mode with the hypervisor CSRs (
hstatus,hedeleg,hideleg,hgatp,hgeip,hgeie,hvip,htval,htinst) accessible. This is where the hypervisor runs. - VS-mode (Virtual Supervisor). S-mode as seen by the guest. The guest OS thinks it is in S-mode; the hardware honors that thinking but routes its CSR accesses to a shadow set (
vsstatus,vsie,vstvec,vsepc,vscause,vstval,vsip,vsatp) so the guest’s view does not collide with the hypervisor’s.
When the hypervisor extension is enabled, the hart has a per-mode bit V (virtualization mode) in addition to the privilege bits. V=0 and privilege=S means HS; V=1 and privilege=S means VS. The privilege encoding remains two bits; H rides as a third orthogonal bit.
Two-stage address translation is the key mechanism: guest virtual addresses (GVA) are translated through the guest’s vsatp page table to guest physical addresses (GPA), and then through the hypervisor’s hgatp page table to host physical addresses (HPA). The TLB caches the composed translation. Trap delegation gets a third axis: hedeleg/hideleg let HS-mode further delegate traps to VS-mode for guest-internal handling.
The result is hardware support for Type-1 or Type-2 hypervisors (KVM-RISC-V, Xvisor, Bao, Spike) running multiple guest OS instances on one core. None of this matters for the definitely-not-esp32 project (which is single-hart, single-process, no virtualization), but H is what the larger RISC-V server roadmap depends on.
ESP32-C3 Cross-Reference
Uncertain
Verify: the specific ESP32-C3 facts in this section (M+U only, vectored-only mtvec, 16 PMP regions, medeleg/mideleg RAZ, Privileged-ISA-v1.10 conformance). Reason: the Espressif Technical Reference Manual is a large binary PDF that WebFetch cannot parse and the official HTML mirror returns the same binary; the facts here rest on cross-referenced WebSearch summaries rather than direct reading. To resolve: download the PDF locally and verify Chapter 5 (RISC-V CPU) and the Interrupt Matrix chapter. The content is consistent across multiple secondary references including the NuttX ESP32-C3 docs (NuttX ESP32-C3) and the SparkFun/Adafruit-distributed datasheet copies. uncertain
The ESP32-C3 implements exactly M-mode and U-mode. The relevant facts:
mstatushas the standardMIE,MPIE, andMPPfields.MPPcan be either00(U) or11(M);01(S) is illegal and writes silently ignore it.mtvecoperates only in vectored mode (mtvec.MODE = 1), withBASEaligned to 256 bytes. No direct-mode handler.- Up to 16 PMP regions are exposed via
pmpcfg0-pmpcfg3andpmpaddr0-pmpaddr15. M-mode configures them on boot; U-mode application code lives within a configured PMP region and any out-of-region access raises an exception that re-enters M-mode. medeleg/midelegexist in the CSR address space but are read-as-zero; there is no S-mode to delegate to.- The ESP32-C3 docs cite Privileged ISA v1.10 as the conformance target (one revision behind the current public release, but the M/U mechanism is unchanged in v1.13).
This is the M+U profile the definitely-not-esp32 project mirrors exactly. The chip provides hardware for the privilege transitions described above; the kernel software (FreeRTOS or ESP-IDF on the C3, the custom Rust microkernel on definitely-not-esp32) provides the trap dispatch and PMP configuration.
The Supervisor Binary Interface (SBI)
On M+S+U systems, the M-mode firmware exposes a stable API to the S-mode kernel via the ecall instruction; this API is the SBI, specified separately in riscv-non-isa/riscv-sbi-doc and ratified at version 3.0 as of July 2025 (SBI repo). SBI is what lets the same Linux kernel binary run on chips with different M-mode firmware: the firmware (OpenSBI, RustSBI, Coreboot’s SBI) implements the SBI calls, and Linux issues ecall for things like “set the timer”, “send an inter-processor interrupt”, “shut down”. M+U-only systems do not use SBI (there is no S-mode caller); the kernel that lives in M-mode handles everything itself.
Failure Modes and Common Misunderstandings
- “S-mode controls PMP.” No. PMP is an M-mode-only mechanism. S-mode controls the MMU via
satp; PMP regions are configured by M-mode and constrain even M-mode itself if its regions are locked. - “
uretexists.” It does not, in any ratified extension. The N extension that would have added user-level traps was withdrawn. U-mode code cannot voluntarily return from a trap; every trap exits viasretormret. - “Hypervisor mode is a fourth privilege level.” No. H extends S into HS and VS without adding to the two-bit privilege field. The
Vvirtualization bit is orthogonal. - “M+S without U is supported.” No. The spec says U is mandatory if S is implemented. Otherwise S has no one to supervise.
- “
mretfrom M-mode always lands in U-mode.” Not necessarily; it lands wherevermstatus.MPPsays. If MPP was set to M before the trap (because M-mode trapped itself),mretreturns to M. - “Each privilege level has its own register file.” All privilege levels share the same
x0-x31integer registers andf0-f31float registers. The OS context-switch code is responsible for saving/restoring those on trap entry. Privilege levels differ in CSR visibility, not in general-register banks. - “PMP fully replaces an MMU.” Only for static partitioning. PMP regions are configured by M-mode; the typical count is 8-16. An MMU translates dynamically through page tables and can isolate hundreds of processes. PMP is sufficient for M+U with a small fixed number of memory regions; not sufficient for general multi-process protection.
Alternatives in Other Architectures
The M/S/U model maps approximately onto familiar architectures:
- x86: rings 0/1/2/3. Linux uses 0 (kernel, ~ S) and 3 (user, ~ U). The hypervisor extension VMX adds a ring “minus 1” (~ H/HS). RISC-V’s M does not directly correspond to any x86 ring; it is more like System Management Mode (SMM), a high-privilege out-of-band mode that the OS does not know about.
- ARMv8-A: EL0 (user, ~ U), EL1 (kernel, ~ S), EL2 (hypervisor, ~ HS), EL3 (secure firmware, ~ M). Closer to RISC-V than x86; the ARM EL3 has the same “owns the world at boot, drops to lower levels” role as RISC-V M.
- OpenRISC: two-level (supervisor and user); no analog of M or H.
- PowerPC: hypervisor, supervisor, user (~ M, S, U) on Power-style processors.
RISC-V’s choice to make S optional and to allow the M+U combination is what differentiates it from ARM and Power. ARMv8-A requires EL1 for any system that runs apps; RISC-V is OK with the kernel being M itself for tiny chips.
Production Notes
For the definitely-not-esp32 project, the privilege model dictates a specific RTL layout: a one-bit “current privilege” register (because M and U differ in one encoding bit), a CSR file with mstatus/mtvec/mepc/mcause/mtval and the PMP block, decode logic that traps on privilege-violating instructions (e.g., U-mode trying to execute mret), and trap-entry/return microcode that updates the privilege register and the CSR stack atomically. The whole privilege subsystem is small (probably a few hundred lines of Verilog out of a few thousand); most of the complexity is in PMP and trap dispatch.
On the kernel side, the M-mode boot code sets up PMP regions, installs the trap vector in mtvec, configures the timer via the CLINT, and finally executes mret with mstatus.MPP=00 to drop the first task into U-mode. After that, every interaction with the kernel is a trap (ecall for syscalls, timer interrupt for the scheduler, PMP fault for bad pointers); each one re-enters M-mode via the trap vector and either services the request or terminates the offending process.
Real-world note from the Caliptra project’s port of the Western Digital VeeR EL2 core: adding U-mode to a core that previously only had M is a non-trivial change because it requires implementing the full mstatus.MPP semantics, the privilege-level checks on every CSR access, the U-mode-permitted-vs-not partition of the CSR address space, the trap entry that captures the source mode, and the mret that restores it (Antmicro blog, VeeR EL2 user mode). The work is illustrative of how much hangs on the seemingly minor decision to support U.
See Also
- Control and Status Registers: the catalog of CSRs each privilege level owns
- Zicsr Extension: the instructions that read/write those CSRs
- RISC-V Trap Handling: the full trap entry/dispatch flow
- RISC-V Trap Delegation: how
medeleg/midelegshort-circuit M to S - Supervisor Binary Interface: the M-to-S ecall ABI for M+S+U systems
- ecall Instruction: the explicit “I want a syscall” trap
- Physical Memory Protection: the M-mode-managed isolation mechanism, used by ESP32-C3 and this project
- Memory Management Unit: the S-mode-managed alternative for M+S+U systems
- RISC-V Instruction Set Architecture: the parent ISA family
- ESP32-C3: the M+U reference chip
- Computer Architecture MOC: parent map