RISC-V Trap Handling

A trap on a RISC-V hart is the unified mechanism by which the hardware transfers control to a software handler in response to either a synchronous exception (raised by an instruction: illegal opcode, page fault, ecall, …) or an asynchronous interrupt (raised by an external source: timer, IPI, peripheral). When a trap fires, the hardware atomically (1) writes the trapping or next-to-execute PC into mepc, (2) writes a cause code into mcause, (3) optionally writes a diagnostic value into mtval, (4) pushes the previous privilege level and interrupt-enable bit onto a one-deep stack in mstatus (MPP, MPIE) and disables interrupts (MIE = 0), then (5) sets the PC to the address in mtvec (Privileged ISA, machine trap handling). The handler runs at the higher privilege, optionally enables interrupts after it has saved enough state, services the trap, then executes mret which atomically undoes the privilege/interrupt-enable push and jumps back to mepc. The same machinery exists in mirrored form for supervisor mode (sepc, scause, stval, sstatus, sret) when traps are delegated; see RISC-V Trap Delegation.

This note is the central reference for how the hardware delivers a trap and how software returns from one. Sibling notes own the pieces: Control and Status Registers documents the CSRs used here, ecall Instruction focuses on the one synchronous-exception instruction that drives system calls, RISC-V Trap Delegation explains how a trap can be routed straight to S-mode, and Supervisor Binary Interface is the ABI built on top of M-mode trap handling.

Mental Model: The Trap as a Forced Function Call

A useful first picture: a trap is a forced function call whose target address is held in a CSR (mtvec), whose return address is recorded by hardware in another CSR (mepc), and whose “calling convention” is implicit (the handler must save every register it intends to clobber before doing anything that might write to them). Unlike a normal call, the handler runs at a higher privilege, with interrupts disabled, and uses mret instead of ret to return.

The trap mechanism is one of the few places in the ISA where the hardware reads and writes CSRs on the program’s behalf. Software sets up mtvec once at boot; from then on, the hardware does its part on every trap, and the handler does the rest.

flowchart TD
  A["Running instruction in mode y<br/>(U-mode, S-mode, or M-mode)"] --> B{"Trap?"}
  B -- "no" --> A
  B -- "synchronous exception<br/>(ecall, illegal insn,<br/>fault, page fault)" --> C["Hardware:<br/>mepc = PC of trapping instruction<br/>mcause = exception code<br/>mtval = diagnostic value"]
  B -- "asynchronous interrupt<br/>(timer, software, external)" --> D["Hardware:<br/>mepc = PC of next instruction<br/>mcause = (1<<31) | int code<br/>mtval = 0"]
  C --> E["Hardware:<br/>mstatus.MPIE = mstatus.MIE<br/>mstatus.MIE = 0<br/>mstatus.MPP = y<br/>privilege = M"]
  D --> E
  E --> F{"mtvec.MODE?"}
  F -- "0 (Direct)" --> G["PC = mtvec.BASE"]
  F -- "1 (Vectored)" --> H{"interrupt?"}
  H -- "yes" --> I["PC = mtvec.BASE + 4 * cause"]
  H -- "no" --> G
  G --> J["Handler stub runs in M-mode<br/>(save registers via mscratch,<br/>dispatch by mcause,<br/>service the trap)"]
  I --> J
  J --> K["mret<br/>mstatus.MIE = mstatus.MPIE<br/>mstatus.MPIE = 1<br/>privilege = mstatus.MPP<br/>mstatus.MPP = U<br/>PC = mepc"]
  K --> A

The full trap entry-and-exit cycle. What it shows: synchronous exceptions and asynchronous interrupts converge in the hardware’s CSR update step, then diverge again based on mtvec’s MODE field. The handler runs, then mret undoes the privilege/interrupt-enable push in one atomic step. The insight to take: the hardware does five precisely defined CSR writes on entry and one composite CSR update on exit, no more and no less. Everything else (register save/restore, syscall dispatch, page-fault recovery) is software’s responsibility.

Mechanical Walk-through: From Trap Trigger to mret

Step 1: trap detection and cause identification

A RISC-V hart checks for a trap at each instruction boundary. Two classes of events qualify, and they are merged into the same dispatch path so that handlers can be uniform.

Synchronous exceptions are raised by the currently executing instruction. They include misaligned loads, illegal opcodes, breakpoints, faults from PMP or page-table walks, and the explicit environment-call (ecall) and breakpoint (ebreak) instructions. Synchronous exceptions are precise: the spec says “These two instructions cause a precise requested trap to the supporting execution environment” of ecall and ebreak (rv32.adoc lines 900-901), but the same precision applies to every synchronous exception. The instruction at mepc is the one that caused the trap; no instruction logically after it has retired.

Asynchronous interrupts are raised by external hardware (the timer in the CLINT, a software IPI from another hart, a wire from the PLIC). They are checked between instructions when the corresponding bit in mip is set, the corresponding bit in mie is set, and global interrupts are enabled (mstatus.MIE = 1 when running in M-mode, or the hart is running in a less-privileged mode in which case the higher-priv interrupt always preempts). The spec is careful: “Interrupts for higher-privilege modes are always enabled regardless of the setting of the global xIE bit for the higher-privilege mode” (machine.adoc), so a U-mode program cannot mask M-mode interrupts by leaving them off.

When the hardware detects either class of event, it determines the cause code from the table in the privileged spec (machine.adoc lines 1774-1900). The complete standard exception codes are:

codeexception
0Instruction address misaligned
1Instruction access fault
2Illegal instruction
3Breakpoint
4Load address misaligned
5Load access fault
6Store/AMO address misaligned
7Store/AMO access fault
8Environment call from U-mode
9Environment call from S-mode
11Environment call from M-mode
12Instruction page fault
13Load page fault
15Store/AMO page fault
16Double trap
18Software check
19Hardware error

(Codes 10, 14, 17, and 20-31 are reserved; 32-47 and 64+ are “Designated for custom use” or platform use; see the spec table.)

The standard interrupt codes are:

codeinterrupt
1Supervisor software interrupt
3Machine software interrupt
5Supervisor timer interrupt
7Machine timer interrupt
9Supervisor external interrupt
11Machine external interrupt
13Counter-overflow interrupt
16+Designated for platform use

The numbering is not arbitrary: each pair shares a bit position in mip, mie, and (where delegated) sip, sie, so that the same hardware bit drives consistent indicators across the CSRs.

Step 2: hardware-driven CSR updates on entry

When a trap is taken into M-mode, the hardware performs the following writes atomically with the privilege transition (machine.adoc lines 391-398; [Control and Status Registers]]):

  1. mepc ← PC. For synchronous exceptions, mepc holds the virtual address of the trapping instruction. For asynchronous interrupts, mepc holds the address of the instruction that would have executed next had the interrupt not fired. The spec wording: “When a trap is taken into M-mode, mepc is written with the virtual address of the instruction that was interrupted or that encountered the exception” (machine.adoc lines 1716-1718). The handler must explicitly advance mepc by 4 (or 2 for compressed instructions) before mret if it wants to skip the trapping instruction; this is what an ecall handler always does.
  2. mcause ← {interrupt-bit, exception-code}. Bit 31 (or bit 63 on RV64) is set if the trap was an interrupt; the lower bits hold the cause code from the tables above. As the spec puts it, “The Interrupt bit in the mcause register is set if the trap was caused by an interrupt” (machine.adoc lines 1735-1737).
  3. mtval ← diagnostic value, per the cause-specific rules. For a memory fault (misaligned, access fault, page fault) mtval is the faulting virtual address. For an illegal-instruction trap mtval is the encoding of the offending instruction (or zero, if the implementation cannot capture it). For ecall and ebreak, the spec allows mtval to be zero or to hold something implementation-defined, but most cores write zero.
  4. mstatus push (the heart of the privilege stack). With the notation x = target mode (here M) and y = source mode (whatever the hart was in), the spec says: “When a trap is taken from privilege mode y into privilege mode x, __x__PIE is set to the value of __x__IE; __x__IE is set to 0; and __x__PP is set to y” (machine.adoc lines 397-398). Concretely for an M-mode trap:
    • mstatus.MPIE ← mstatus.MIE (save the old interrupt-enable)
    • mstatus.MIE ← 0 (disable interrupts so the handler can save registers without re-entrance)
    • mstatus.MPP ← y (record where we came from; 2-bit field can hold U=00 or M=11 if S is not present, S=01 or H=10 with the appropriate extensions)
  5. PC ← mtvec target, where the target depends on mtvec.MODE. For MODE = 0 (Direct), the new PC is mtvec.BASE for any trap. For MODE = 1 (Vectored), synchronous exceptions still go to mtvec.BASE, but interrupts go to mtvec.BASE + 4 * (cause code). The spec is explicit: “When MODE=Direct, all traps into machine mode cause the pc to be set to the address in the BASE field. When MODE=Vectored, all synchronous exceptions into machine mode cause the pc to be set to the address in the BASE field, whereas interrupts cause the pc to be set to the address in the BASE field plus four times the interrupt cause number” (machine.adoc lines 1214-1220). The “four times” budget gives each interrupt slot a single 4-byte instruction, just enough room for a j handler_n jump.

All five updates happen as a single atomic transition. Software cannot observe an intermediate state where mepc has the new value but mstatus still has the old MPP.

Step 3: the trap handler stub

After the hardware transition, the hart is now running in M-mode at the address in mtvec, with interrupts disabled, and with every general-purpose register still holding the user-mode value. Anything the stub touches that is not first saved is lost. The canonical pattern uses mscratch (the per-hart scratch CSR) to swap in a known kernel pointer atomically.

.align 4
trap_entry:
    # mscratch holds a pointer to a per-hart trap-context struct
    # in M-mode kernel memory. Atomically swap it with sp so that
    # sp now points at the context and mscratch holds the old sp.
    csrrw sp, mscratch, sp
 
    # Save the entire register file into the context.
    sw  ra,   0(sp)
    sw  gp,   4(sp)
    sw  tp,   8(sp)
    sw  t0,  12(sp)
    # ... continue for x6..x31 ...
    sw  a0,  40(sp)
    sw  a1,  44(sp)
    # ... etc.
 
    # Recover the original sp (currently sitting in mscratch) and
    # save it too.
    csrr  t0, mscratch
    sw    t0, 8*4(sp)            # slot for x2 (sp)
 
    # Restore mscratch so a nested trap (which we have disabled
    # for now, but in case we re-enable) finds the context again.
    csrw  mscratch, sp
 
    # Save mepc, mcause, mtval into the context.
    csrr  t0, mepc
    sw    t0, MEPC_OFF(sp)
    csrr  t0, mcause
    sw    t0, MCAUSE_OFF(sp)
    csrr  t0, mtval
    sw    t0, MTVAL_OFF(sp)
 
    # Decide whether this is an interrupt or an exception by
    # checking the sign bit of mcause.
    csrr  t0, mcause
    bltz  t0, .Linterrupt        # mcause < 0 means interrupt bit set
 
.Lexception:
    # Dispatch on the low bits of mcause (the exception code).
    mv    a0, sp                  # arg0 = pointer to context
    csrr  a1, mcause
    andi  a1, a1, 0xff            # arg1 = exception code
    call  do_exception
    j     trap_exit
 
.Linterrupt:
    mv    a0, sp
    csrr  a1, mcause
    slli  a1, a1, 1               # shift out interrupt bit
    srli  a1, a1, 1               # zero-extend remainder
    call  do_interrupt
    # fall through to trap_exit
 
trap_exit:
    # Restore mepc (handler may have advanced it past an ecall).
    lw    t0, MEPC_OFF(sp)
    csrw  mepc, t0
 
    # Restore all general-purpose registers.
    lw    ra,   0(sp)
    lw    gp,   4(sp)
    # ... continue for all of x3..x31 except sp and the temporaries
    #     we still need to do the final swap ...
 
    # Final atomic swap: put sp back in mscratch and restore the
    # user-mode sp from where we stashed it.
    csrrw sp, mscratch, sp
 
    # Return from trap. Hardware will:
    #   mstatus.MIE = mstatus.MPIE
    #   mstatus.MPIE = 1
    #   privilege   = mstatus.MPP
    #   mstatus.MPP = U
    #   pc          = mepc
    mret

Line-by-line commentary on the critical parts. csrrw sp, mscratch, sp is the one indispensable trick of every RISC-V kernel: it atomically writes the current sp into mscratch and reads the old mscratch (which the boot code initialized to a pointer to per-hart trap-context memory) into sp. With one instruction, the handler now has a kernel stack and the user’s sp safely in mscratch. The Linux kernel does the same thing with sscratch and tp (arch/riscv/kernel/entry.S), swapping in the thread pointer rather than sp.

The bltz t0, .Linterrupt check exploits the layout of mcause: the interrupt bit is the top bit, so mcause read as a signed integer is negative iff the trap is an interrupt. Linux uses the same pattern, “bge s4, zero, 1f / tail do_irq,” where s4 is the cached cause value and the branch routes interrupts to do_irq (arch/riscv/kernel/entry.S, handle_exception).

The dispatch is then a jump table indexed by the low cause bits, or a direct dispatch into a C handler that switches on the cause. In vectored mode (mtvec.MODE = 1), the hardware does the per-interrupt jump for you, so the vectored trap-vector table looks like a small sequence of j instructions, one per interrupt cause.

Step 4: handler runs

The handler is normal C code (or Rust, in definitely-not-esp32’s case) with one constraint: it cannot rely on any caller-saved register being preserved unless the stub saved it. The first action of the handler is typically to advance mepc by 4 if this is an ecall (or any other “skip the trapping instruction” case), so that the eventual mret resumes at the instruction after the ecall rather than re-executing it. Stephen Marz’s RISC-V OS tutorial puts it directly: “The handler must increment the program counter by 4 bytes to avoid re-executing ecall” (osblog.stephenmarz.com ch7).

For a memory fault, the handler can read mtval to see the offending address; for an illegal instruction, mtval (if implemented) holds the offending encoding, useful for emulating instructions that the core does not support natively (a common technique for misaligned-access fixup).

A handler that intends to enable interrupts in the middle of its work, for example to allow a higher-priority interrupt to preempt it, must first save the privilege-stack state it cares about somewhere (typically the trap-context struct). The privileged-spec note explains why: “Trap handlers must be designed to neither enable interrupts nor cause exceptions during the phase of handling where the trap handler preserves the critical state information required to handle and resume from the trap” (machine.adoc lines 421-431). The one-deep stack in mstatus is enough only if a trap is fully handled before the next one fires.

Step 5: mret undoes the entry

The mret instruction is the inverse of the entry-time CSR update. From the spec: “When executing an _x_RET instruction, supposing _x_PP holds the value y, _x_IE is set to _x_PIE; the privilege mode is changed to y; _x_PIE is set to 1; and _x_PP is set to the least-privileged supported mode (U if U-mode is implemented, else M). If y ≠ M, _x_RET also sets MPRV=0” (machine.adoc lines 411-415).

In English: mret atomically (1) restores MIE from MPIE, (2) sets MPIE back to 1 (the spec’s idea is that we are now in a “freshly re-enabled” state), (3) sets the privilege level to MPP (the value the trap saved), (4) resets MPP to the least-privileged supported mode (U if U-mode exists, else M), and (5) jumps to mepc. The MPP reset is a defensive default that “helps identify software bugs in the management of the two-level privilege-mode stack” (machine.adoc lines 421-422): if your handler forgot to set MPP to the right value before mret, you do not silently fall through to M-mode again; you visibly fall to U-mode and the misbehavior is loud.

mret is a privileged instruction; executing it from anywhere other than M-mode raises an illegal-instruction exception. Supervisor mode has its own analogous sret that operates on sstatus.SPIE/SPP/SIE and sepc, and the H-extension has mnret for non-maskable-interrupt return.

Configuration / Code / Specification

Vectored mtvec setup

In vectored mode each interrupt cause has its own 4-byte slot. A typical setup:

.align 4
trap_vec:
    j trap_handler_sync     # offset 0: synchronous exceptions
    j unused                # offset 4: int cause 1 (SSI), not used in M-only
    j unused                # offset 8: int cause 2 (reserved)
    j sw_int_handler        # offset 12: int cause 3 (MSI)
    j unused                # offset 16
    j unused                # offset 20
    j unused                # offset 24
    j timer_handler         # offset 28: int cause 7 (MTI)
    j unused
    j unused
    j unused
    j ext_int_handler       # offset 44: int cause 11 (MEI)
    # ... up to the largest implemented interrupt code

Set up with:

    la    t0, trap_vec
    ori   t0, t0, 1          # MODE field = 1 (Vectored)
    csrw  mtvec, t0

The ori t0, t0, 1 sets the MODE field bit; la placed the address with low bits zero (because .align 4 lined up the table at a 4-byte boundary, and most cores require BASE alignment of at least 4 bytes in direct mode and stricter alignment in vectored mode; the spec leaves the exact constraint implementation-defined). Note the synchronous-exception case still flows through offset 0, so the same trap_handler_sync does the cause-table dispatch for every exception.

Returning from an ecall handler

void handle_user_ecall(struct trap_ctx *ctx) {
    // ctx->mepc points at the ecall instruction. Advance past it so
    // mret resumes at the instruction after the ecall.
    ctx->mepc += 4;          // ecall is always 4 bytes (no compressed form)
 
    // Dispatch on a7 (the syscall number convention).
    long syscall_no = ctx->a7;
    long ret = sys_call_table[syscall_no](ctx->a0, ctx->a1, ctx->a2,
                                          ctx->a3, ctx->a4, ctx->a5);
    ctx->a0 = ret;           // return value goes into a0
    // trap_exit will write ctx->mepc back into the mepc CSR and mret.
}

The line ctx->mepc += 4 is the moment of difference between “skip the ecall” and “re-execute it on retry.” Software must do it manually; the hardware does not auto-increment.

Failure Modes and Common Misunderstandings

The single most common bug is failing to advance mepc for synchronous exceptions. If a U-mode program executes ecall and the kernel’s handler forgets to do mepc += 4, mret returns to the same ecall, which immediately traps again, and the program is stuck in an infinite syscall loop. This is one of the easier bugs to catch (an instruction pointer that does not progress) but it is a rite of passage for everyone implementing trap handling from scratch.

A more insidious bug is clobbering registers before saving them. The trap stub above uses the csrrw sp, mscratch, sp swap before it touches any other register, precisely so that no general-purpose register is corrupted before its user-mode value is safely on the kernel stack. Code that does, say, addi sp, sp, -CTXSIZE first (a typical C-style prologue) destroys the user’s sp irrecoverably.

Forgetting that interrupts are disabled on entry trips up handlers that block. The hardware sets mstatus.MIE = 0 on entry, so a handler that calls into code expecting interrupts to be running (a sleep loop, a UART write that waits for TX-ready and assumes a timer interrupt will fire) deadlocks. If a handler genuinely needs interrupts during its work, it must save the trap context first, then explicitly csrsi mstatus, 8.

Misreading the cause field on the immediate-form CSR. mcause is interpreted as a signed integer to detect interrupts via the sign bit. Software that does csrr t0, mcause; andi t0, t0, 0xff and then dispatches without first checking the sign bit will route a machine-timer interrupt (cause = 0x80000007 on RV32) into the exception handler for cause 7 (store/AMO access fault), with hilarious consequences.

Conflating mtval semantics. mtval is populated only for certain causes (memory faults, illegal instructions, breakpoints with a non-zero hardware breakpoint configuration), and is zero or unspecified otherwise. Handlers that print mtval unconditionally for “diagnostics” will print stale or zero values for ecall and software-interrupt traps.

A historical confusion comes from older documents that reference N-extension user-mode trap CSRs (utvec, uepc, ucause, ustatus, uret). The N extension was a draft proposal for user-mode interrupt handling that was removed from the ratified privileged spec. Any tutorial that talks about uret or utvec as the modern trap mechanism is anachronistic; the only trap targets in the ratified spec are M-mode (via mtvec) and S-mode (via stvec, when traps are delegated).

Alternatives and When to Choose Them

x86 interrupt and exception handling uses an Interrupt Descriptor Table (IDT) of 256 8-byte descriptors at a base specified by the idt register, each entry pointing at a handler and specifying the privilege required to invoke it via INT, plus segmentation glue (gate descriptors specifying CS, DPL, type). Compared to RISC-V’s “two-bit MODE field on mtvec,” the x86 model is more flexible (per-vector privilege control built in) but substantially more state to set up.

ARM exception handling uses a vector table of fixed-offset handlers (one per exception class: synchronous, IRQ, FIQ, SError), each 0x80 bytes apart and itself a small piece of code (not just a pointer). On exception, hardware writes ELR (the return address), ESR (cause), and FAR (fault address), and changes the exception level (EL0 user → EL1 kernel → EL2 hypervisor → EL3 secure monitor). The structure is similar to RISC-V’s mepc / mcause / mtval but more layered (four exception levels vs three privilege modes), and the vector table contains code rather than addresses.

MIPS exception model uses a fixed exception vector (originally hardcoded to a specific virtual address; later EBase-relative) and a single EPC / Cause / BadVAddr triple analogous to mepc/mcause/mtval. Like RISC-V, MIPS unifies exceptions and interrupts into one dispatch path; unlike RISC-V, MIPS has branch-delay-slot complications that make the EPC interpretation more subtle.

For definitely-not-esp32 specifically, direct mode (mtvec.MODE = 0) is the right starting point: a single trap-entry stub that does the cause dispatch in software is easier to write and easier to debug than a vectored table. The latency penalty is one extra branch per interrupt, which on a small in-order RV32 is negligible compared to the cost of saving and restoring 32 registers.

Production Notes

The Linux kernel’s RISC-V port uses S-mode trap handling (since Linux runs in S-mode under SBI firmware in M-mode), with sscratch/stvec/sepc/scause/stval/sret as the working CSRs. The actual entry stub in arch/riscv/kernel/entry.S follows the same shape as the example above: csrrw tp, CSR_SCRATCH, tp swap, register saves into a pt_regs struct, sign-bit dispatch on scause, dispatch to do_irq for interrupts or excp_vect_table[cause] for exceptions (arch/riscv/kernel/entry.S, handle_exception). For exceptions, the dispatch ends in handlers like do_trap_ecall_u (for cause 8), do_trap_break (cause 3), or the page-fault entry.

OpenSBI does the equivalent for M-mode: its sbi_trap_handler (declared in include/sbi/sbi_trap.h) takes a context struct holding the saved register file plus mepc, mstatus, and the cause/tval. The handler services M-mode-only traps (illegal instruction emulation, misaligned load/store fixup, the supervisor-ecall path that implements SBI calls), then returns. The framework is sketched out by OpenSBI’s library-usage documentation: “sbi_trap_handler() function should be called by the external firmware or bootloader to service… Illegal instruction trap, Misaligned load trap, Misaligned store trap, Supervisor ecall trap, Hypervisor ecall trap” (OpenSBI library_usage.md).

Real cores often add small optimizations on top of the spec-mandated behavior. Some implement a small hardware-managed exception cache that pre-decodes the cause and jumps without software dispatch; some provide a “fast trap” mode that delivers a small subset of interrupts to a dedicated handler register without touching mepc/mcause. None of these are required for spec compliance; a clean v1.0 RV32IMC implementation (the definitely-not-esp32 target) is best served by the textbook direct-mode handler above.

A field-tested practical tip from kernel folklore: write a tiny panic trap handler that does nothing except infinite-loop with the cause code in a known register. This way, if the trap handler itself faults during early bootstrap (before mscratch is initialized, or before the kernel stack is mapped), the system at least halts at a known PC with a known cause, recoverable with JTAG. Without this, an early trap-during-trap-handling results in either an infinite reset loop or, worse, garbage execution.

See Also