RISC-V Trap Delegation
Trap delegation is the RISC-V mechanism by which M-mode software tells the hardware “do not bounce this trap through me, send it straight to S-mode.” Two CSRs, medeleg (Machine Exception Delegation, 0x302) and mideleg (Machine Interrupt Delegation, 0x303), are bitmasks indexed by cause code: a 1 bit in position i of medeleg routes synchronous exception code i directly to S-mode when the trap originates in S-mode or U-mode; a 1 bit in position i of mideleg does the same for interrupt i. The privileged spec authorizes the mechanism in one sentence: “Implementations can provide individual read/write bits within
medelegandmidelegto indicate that certain exceptions and interrupts should be processed directly by a lower privilege level” (machine.adoc lines 1241-1243). Without delegation, every trap goes to M-mode and the M-mode firmware has to re-dispatch S-mode traps via software, doubling the cost of every syscall and page fault.
This note covers the routing aspect of traps. The general trap entry mechanism is in RISC-V Trap Handling; the CSRs involved are in Control and Status Registers; the SBI firmware that sets up delegation is in Supervisor Binary Interface.
Mental Model: A Bitmask Switch Per Cause Code
Picture a small mux per cause code. By default, every cause-code wire is routed to M-mode (mtvec). Flipping a bit in medeleg or mideleg flips the mux for that single cause, sending it to S-mode (stvec) instead. The wires for causes that cannot exist in less-privileged modes (ecall-from-M, the double-trap exception, M-only interrupts) are physically not switchable; their bits are read-only zero.
flowchart LR subgraph CAUSES["Trap causes (per cause code i)"] direction TB C0["cause 0..15<br/>(sync exceptions)"] C1["cause 8 (ecall-U)"] C2["cause 11 (ecall-M)"] C3["cause 7 (M timer int)"] C4["cause 5 (S timer int)"] end subgraph DELEG["Delegation bitmaps in M-mode CSRs"] direction TB ME["medeleg bit i:<br/>set → cause i sync exception<br/>delegated to S-mode"] MI["mideleg bit i:<br/>set → cause i interrupt<br/>delegated to S-mode"] end subgraph TARGETS["Trap targets"] direction TB M["M-mode handler<br/>via mtvec"] S["S-mode handler<br/>via stvec"] end C1 --> ME C0 --> ME C3 --> MI C4 --> MI C2 -. "medeleg[11] is<br/>read-only zero" .-> M ME -- "bit=1 and from S/U" --> S ME -- "bit=0 or from M" --> M MI -- "bit=1 and from S/U" --> S MI -- "bit=0 or from M" --> M
Delegation as a per-cause routing switch. What it shows: every trap cause flows through the delegation bitmask. A 1 bit (when the originating privilege is below M) reroutes to S-mode; a 0 bit (or when M-mode is the originator) keeps the trap in M-mode. The insight to take: delegation is purely a routing decision, not a permission grant; setting medeleg[8] does not change what U-mode is allowed to do, only which handler runs.
Mechanical Walk-through: How the Bitmaps Work
medeleg and mideleg layouts
Each of medeleg and mideleg is an XLEN-bit (typically 32 or 64 bits) read-write CSR. The privileged spec defines their layouts: “medeleg has a bit position allocated for every synchronous exception shown in [the mcause table], with the index of the bit position equal to the value returned in the mcause register” (machine.adoc lines 1311-1314). The same indexing applies to mideleg: “mideleg holds trap delegation bits for individual interrupts, with the layout of bits matching those in the mip register” (machine.adoc lines 1325-1327).
So if exception cause 8 is “Environment call from U-mode,” then setting bit 8 of medeleg delegates U-mode ecall to S-mode. If interrupt cause 5 is “Supervisor timer interrupt,” then setting bit 5 of mideleg delegates the S-timer to S-mode. The cause table from RISC-V Trap Handling doubles as the bit-position map for these two CSRs.
On RV32, the spec adds a companion register medelegh at 0x312 that aliases bits 63:32 of medeleg, so the upper half of the 64-bit logical bitmap is reachable on 32-bit cores (machine.adoc lines 1317-1319). The user’s RV32 definitely-not-esp32 would use medelegh if it implemented S-mode, but v1.0 has no S-mode and so does not implement either delegation CSR.
What “delegate” actually means
When a trap fires, the hardware looks at:
- the cause code,
- the originating privilege,
- the corresponding bit in medeleg (or mideleg for interrupts).
If the bit is set and the originating privilege is lower than M, the trap is delivered directly to S-mode. That means:
- the S-mode CSRs (sepc, scause, stval, sstatus) get the trap-entry writes instead of the M-mode counterparts
- the privilege transitions to S-mode (not M-mode)
- the PC is loaded from stvec (not mtvec)
- mstatus.SPP records the previous privilege; mstatus.SPIE records the previous SIE; mstatus.SIE is cleared
The M-mode CSRs are untouched. mepc, mcause, mtval still hold whatever the last M-mode trap left there. The spec is explicit on this: “The mcause, mepc, and mtval registers and the MPP and MPIE fields of mstatus are not written” (machine.adoc line 1270, wording for delegated traps to S-mode).
The S-mode handler runs, services the trap, and executes sret (not mret) to return. M-mode is bypassed entirely.
What “delegate” cannot mean
Three rules limit the routing:
1. Traps never transition to lower privilege than the originator. From the spec: “Traps never transition from a more-privileged mode to a less-privileged mode. For example, if M-mode has delegated illegal-instruction exceptions to S-mode, and M-mode software later executes an illegal instruction, the trap is taken in M-mode, rather than being delegated to S-mode” (machine.adoc lines 1291-1294). The delegation bitmap only affects traps from S-mode and U-mode; M-mode traps always stay in M-mode regardless.
2. Traps may transition horizontally. From the same section: “Traps may be taken horizontally. Using the same example, if M-mode has delegated illegal-instruction exceptions to S-mode, and S-mode software later executes an illegal instruction, the trap is taken in S-mode” (machine.adoc lines 1295-1298). So if S-mode itself faults on a delegated cause, the trap is delivered to S-mode (the originator gets the trap). This is how S-mode handles its own page faults and illegal-instruction traps without bouncing through M-mode.
3. Some bits cannot be set. The spec mandates read-only-zero bits for causes that cannot occur in less-privileged modes. The two named ones:
medeleg[11](Environment call from M-mode) is read-only zero. Reason: ecall-from-M is by definition a M-mode trap, and an M-mode trap cannot be delegated by rule 1, so the bit is meaningless. The spec: “For exceptions that cannot occur in less privileged modes, the correspondingmedelegbits should be read-only zero. In particular,medeleg[11] is read-only zero” (machine.adoc lines 1329-1331).medeleg[16](Double trap) is read-only zero by another spec rule: “Themedeleg[16] is read-only zero as double trap is not delegatable” (machine.adoc line 1333).
Beyond these spec-mandated read-only zeros, implementations are allowed to subset the delegatable causes (some chips might pin medeleg[2] to zero so illegal-instruction always traps to M-mode for emulation), but they may not pin a bit to read-only one: “An implementation shall not have any bits of medeleg be read-only one” (machine.adoc line 1278). Same restriction for the M-level interrupt bits in mideleg: “An implementation shall not fix as read-only one any bits of mideleg corresponding to machine-level interrupts” (machine.adoc lines 1281-1283).
Delegated interrupts get masked at the delegator
A subtle but important fact about interrupt delegation: when an interrupt is delegated to S-mode, it is no longer visible to M-mode. The spec says: “Delegated interrupts result in the interrupt being masked at the delegator privilege level. For example, if the supervisor timer interrupt (STI) is delegated to S-mode by setting mideleg[5], STIs will not be taken when executing in M-mode. By contrast, if mideleg[5] is clear, STIs can be taken in any mode and regardless of current mode will transfer control to M-mode” (machine.adoc lines 1300-1306).
The practical effect: an S-mode kernel can rely on the S-timer interrupt firing while it executes (good) and an M-mode firmware that delegates the S-timer will never see the S-timer fire while it executes (also good, the firmware wants to be left alone). The M-timer interrupt (cause 7) is not delegated in a typical setup, so M-mode firmware keeps full control of the machine-level timer for its own bookkeeping.
Existence rules for delegation CSRs
A hart without S-mode does not need delegation CSRs at all, and the spec asks that they be absent: “In harts without S-mode, the medeleg and mideleg registers should not exist” (machine.adoc line 1252). Attempting to access them on such a hart raises an illegal-instruction trap. The user’s definitely-not-esp32 v1.0 is M+U with no S-mode, so it correctly omits both registers.
A hart with S-mode must implement both: “In harts with S-mode, the medeleg and mideleg registers must exist, and setting a bit in medeleg or mideleg will delegate the corresponding trap…” (machine.adoc lines 1248-1250).
Why delegation matters for performance
Without delegation, every S-mode trap (page fault, syscall, timer interrupt) would do this:
- Hardware traps to M-mode (1 trap entry: ~5-20 cycles).
- M-mode firmware checks if the trap belongs to S-mode (a few cycles of cause decoding).
- M-mode firmware re-injects the trap into S-mode by setting up sepc, scause, stval, sstatus, then executing
mretwith mstatus.MPP = S so the hart re-enters S-mode at stvec. - S-mode handler runs, services the trap.
- S-mode executes
sretto return.
That is two trap entries and two returns for one logical event. On a system with high syscall rates (a typical Linux workload doing millions of syscalls per second), the doubled overhead is unacceptable.
With delegation, the same event is:
- Hardware traps directly to S-mode (1 trap entry: ~5-20 cycles).
- S-mode handler runs.
- S-mode executes
sret.
One entry and one return. Page faults are the dominant cost in many workloads, and saving a round-trip through M-mode on every page fault is the single largest reason to use delegation. The walk on popovicu.com about SBI boot makes this concrete by tracing what happens when the kernel does not delegate (extra latency) versus when it does (the typical Linux/OpenSBI default).
The Linux/OpenSBI typical configuration
A typical Linux-on-RISC-V system has OpenSBI in M-mode and Linux in S-mode. OpenSBI’s delegate_traps function in lib/sbi/sbi_hart.c sets up the masks; the exact code, copied verbatim (OpenSBI lib/sbi/sbi_hart.c), is:
interrupts = MIP_SSIP | MIP_STIP | MIP_SEIP;
interrupts |= sbi_pmu_irq_mask();
exceptions = (1U << CAUSE_MISALIGNED_FETCH) | (1U << CAUSE_BREAKPOINT) |
(1U << CAUSE_USER_ECALL);
if (sbi_platform_has_mfaults_delegation(plat))
exceptions |= (1U << CAUSE_FETCH_PAGE_FAULT) |
(1U << CAUSE_LOAD_PAGE_FAULT) |
(1U << CAUSE_STORE_PAGE_FAULT) |
(1U << CAUSE_SW_CHECK_EXCP);
csr_write(CSR_MIDELEG, interrupts);
csr_write(CSR_MEDELEG, exceptions);So unconditionally OpenSBI sets:
medelegbits 0 (instruction misaligned), 3 (breakpoint), 8 (ecall-from-U)midelegbits 1 (SSIP, S-software), 5 (STIP, S-timer), 9 (SEIP, S-external), plus any PMU interrupts the build advertises
And conditionally, when the platform claims it can delegate memory-fault traps to S-mode (the common case for any system with an MMU), OpenSBI additionally sets:
medelegbits 12 (instruction page fault), 13 (load page fault), 15 (store/AMO page fault), 18 (software check)
Notice what is not delegated: access faults (causes 1, 5, 7), illegal-instruction (cause 2), misaligned load/store (causes 4, 6), ecall-from-S (cause 9), and ecall-from-M (cause 11, read-only zero by spec). OpenSBI keeps illegal-instruction in M-mode so it can emulate unimplemented instructions (notably misaligned access fixup) before passing control to the kernel, and keeps S-mode ecall in M because that is the SBI call mechanism.
The Linux kernel itself takes whatever delegation OpenSBI sets up and works with it. When OpenSBI delegates page faults, Linux handles them directly in S-mode; when it does not (a small platform that opted out), Linux’s page-fault path goes through OpenSBI’s bounce, which is correct but slower.
The H-extension code path (visible in the same function but pruned from the excerpt above) additionally delegates virtual-supervisor ecall (cause 10) and the guest-page-fault causes to HS-mode, which is the mechanism that lets KVM-on-RISC-V run efficiently.
Configuration / Code / Specification
Setting up delegation in OpenSBI-style assembly
The actual delegation setup in M-mode boot code looks like:
# Build the medeleg bitmask: the OpenSBI default with mfaults delegation
# enabled, i.e. instruction-misaligned (0), breakpoint (3), ecall-from-U (8),
# inst-page-fault (12), load-page-fault (13), store-page-fault (15).
li t0, 0x0000B109 # = (1<<0)|(1<<3)|(1<<8)|(1<<12)|(1<<13)|(1<<15)
csrw medeleg, t0
# Build the mideleg bitmask: S-software (1), S-timer (5), S-external (9).
li t0, (1<<1) | (1<<5) | (1<<9) # = 0x222
csrw mideleg, t0
# Now any U-mode ecall, page fault, misalign, etc. goes straight to
# S-mode without bouncing through this M-mode firmware. Any S-mode
# ecall (the SBI call mechanism) still traps to M-mode.csrw is the pseudo for csrrw x0, medeleg, t0 (a pure write because rd = x0 suppresses the read side effects; see Control and Status Registers for the Zicsr rules).
A real OpenSBI build does the equivalent in C, in sbi_hart_init, with explicit constants per cause code. After the masks are set, OpenSBI also configures mtvec to its own trap handler (for the traps that do still come to M-mode, i.e., S-mode ecalls and the small set of cause codes M keeps for emulation), then jumps to the next-stage S-mode kernel via mret with mstatus.MPP = 01 (S).
Reading back to discover what is supported
Because the delegation bits are WARL (write any, read legal), the kernel can discover at runtime which causes the implementation allows it to take by writing all-ones and reading back. This is the canonical “what can I delegate?” probe:
li t0, -1 # 0xFFFFFFFF on RV32, all 64 ones on RV64
csrw medeleg, t0
csrr t1, medeleg # t1 = bits the hardware accepts
# Now t1 has 1s for every cause this core allows delegating.After the probe the kernel knows exactly which bits to set in production. This is how OpenSBI handles vendor-specific implementations that pin certain causes to M-mode (or, conversely, that allow delegation of vendor-specific custom causes).
Per-hart delegation
Each hart has its own medeleg/mideleg pair. Multi-hart firmware boot code sets up delegation on each hart independently. SBI firmware typically does this in the per-hart initialization path, before parking the secondary harts in WFI to await a sbi_hart_start call from the boot hart.
Failure Modes and Common Misunderstandings
The most common misunderstanding is confusing delegation with permission. Setting medeleg[8] does not give U-mode any new abilities; the ecall instruction was always legal in U-mode. The only thing the bit changes is who handles the resulting trap. Conversely, not setting medeleg[8] does not break user-mode syscalls; it just makes them slower (round-trip through M-mode).
Forgetting that M-mode traps never delegate. Programmers occasionally try to debug a problem by “delegating illegal-instruction so my S-mode kernel can catch it,” then are surprised when an illegal instruction in the M-mode firmware still traps to M-mode. Rule 1 prevails: M-originated traps stay in M.
Forgetting that delegation masks the interrupt at M. Code that delegates the S-timer and then expects M-mode firmware to also see S-timer interrupts (for, say, scheduling its own work piggybacked on the kernel’s timer) will be disappointed. M-mode must keep its own interrupt source (typically the M-timer at cause 7) and not share with S-mode.
Reading the wrong CSR after a delegated trap. A naive M-mode handler that on entry does csrr t0, mcause; csrr t1, mtval will see stale values for any trap that was delegated to S-mode; mcause and mtval only update for M-mode traps. The S-mode handler must read scause and stval instead.
Pinning a bit to read-only one. An implementer who thinks “all page faults always go to S-mode, no kernel will ever change it” cannot legally pin medeleg[12,13,15] to read-only one; the spec disallows it. Similarly, vendors who would like to force the S-timer interrupt to always be delegated must instead leave mideleg[5] software-writable and rely on the SBI firmware to set it.
Trying to delegate from S to U. There is no sedeleg or sideleg in the ratified privileged spec (those were part of the N extension, which was removed). S-mode cannot push traps down to U-mode; the privilege model is two-level (delegate from M to S, or do not), not arbitrary-level.
Alternatives and When to Choose Them
No delegation (M-only systems). A small embedded core like the ESP32-C3 or definitely-not-esp32 v1.0 has no S-mode and no delegation. The M-mode kernel handles every trap directly. This is the simplest model and is exactly right when there is no need for an S-mode kernel layered under user processes.
Full delegation (Linux-on-RISC-V). A Linux system delegates every cause Linux can handle to S-mode, keeping only the bare minimum in M-mode (illegal-instruction for emulation, S-mode ecall for the SBI ABI itself). This minimizes the firmware footprint and maximizes performance.
Selective delegation (research kernels, custom firmware). Some experiments keep certain causes in M-mode for instrumentation: a research kernel might keep page faults in M-mode to measure timing or implement experimental TLB management policies. The cost is one extra trap-entry per delegated event.
x86 trap handling does not have an analog. On x86, every trap goes through the IDT, which is set up by the kernel (ring 0). There is no two-level firmware-kernel split, so there is no “delegate this trap to a lower level” mechanism. The closest analog is virtualization: under a hypervisor, certain traps are intercepted by the hypervisor and others are passed through to the guest, but the mechanism (VMCS exit conditions) is structurally different.
ARM’s exception model has analogous per-exception-level routing. Exceptions can be routed to EL1 (kernel), EL2 (hypervisor), or EL3 (secure monitor) based on a combination of SCR_EL3 and HCR_EL2 bits. The high-level idea is the same (firmware decides which exceptions it wants to handle vs delegate), but the encoding is per-class rather than per-cause-code.
For definitely-not-esp32 the simple answer is: v1.0 has no S-mode, no delegation, and the M-mode microkernel handles every trap. Delegation becomes relevant only if the project ever grows S-mode for a stretch-goal Linux port, in which case the kernel would need to be paired with a small SBI implementation (RustSBI is the natural choice for a Rust microkernel project), and the delegation setup above would be needed in the SBI boot code.
Production Notes
OpenSBI’s delegation code lives in lib/sbi/sbi_hart.c in the function mstatus_init (and friends). It enumerates each cause code and decides whether to delegate based on platform-specific allow lists; the resulting masks are then written to medeleg/mideleg via the CSR helpers. The reference firmware also handles the case where the implementation refuses to delegate certain bits by re-reading after writing and adjusting its internal “what I have to handle in M” set accordingly (OpenSBI repo).
RustSBI does the same in Rust: the relevant function is init_runtime_delegation (approximate name; the project’s namespace evolves). The bits set are essentially identical to OpenSBI’s because the Linux kernel expects the same delegation; an SBI implementation that delegated fewer bits would cause Linux to take double traps and slow down dramatically (RustSBI repo).
Linux itself does not normally write medeleg/mideleg directly because it runs in S-mode and those CSRs are M-mode-only. The kernel relies on the SBI firmware to have done the setup correctly. If the kernel notices (via probing) that a particular cause is not delegated to S-mode, it falls back to slower paths (or panics on truly required causes like ecall-from-U).
The seL4 microkernel and NuttX RTOS both run in S-mode on systems that have it, and both rely on the same OpenSBI delegation setup. The microkernel community has standardized on the SBI ABI precisely so that one firmware can host many kernels.
A platform-specific note: some RISC-V chips (notably the SiFive U54 and U74 cores) implement all spec-defined delegation bits, while smaller embedded cores may omit medeleg entirely (since they have no S-mode). The CVA6 family (CVA6 documentation) is a documented open-source example: the CV32A65X configuration has only M+U mode and so does not implement medeleg/mideleg, while the CV64A6_MMU configuration has full M+S+U and the full delegation CSR set.
See Also
- RISC-V Trap Handling for the general trap entry mechanism
- Control and Status Registers for medeleg, mideleg, and friends
- ecall Instruction for cause 8 (U-mode ecall) is the most-delegated exception
- Supervisor Binary Interface for the ABI that delegation enables
- RISC-V Privilege Modes for M/S/U levels and delegation between them
- Microkernel for pushes work into U-mode, often paired with delegation
- Core Local Interruptor for generates the timer and software interrupts that get delegated
- Platform-Level Interrupt Controller for generates the external interrupts that get delegated
- Computer Architecture MOC