RISC-V Trap 机制深度解析:从 CSR 到 mret 的完整流程
1. 为什么说 Trap 是 RISC-V 里最该先啃透的机制如果你刚开始接触 RISC-V大概率会先被它那套干净利落的指令集吸引——基础整数指令就几十条寄存器规规矩矩编码格式整整齐齐。但真正上手写裸机代码或者移植操作系统的时候第一个卡住你的往往不是指令本身而是trap 机制。异常怎么进来的、中断怎么处理的、系统调用怎么从用户态切到内核态、mret到底干了什么、那一堆 CSR 寄存器各自管什么——这些问题绕不开绕开了后面全是坑。我自己最早接触 RISC-V 是在一块国产开发板上跑裸机程序当时想当然地以为异常处理和 ARM 差不多结果第一次触发非法指令异常程序直接跑飞连个打印都没有。后来一点点啃特权手册才明白RISC-V 的 trap 设计思路和 ARM 完全不是一回事。它把陷入这件事抽象得非常统一不管是外部中断、指令异常、还是主动发起的系统调用统统走同一套入口逻辑硬件只负责把现场信息塞进几个固定的 CSR然后跳到mtvec或stvec指向的地址剩下的全交给软件。这套设计的核心价值在于极简和可预测。硬件不做太多花哨的事软件拿到的是一个确定的、可枚举的状态快照。你要做的就是搞清楚这个快照里有什么、怎么读、怎么恢复。听起来简单但细节非常多尤其是涉及特权级切换、中断使能嵌套、mstatus里那几个位的时候稍不留神就会踩坑。这篇文章我打算把 trap 机制从里到外拆一遍。不是照着手册念而是按照一个实际写代码的人会遇到的顺序来先搞清楚 trap 是什么、有哪些类型再把涉及的 CSR 一个个过一遍然后讲 trap 发生时的完整硬件流程接着是mret的返回逻辑最后用几个实际场景系统调用、缺页、中断嵌套把前面讲的东西串起来。中间会穿插我自己踩过的坑和一些手册上不会明说的经验。适合谁看如果你正在写 RISC-V 的裸机程序、移植 RTOS、或者做操作系统内核开发这篇内容应该能帮你省下不少翻手册的时间。如果你只是对 RISC-V 感兴趣想了解它的设计哲学前半部分也能让你对trap这个抽象有个清晰的认识。2. Trap 机制的整体设计与思路拆解2.1 什么是 trap为什么 RISC-V 要这样设计在 RISC-V 的语境里trap是一个统称指的是处理器从正常执行流中偏离出去转去执行一段特殊代码的所有情况。它包括三类异常Exception由当前正在执行的指令引发比如非法指令、地址对齐错误、缺页、断点等。异常是同步的和指令流严格对应。中断Interrupt由外部事件引发比如定时器到期、外部设备发来信号。中断是异步的和当前执行到哪条指令没有固定关系。系统调用System Call严格说它属于异常的一种通过ecall指令主动触发用于从低特权级请求高特权级的服务。RISC-V 把这三类东西统一到一套处理框架里硬件的行为高度一致保存现场信息到 CSR跳转到约定的入口地址切换特权级如果需要。这种统一性带来的好处是软件只需要写一套 trap 入口代码就能同时处理异常、中断和系统调用不用像某些架构那样为不同类型分别写入口。为什么这么设计我的理解是 RISC-V 从一开始就瞄准了从微控制器到高性能处理器的全场景覆盖。微控制器可能只需要最简单的异常处理而服务器芯片需要复杂的中断嵌套和虚拟化支持。统一框架加上可选的扩展让同一套基础机制能适应不同规模的需求。你不需要为小系统实现复杂的中断控制器逻辑也不需要为大系统重新设计异常入口。另一个关键设计是硬件只做最少的事。trap 发生时硬件只负责记录 trap 原因、记录 trap 发生时的 PC、保存必要的状态位、切换到目标特权级、跳到入口地址。至于保存通用寄存器、处理嵌套、区分中断优先级全部交给软件。这让硬件实现简单也让软件有最大的灵活性去决定怎么处理。2.2 特权级与 trap 的关系RISC-V 定义了三个标准特权级特权级编码典型用途M-modeMachine3最高权限固件、BootloaderS-modeSupervisor1操作系统内核U-modeUser0用户程序trap 的本质就是特权级的跃迁。当低特权级发生异常或中断时处理器会切换到更高的特权级去处理。具体来说U-mode 发生 trap会进入 S-mode如果配置了或 M-mode。S-mode 发生 trap会进入 M-mode。M-mode 发生 trap仍然在 M-mode 处理。这里有个关键点trap 只会往高特权级走不会往低走。从高特权级返回低特权级靠的是mret或sret指令。这个单向性保证了特权级隔离的基本安全模型——低权限代码无法通过 trap 机制直接获得更高权限必须经过高权限代码的检查和授权。还有一个容易被忽略的点trap 的目标特权级由medeleg和mideleg两个 CSR 控制。默认情况下所有 trap 都进 M-mode。如果你想让某些异常或中断直接在 S-mode 处理比如操作系统的缺页异常需要在 M-mode 初始化时设置medeleg把对应的异常委托给 S-mode。这个委托机制是 RISC-V 支持 Type-1 Hypervisor 和高效操作系统的基础。2.3 trap 涉及的核心 CSR 一览trap 机制涉及的 CSR 不少我按功能分组列一下后面会逐个展开M-mode 相关mtvectrap 入口地址mepctrap 发生时的 PCmcausetrap 原因mtvaltrap 附加信息mstatus全局状态包含中断使能、特权级等mie/mip中断使能和挂起medeleg/mideleg异常/中断委托mscratch临时寄存器通常用来保存上下文指针S-mode 相关stvec、sepc、scause、stval、sstatus、sie/sip、sscratch这些 CSR 的命名规律很清晰M-mode 的以m开头S-mode 的以s开头功能一一对应。理解了 M-mode 的那套S-mode 的基本就是换个前缀。注意mstatus和sstatus是同一个物理寄存器的不同视图。sstatus只暴露了 S-mode 能看到的那些位M-mode 看到的mstatus包含全部位。这个设计在调试的时候容易搞混读sstatus看不到 M-mode 的位是正常的。3. 核心 CSR 逐个拆解与实操要点3.1 mtvectrap 入口地址怎么设mtvec是 trap 发生时硬件跳转的目标地址。它的低两位有特殊含义bit[1:0] 00Direct 模式。所有 trap 都跳到BASE地址。bit[1:0] 01Vectored 模式。中断跳到BASE 4 × cause异常仍然跳到BASE。bit[1:0] 10/11保留。Direct 模式最简单入口只有一个软件在入口处读mcause判断类型再分发。Vectored 模式省去了软件判断中断类型的一步硬件直接算出偏移。但注意Vectored 模式只对中断生效异常还是走 BASE。而且BASE必须 4 字节对齐Vectored 模式下每个中断入口只有 4 字节空间通常放一条跳转指令。实际用哪个裸机程序我一般用 Direct因为中断源不多统一入口好管理。操作系统内核通常也用 Direct因为需要在入口处保存完整上下文Vectored 的 4 字节空间根本不够。Vectored 更适合那些中断处理非常轻量、追求极致延迟的场景。设置mtvec的代码大概长这样// Direct 模式入口地址 4 字节对齐 uintptr_t trap_entry (uintptr_t)trap_handler; csr_write(mtvec, (trap_entry ~0x3) | 0x0); // Vectored 模式 csr_write(mtvec, (trap_entry ~0x3) | 0x1);实操心得mtvec的 BASE 地址必须 4 字节对齐如果你用 Vectored 模式实际上要求更严格因为要保证BASE 4 × cause不会跨到错误的页。我一般直接按 64 字节对齐省得算。3.2 mepctrap 发生时 PC 存到哪mepc保存的是 trap 发生时的程序计数器。但这里有个细节保存的 PC 值取决于 trap 类型。对于异常mepc保存的是引发异常的指令的地址。也就是说如果你不修改mepc就mret会重新执行那条指令再次触发异常死循环。对于中断mepc保存的是被中断的那条指令的地址。mret后会重新执行这条指令。因为中断是异步的这条指令可能还没执行完重新执行是安全的。这个区别非常关键。处理异常时你通常需要根据情况决定是修复问题后重试比如缺页异常加载页表后重新执行还是跳过这条指令比如非法指令模拟需要把mepc加上指令长度还是直接终止进程。mepc的另一个细节是它的低位。RISC-V 指令至少 2 字节对齐所以mepc的 bit[0] 始终为 0。如果支持压缩指令C 扩展bit[1] 也可能为 0。mret的时候硬件会忽略这些低位但软件在修改mepc时要注意保持对齐。// 读取 trap 时的 PC uintptr_t epc csr_read(mepc); // 如果是非法指令异常跳过当前指令假设 4 字节指令 csr_write(mepc, epc 4);3.3 mcausetrap 原因怎么读mcause是 trap 处理里最常读的寄存器。它的编码方式很讲究最高位XLEN-11 表示中断0 表示异常。低位具体的 cause 编号。M-mode 常见的 cause 编号cause类型含义0异常指令地址非对齐1异常指令访问错误2异常非法指令3异常断点4异常加载地址非对齐5异常加载访问错误6异常存储地址非对齐7异常存储访问错误8异常用户态 ecall9异常超级用户态 ecall11异常机器态 ecall12异常指令缺页13异常加载缺页15异常存储缺页3中断机器态软件中断7中断机器态定时器中断11中断机器态外部中断注意异常和中断的编号是独立的空间靠最高位区分。所以 cause 3 既可能是断点异常也可能是机器态软件中断读的时候必须先看最高位。uintptr_t cause csr_read(mcause); int is_interrupt (cause (XLEN - 1)) 1; uintptr_t code cause ~(1UL (XLEN - 1)); if (is_interrupt) { // 处理中断 } else { // 处理异常 }踩过的坑早期我写代码时直接用mcause的值去 switch忘了区分最高位结果断点异常和软件中断混在一起调试了半天。一定要先判断最高位。3.4 mtvaltrap 附加信息有什么用mtval提供 trap 的附加信息具体内容取决于 trap 类型非法指令保存指令的编码。地址非对齐/访问错误/缺页保存出错的地址。断点保存断点指令的地址某些实现可能为 0。其他异常通常为 0。这个寄存器在调试时非常有用。比如缺页异常mtval直接告诉你访问的是哪个地址不用去反汇编指令算地址。非法指令异常mtval给你指令编码可以判断是不支持的扩展还是真的非法。但要注意mtval是可选实现的。有些精简实现可能不写这个寄存器读出来永远是 0。如果你的代码依赖mtval最好先确认硬件支持。3.5 mstatus全局状态里哪些位和 trap 相关mstatus是个大杂烩和 trap 相关的位主要有这几个MIEbit 3M-mode 全局中断使能。SIEbit 1S-mode 全局中断使能。MPIEbit 7M-mode 先前中断使能trap 时保存 MIE 的值。SPIEbit 5S-mode 先前中断使能。MPPbit[12:11]M-mode 先前特权级trap 时保存当前特权级。SPPbit 8S-mode 先前特权级。trap 发生时硬件自动做这几件事MPIE←MIE然后MIE← 0进入 trap 后默认关中断。MPP← 当前特权级。如果切换到 S-modeSPIE←SIESIE← 0SPP← 当前特权级。mret时反过来MIE←MPIEMPIE← 1。特权级 ←MPPMPP← U-mode或保持取决于实现。这个自动保存/恢复机制保证了 trap 处理期间中断默认关闭避免嵌套 trap 把状态搞乱。如果你需要在 trap 处理中允许嵌套中断得手动把MIE置 1。// 在 trap 处理中允许嵌套中断 csr_set(mstatus, MSTATUS_MIE); // 处理完再关掉 csr_clear(mstatus, MSTATUS_MIE);注意MPP在mret后会被设置为 U-mode如果实现了 U-mode这是规范要求的。如果你在 M-mode 处理完 trap 后想回到 M-mode必须在mret前手动把MPP设回 M-mode否则会掉到 U-mode 去。3.6 mie / mip中断使能和挂起mie控制哪些中断源被使能mip反映哪些中断正在挂起。两者的位定义一一对应位中断类型3M-mode 软件中断7M-mode 定时器中断11M-mode 外部中断1S-mode 软件中断5S-mode 定时器中断9S-mode 外部中断一个中断要真正被处理需要满足mip对应位置 1挂起、mie对应位置 1使能、mstatus.MIE为 1全局使能。三者缺一不可。mip的位有的是只读的由硬件置位有的可以软件写来清除。比如软件中断你可以写mip来触发或清除。定时器和外部中断通常由硬件控制。// 使能 M-mode 定时器中断 csr_set(mie, MIE_MTIE); // 使能全局中断 csr_set(mstatus, MSTATUS_MIE); // 检查是否有定时器中断挂起 if (csr_read(mip) MIP_MTIP) { // 处理定时器中断 }3.7 medeleg / mideleg把 trap 委托给 S-mode这两个寄存器控制哪些异常和中断直接在 S-mode 处理而不是进 M-mode。默认全 0所有 trap 都进 M-mode。对于运行操作系统的场景通常需要把以下异常委托给 S-mode指令/加载/存储缺页cause 12/13/15用户态 ecallcause 8非法指令cause 2可选断点cause 3可选中断方面通常把 S-mode 定时器和外部中断委托给 S-modeM-mode 只保留自己需要的中断。// 委托缺页异常和用户态 ecall 给 S-mode csr_set(medeleg, (1 12) | (1 13) | (1 15) | (1 8)); // 委托 S-mode 定时器中断 csr_set(mideleg, (1 5));委托之后这些 trap 发生时硬件直接进 S-mode用stvec、sepc、scause那一套。M-mode 完全不参与效率更高。实操心得委托配置一定要在系统初始化早期做好而且要考虑清楚哪些 trap 必须留在 M-mode。比如 M-mode 的定时器中断通常不能委托因为 S-mode 可能没有权限操作 M-mode 的定时器。委托错了会导致 trap 进到错误的特权级行为完全不可预测。4. Trap 发生时的完整硬件流程4.1 从触发到跳转硬件做了什么把前面散落的知识点串起来一次 trap 的完整硬件流程是这样的检测到 trap 条件。可能是当前指令执行出错异常也可能是外部信号到达且满足使能条件中断。确定目标特权级。根据 trap 类型查medeleg/mideleg决定进 M-mode 还是 S-mode。保存现场mepc← 当前 PC异常保存出错指令地址中断保存被中断指令地址。mcause← trap 原因编码。mtval← 附加信息如果实现。mstatus.MPIE←mstatus.MIEmstatus.MIE← 0。mstatus.MPP← 当前特权级。切换特权级到目标模式。跳转到入口PC ←mtvecDirect或mtvec.BASE 4 × causeVectored仅中断。整个过程是原子的硬件保证不会在中途被另一个 trap 打断除非目标模式允许嵌套。这里有个容易忽略的点trap 发生时通用寄存器不会被硬件保存。也就是说进入 trap 入口时你手里的寄存器值还是被打断那一刻的值。如果你在入口处直接调用 C 函数编译器可能会覆盖这些寄存器导致无法恢复。所以 trap 入口通常是一段汇编先把所有通用寄存器压栈再调用 C 处理函数。4.2 入口汇编怎么写一个典型的 trap 入口汇编大概长这样trap_entry: # 交换 sp 和 mscratch拿到上下文指针 csrrw sp, mscratch, sp # 如果 sp 为 0说明来自 M-mode需要特殊处理 bnez sp, 1f csrrw sp, mscratch, sp 1: # 保存通用寄存器 addi sp, sp, -256 sd x1, 0(sp) sd x3, 8(sp) # ... 保存 x4-x31 # 保存 CSR csrr t0, mepc sd t0, 240(sp) # 调用 C 处理函数 call trap_handler # 恢复 CSR ld t0, 240(sp) csrw mepc, t0 # 恢复通用寄存器 ld x1, 0(sp) # ... 恢复 x3-x31 addi sp, sp, 256 # 交换回来 csrrw sp, mscratch, sp mret这里用mscratch做上下文指针的交换是个经典技巧。正常运行时mscratch保存 trap 栈的指针sp是当前栈。trap 发生时csrrw sp, mscratch, sp一条指令就把sp换成 trap 栈同时把原来的sp存到mscratch。这样入口代码不需要先找地方保存sp。踩过的坑mscratch的交换逻辑在嵌套 trap 时会出问题。如果 trap 处理中又发生 trap第二次交换会把mscratch里的值搞乱。解决办法是在 trap 处理中禁用嵌套或者用更复杂的上下文管理。我一般先在入口关中断处理完再根据情况开。4.3 上下文结构怎么设计上下文结构决定了你保存和恢复寄存器的效率。一个常见的布局struct trap_context { uint64_t x1; // ra uint64_t x2; // sp uint64_t x3; // gp uint64_t x4; // tp uint64_t x5; // t0 // ... x6-x31 uint64_t mepc; uint64_t mstatus; };保存顺序和恢复顺序必须严格对应否则会恢复出错误的寄存器值。我习惯在汇编里用宏来生成保存/恢复代码避免手写出错。上下文的存放位置也有讲究。如果每个任务有自己的内核栈上下文就存在各自的内核栈上。如果是单栈模型就存在全局的 trap 栈上。操作系统通常用前者裸机程序用后者更简单。5. mret 的返回逻辑与特权级切换5.1 mret 到底做了什么mret是 trap 处理的最后一步它的行为可以概括为特权级切换当前特权级 ←mstatus.MPP。中断使能恢复mstatus.MIE←mstatus.MPIEmstatus.MPIE← 1。特权级状态更新mstatus.MPP← U-mode如果实现了 U-mode否则保持。跳转PC ←mepc。注意第 3 步MPP被重置为 U-mode 是规范要求的。这意味着如果你在 M-mode 处理 trap处理完想回到 M-mode必须在mret前手动把MPP设回 M-mode。否则mret后会掉到 U-mode而 U-mode 可能根本没有有效的代码直接跑飞。// 从 M-mode trap 返回 M-mode csr_clear(mstatus, MSTATUS_MPP); // 先清 csr_set(mstatus, MSTATUS_MPP); // 再设成 M-mode编码 3 // 或者直接写 uintptr_t ms csr_read(mstatus); ms (ms ~MSTATUS_MPP_MASK) | MSTATUS_MPP_M; csr_write(mstatus, ms);5.2 返回地址的修正mepc里的值不一定能直接用来返回。前面说过异常保存的是出错指令的地址中断保存的是被中断指令的地址。返回前需要根据 trap 类型决定是否修正中断直接返回重新执行被中断的指令。系统调用ecallmepc指向ecall指令返回前必须加指令长度否则会再次执行ecall死循环。缺页异常修复页表后直接返回重新执行出错指令。非法指令如果模拟了指令需要加指令长度跳过如果无法处理终止进程。断点通常加指令长度跳过或者交给调试器处理。指令长度怎么判断如果支持压缩指令需要读指令编码的低两位11表示 4 字节其他表示 2 字节。不支持压缩指令的话统一加 4。// 判断指令长度 uintptr_t epc csr_read(mepc); uint16_t inst_lo *(uint16_t *)epc; int inst_len ((inst_lo 0x3) 0x3) ? 4 : 2; csr_write(mepc, epc inst_len);实操心得ecall的返回地址修正特别容易忘。我第一次写系统调用处理时mret后程序又执行了一遍ecall陷入死循环查了好久才发现是mepc没加 4。这个坑几乎每个写 RISC-V 内核的人都会踩一次。5.3 sret 和 mret 的区别sret是 S-mode 的返回指令逻辑和mret类似但操作的是 S-mode 的 CSR特权级 ←sstatus.SPP。sstatus.SIE←sstatus.SPIEsstatus.SPIE← 1。sstatus.SPP← U-mode。PC ←sepc。关键区别sret只能返回到比 S-mode 低或相等的特权级。如果sstatus.SPP是 M-modesret的行为是未定义的。所以 S-mode 的 trap 处理只能返回到 S-mode 或 U-mode不能返回到 M-mode。这个限制保证了特权级隔离S-mode 代码无法通过sret提升到 M-mode。M-mode 的代码只能通过mret返回而mret的目标特权级由mstatus.MPP控制M-mode 软件可以完全掌控。6. 实际场景中的 trap 处理6.1 系统调用从 U-mode 到 S-mode 的完整流程系统调用是 trap 机制最典型的应用。以 RISC-V 的ecall为例完整流程如下U-mode 程序把系统调用号放到a7参数放到a0-a6执行ecall。硬件检测到ecall异常cause 8查medeleg发现委托给 S-mode。硬件保存sepc←ecall指令地址scause← 8sstatus.SPP← U-modesstatus.SIE← 0。跳转到stvec指向的入口。入口汇编保存上下文调用 C 处理函数。C 函数根据a7分发系统调用执行相应服务返回值放到a0。返回前修正sepc加 4跳过ecall。sret返回 U-mode继续执行ecall后面的指令。void syscall_handler(struct trap_context *ctx) { uintptr_t syscall_num ctx-a7; uintptr_t arg0 ctx-a0; // ... switch (syscall_num) { case SYS_WRITE: ctx-a0 do_write(arg0, ...); break; // ... } ctx-sepc 4; // 跳过 ecall }这里有个细节系统调用的返回值通过修改上下文里的a0来传递sret恢复上下文后U-mode 看到的a0就是返回值。这个机制很优雅不需要额外的寄存器。6.2 缺页异常按需分页的实现基础缺页异常cause 12/13/15是虚拟内存的核心。当程序访问一个尚未映射的页面时硬件触发缺页异常操作系统负责加载页面并建立映射然后重新执行出错指令。处理流程读stval拿到出错的虚拟地址。读scause判断是哪种缺页指令/加载/存储。检查地址是否在合法范围内不合法则终止进程。分配物理页从磁盘加载数据或清零。更新页表建立映射。刷新 TLB如果需要。sret返回重新执行出错指令。void page_fault_handler(struct trap_context *ctx) { uintptr_t fault_addr csr_read(stval); uintptr_t cause csr_read(scause) 0xFF; if (!is_valid_address(fault_addr)) { kill_process(); return; } void *page alloc_page(); load_page_from_disk(fault_addr, page); map_page(current_pagetable, fault_addr, page); // 不需要修改 sepc直接返回重新执行 }缺页异常的关键是不需要修改sepc。因为出错指令还没有执行成功返回后重新执行是正确的。这和系统调用需要加 4 形成鲜明对比。注意缺页异常处理中如果再次发生缺页比如页表本身不在内存里会导致嵌套缺页。操作系统通常用锁或者预留页表空间来避免这种情况。我见过有人在这里死锁就是因为处理缺页时又触发了缺页。6.3 中断嵌套什么时候该允许怎么实现默认情况下trap 发生时MIE被清零中断被禁用。这意味着 trap 处理期间不会响应新的中断。对于简单的裸机程序这足够了。但对于需要低延迟响应的系统可能需要允许中断嵌套。允许嵌套的步骤在 trap 入口保存上下文后手动置MIE为 1。处理中断。返回前恢复MIE为 0或者依赖mret自动恢复。但嵌套会带来几个问题栈溢出每次嵌套都要保存上下文栈消耗成倍增加。优先级反转低优先级中断可能打断高优先级中断的处理。重入问题处理函数必须是可重入的。我的建议是除非有明确的低延迟需求否则不要开嵌套。大多数场景下关中断处理完再开简单可靠。如果确实需要嵌套一定要给每个中断源设优先级并且在入口处判断当前优先级只允许更高优先级的中断打断。// 带优先级的中断嵌套 void trap_handler(struct trap_context *ctx) { int irq get_irq_number(ctx); int prio get_irq_priority(irq); if (prio current_priority) { // 允许嵌套 csr_set(mstatus, MSTATUS_MIE); current_priority prio; handle_irq(irq); current_priority old_priority; csr_clear(mstatus, MSTATUS_MIE); } else { // 不处理等当前中断完成 return; } }7. 常见问题与排查技巧实录7.1 trap 相关问题的排查思路trap 出问题通常表现为程序跑飞、死循环、打印乱码、或者干脆没反应。排查的时候我一般按这个顺序来第一步确认 trap 入口是否被正确设置。读mtvec/stvec确认地址和模式正确。如果入口地址是 0 或者未对齐trap 发生时直接跳到非法地址。第二步确认 trap 是否真的发生了。在入口处加一个最简单的打印比如直接写 UART 寄存器看是否被执行。如果没有可能是 trap 没触发或者触发了但跳到了错误的地方。第三步读mcause/scause确认 trap 类型。如果 cause 是意料之外的值说明触发的不是你以为的那个 trap。第四步读mepc/sepc确认 trap 位置。反汇编那个地址附近的代码看看是什么指令触发的。第五步检查mstatus的MPP/MIE等位。特权级和中断使能配置错误是常见原因。7.2 常见问题速查表现象可能原因排查方法trap 后程序跑飞mtvec未设置或地址错误读mtvec确认死循环重复触发mepc未修正ecall/非法指令检查返回前是否加指令长度中断不响应mie或mstatus.MIE未使能读mie和mstatus中断响应一次后不再响应中断标志未清除检查外设的中断清除逻辑特权级切换错误MPP设置错误读mstatus.MPP嵌套 trap 状态混乱未关中断或上下文管理错误检查入口是否关中断mtval读出来是 0硬件未实现mtval查手册确认S-mode trap 进了 M-modemedeleg未设置读medeleg确认委托位7.3 几个容易忽略的细节细节一mstatus.MPP在mret后变成 U-mode。前面强调过但真的很容易忘。如果你在 M-mode 处理 trap返回前一定要把MPP设回 M-mode。细节二ecall的返回地址。ecall是异常mepc指向ecall本身返回前必须加 4或指令长度。忘了就会死循环。细节三中断的mepc不需要修正。中断是异步的mepc指向被中断的指令返回后重新执行是正确的。不要手贱去加 4。细节四mscratch在嵌套时的行为。如果 trap 处理中又发生 trapmscratch的交换逻辑会出错。要么关中断要么用更复杂的上下文管理。细节五CSR 读写的副作用。有些 CSR 读的时候会清除状态比如某些中断挂起位写的时候有特殊语义。读手册的时候一定要看清楚。我个人在实际操作中的体会是trap 机制的学习曲线前期比较陡因为涉及的 CSR 多、细节多、容易混淆。但一旦把 M-mode 的那套搞明白S-mode 的基本就是换个前缀理解成本大幅下降。建议初学的时候先在一个最简单的裸机环境里把 trap 跑通打印出mcause、mepc、mtval的值亲眼看到 trap 发生时的状态比看十遍手册都管用。最后再分享一个小技巧调试 trap 的时候可以在入口处把关键 CSR 的值存到一个全局数组里程序跑飞后通过调试器读出来。这比在 trap 里打印安全得多因为 trap 处理中打印可能触发新的 trap比如 UART 中断把问题搞复杂。