A practical walkthrough of machine-mode interrupt and exception handling on WCH CH32V RISC-V MCUs: the CSR pipeline (mtvec, mcause, mepc, mstatus), the attribute declarations the WCH toolchain expects, and five classic pitfalls when coming from ARM.
Coming to WCH's CH32V family from an ARM Cortex-M background, the biggest surprise is usually not the core itself but how interrupts are wired. Two recent community posts on 21ic (mid-September 2026) revisited exactly this topic: one dissects the machine-mode CSR pipeline, the other collects the C-level attribute declarations the WCH toolchain expects. This article merges both into one practical reference for CH32V developers.
The CSR pipeline: mtvec, mcause, mepc, mstatus
RISC-V machine mode routes every trap through a small set of CSRs:
- mtvec holds the trap base address. In direct mode (low two bits = 0) all traps jump to the base; in vectored mode (low two bits = 1) asynchronous interrupts jump to base + 4 x cause, which is how the CH32V startup files lay out their vector tables.
- mcause registers what happened. The most significant bit distinguishes an interrupt (1) from a synchronous exception (0); the remaining bits carry the code. On CH32V-class cores you will constantly see machine external interrupts (code 11), machine timer (7) and machine software (3); on the exception side, illegal instruction (2) is the classic one to hit while porting code.
- mepc stores the trapping PC. On an interrupt it already points at the next instruction to execute, so a plain mret resumes correctly. For synchronous exceptions that emulate a skip, software must add 4 itself.
- mstatus stacks MIE into MPIE on entry and restores it on mret, which is what makes the global interrupt enable bit behave like a small stack rather than a flag.
The full entry/exit flow is therefore: hardware saves the PC to mepc, writes mcause, moves MIE to MPIE and clears MIE, then jumps through mtvec. Your handler saves caller-saved registers, reads mcause, dispatches, and returns with mret - which restores MIE from MPIE and resumes at mepc.
C declarations the WCH toolchain expects
On the WCH toolchain, an interrupt service routine must be declared with the interrupt attribute, and the function name must match the vector table symbol defined in the startup file:
void SysTick_Handler(void) __attribute__((interrupt));
void NMI_Handler(void) __attribute__((interrupt("WCH-Interrupt-fast")));
void HardFault_Handler(void) __attribute__((interrupt("WCH-Interrupt-fast")));
The plain interrupt attribute makes the compiler emit a standard prologue and epilogue that preserve the context. The WCH-Interrupt-fast variant engages the hardware prologue/epilogue (HPE) feature of QingKe V4-class cores: the hardware pushes the full register set, so the C body can be written like an ordinary function and latency drops noticeably. Use it where it matters (hard faults, NMI, tight timer ticks) and keep the plain form elsewhere.
The vector-table name matching is the classic silent failure: if you misspell SysTick_Handler, compilation and linking still succeed because a weak default handler takes its place, and your board simply never ticks. When an interrupt appears dead on a CH32V board, check the spelling first, the PFIC enable bit second.
Five pitfalls collected from the field
The practice-oriented post lists five traps that keep biting people who port from ARM:
- Missing interrupt attribute. The code compiles, the vector table links, but no context is saved - the first interrupt corrupts the run. Always declare ISRs with the attribute.
- Non-atomic read-modify-write on enable bits. Enabling an interrupt through a load-modify-store races against an interrupt already in flight. Use the single-instruction CSR operations the ISA provides.
- Inline assembly constraints mismatched with C types. When accessing CSRs through inline assembly, the register constraint must match the width of the C variable; on RV32 a mismatched constraint silently truncates the upper bits, which produces bugs that only show up far away from the source.
- Global interrupt toggling via read-then-write. Clearing and setting MIE should be a single csrs/csrc-style instruction on mstatus (MIE is bit 3), not a read-modify-write sequence that can itself be interrupted:
/
disable global interrupts - one instruction, atomic /
asm volatile("csrc mstatus, 8");
/
critical section /
asm volatile("csrs mstatus, 8");
- Carrying ARM assumptions into assembly. The RISC-V calling convention differs from ARM's: arguments arrive in a0-a7, return values in a0/a1, ra (x1) and sp (x2) have fixed roles. Any hand-written assembly ported from ARM must have its register usage and stacking order re-checked.
A short bring-up checklist
- Vector table symbols in the startup file match your handler names exactly.
- Every ISR carries either interrupt or WCH-Interrupt-fast.
- Enable bits are set/cleared with single CSR instructions, never RMW.
- Inline assembly constraints are double-checked against variable widths on RV32.
- First interrupt test is a software-triggered one (PFIC software interrupt or MSIP), then the real peripheral.
Everything above applies across the CH32V range - from the ten-cent CH32V003 entry part, through the BLE-capable CH32V208 and the Ethernet-capable CH32V307/CH32V317, up to the dual-core CH32H417 - since the QingKe core interface stays consistent across the family. If you are evaluating WCH parts for a design, our CH32V catalog on open-riscv.com lists the current boards and SDK packages.
Sources:
- RISC-V interrupt and exception handling: mcause, mtvec, mepc walkthrough (21ic, Sep 18, 2026): https://bbs.21ic.com/icview-3534454-1-1.html
- C language practice for WCH CH32: interrupt and attribute declarations (21ic, Sep 19, 2026): https://bbs.21ic.com/icview-3534621-1-1.html