RISC-V trap机制深度解析:从异常入口到CSR现场恢复

发布时间:2026/10/30 3:56:20
RISC-V trap机制深度解析:从异常入口到CSR现场恢复

RISC-V对外宣传的卖点一直是“极简”,指令少、模块化、可裁剪。但有一个机制,它非但不“简”,还牵扯到流水线、特权级、CSR、异常路由、软硬件协同,几乎把处理器的重点工作全占了——这就是trap机制。这个risc-v专栏写到第四篇,我终于把这块最难啃的骨头拿出来了,因为如果只能挑一个机制讲透,我一定选trap。它能解决什么问题?处理器怎么知道当前指令出了错、外部设备来了中断、用户程序要调内核服务?这些全部靠trap机制。它适合谁看?想做risc-v CPU设计的人、在写bootrom或RTOS的人、以及被异常处理流程绕晕的系统程序员。

1. 为什么说trap是整个RISC-V设计里最“硬”的一环

1.1 “能省则省”的指令集,凭什么不能省trap

RISC-V的指令集设计哲学,我总结成一句话:硬件负责最小化的原子动作,剩下的交给软件。没有条件码寄存器、没有专门的乘法除法指令(可选扩展)、没有复杂的寻址模式、没有指令前缀,连指令编码都刻意保持固定长度对齐,目的就是让取指、译码、写回这些流水线阶段尽量简单。

但trap是唯一的例外,它是硬件无论如何都不能“甩锅”给软件的部分。为什么?因为trap本质上是一次强制性的控制流转向,它发生在当前指令流的“正常轨道”之外。指令流水线正在按顺序取指、执行,突然冒出来一个外部中断,或者一条访存指令访问了没有权限的地址,这时候处理器必须停下当前一切,跳到一段特殊代码去处理。这个过程如果硬件不做,软件根本插不上手——软件连异常发生了都不知道,更别提去维护现场。

所以你看RISC-V设计者其实把“极简”玩得很微妙:指令集本身极简,但对trap的规定极其严密。从mtvec跳转地址的对齐要求,到mepc精确保存哪条指令的PC,到mstatus里MPP/MPIE的切换顺序,每一笔都有严格定义。硬件必须做的这部分,一点折扣都不能打。

1.2 “trap”不是一个概念,而是三类事件

Trap是异常和中断的总称,但这两类事件的处理逻辑有本质区别。我建议读这篇文章之前,先把三类来源分清楚:

  • 同步异常(synchronous exception):由正在执行的指令本身引起,比如非法指令、断点、地址错位、页错误、ecall系统调用。它的特点是“可预测”,同一个程序在相同输入下总会触发,而且必须精确绑定到触发它的那条指令。
  • 异步中断(asynchronous interrupt):由处理器外部或定时器引起,与当前执行指令无直接关系,比如UART收到数据、定时器溢出、外部按键。它的特点是“随机”,可能在任意指令边界到达。
  • 系统调用(ecall):严格来说也是一种同步异常,但它的触发是“故意”的。用户程序想访问硬件资源,必须通过ecall主动陷入更高特权级,请求内核服务。

这三类事件在RISC-V里都走同一条硬件路径:记录原因、保存断点、跳到trap入口。但同一条路径上,硬件对它们的处理细节不一样。比如中断的mepc应该是“被打断的正常下一条指令”,而同步异常的mepc应该是“触发异常的那条指令本身”。这个区别后面我会专门展开。

1.3 没有trap的机器,连printf都喊不动

可能有人觉得trap是CPU设计里很偏门的东西,“我写应用程序一辈子也用不到”。但在我的理解里,trap恰恰是计算机系统从“单片机”变成“通用计算设备”的分水岭。你想想看,printf最终要调用write系统调用,write靠ecall陷入内核,内核在S或M模式下完成驱动操作,再靠mret/sret返回用户态。这一进一出,全部依赖trap机制。

进程调度就更直接了。操作系统凭什么抢占当前进程?靠定时器中断。定时器到期后,硬件强制把CPU从用户态切换到内核态,保存当前进程的上下文,然后由内核决定换哪个进程上去跑。没有定时器中断这种异步trap,任何抢占式调度都是空谈,系统只能靠进程自己乖乖让出CPU(协作式调度),那样的操作系统在现代计算机上根本跑不起来。

所以我对“trap机制最核心”这句话的理解是:指令集定义了CPU能干什么,而trap定义了CPU如何从错误和外界请求中恢复,它决定了这个CPU能不能被一个成熟操作系统接管。做CPU设计,指令集语法是容易的部分,trap路径才是验证复杂度的重头戏。

2. 硬件要记的账:trap现场到底存在哪几个寄存器里

2.1 核心CSR一栏:mtvec、mepc、mcause、mtval、mstatus

RISC-V用一组控制状态寄存器(CSR)来记录trap发生时的现场,而不是像x86那样把现场压到栈上或写进内存。这个设计选择很关键:处理器进入trap时,栈指针sp可能本身就是不可用的,甚至用户程序里sp的值可能是非法的,硬件必须先有一套“不需要依赖内存也能记录现场”的机制。最基本的五个寄存器如下:

CSR作用关键规则
mtvectrap入口地址保存处理函数的基地址,要求至少4字节对齐(或2字节,若支持C扩展)
mepc断点PC记录发生trap时应当返回到的指令地址
mcause异常原因最高位表示中断/异常,低位12位为异常编号
mtval辅助信息记录出错虚拟地址、非法指令编码等补充信息
mstatus状态开关保存中断使能状态、trap前特权级等

另外还有一个重要的治理算子——mie(机器模式中断使能寄存器),它和mstatus.MIE配合使用。mstatus.MIE是全局总开关,mie[11:0]则是每中断源的独立开关,两者用“与”逻辑共同决定某个中断是否被响应。举个例子,定时器中断在mcause里编号是7,但要真正触发它,必须同时满足mstatus.MIE=1和mie[7]=1。这个双保险设计避免了一种尴尬情况:全局中断开启了,但某类中断还没准备好,软件还没初始化对应的中断源。

2.2 mstatus里那两个要命的位:MPP和MPIE

mstatus是个大寄存器,trap相关的核心逻辑集中在三个位上:MIE(机器模式全局中断开关)、MPIE(进入trap之前的中断开关状态)、MPP(进入trap之前的特权级)。这三个位在trap发生时协同工作,规则如下:

  1. MPIE = MIE:把trap之前的中断开关状态存起来,方便返回时恢复。
  2. MIE = 0:关闭中断。这样处理器进入trap handler后,默认不再响应新的中断,防止现场还没保存就被下一次trap打断。
  3. MPP = 当前特权级:记录处理器在哪个特权级被打断的,mret时用这个值恢复特权级。

MPP编码很简单:0表示User模式(U),1表示Supervisor模式(S),3表示Machine模式(M),2是保留值。我们做启动代码的时候,mret之前如果忘了设置MPP,返回后特权级就不对,后续访问S模式CSR可能会非法指令异常,这是最常见的低级错误之一。

2.3 S模式对称“账本”:SEC模式下的同款组合

S模式(Supervisor)有和M模式完全对称的一套trap状态寄存器,名称上把m换成s即可:sepc、scause、stval、sstatus、stvec、sie、sscratch。命名看着有点重复,但它服务的是同一套机制在不同特权级下的视角。谁在实际使用它们?操作系统内核。因为现代OS跑在S模式,用户进程跑在U模式,真正频繁进出trap的是S模式内核,而不是M模式固件。

这套对称设计简化了M模式固件的职责。固件在启动时,只要通过一条csrw sie和csrw stvec就能把中断管理权下放给S模式内核,不需要自己做任何转发逻辑。后面要讲的delegation(委托)机制,就是以这套S模式CSR为载体的。所以你在读spec的时候,经常看到“M模式xxx,S模式xxx”成对出现,不要觉得啰嗦,这是特权级对称美的体现。

3. 从异常发生到mtvec入口:一条trap的完整硬件路径

3.1 精确异常:RISCV对流水线设计最严苛的约束

我见过不少第一次做risc-v CPU的人,以为trap机制是“写写CSR就行”,结果一上FPGA验证就崩溃——因为trap路径和流水线的交互远比想象中复杂。RISCV规范有一条非常重要的要求:同步异常必须是“精确异常”(precise exception),意思是异常报告时,处理器整体状态必须精确对应到触发异常的那一条指令。

什么叫“精确对应”?假设流水线里已经执行到第3条指令,第1条指令的load发生了页错误。规范要求,异常报告的瞬间,后续指令(第2、3条)不能产生任何永久性副作用。也就是说,第2、3条指令即使已经在流水线里执行了,它们写入寄存器的结果也必须被作废,存储器写也不能保留,整个处理器要回到仿佛只执行到第1条指令的状态。

这在顺序流水线里好办,异常发生的那一拍直接把后续年轻指令全部冲刷掉就行。但在乱序执行流水线里就是大工程——那些比异常指令更年轻的指令可能已经提交了,你得靠重排序缓冲(ROB)回滚,或者在提交阶段才报告异常,且必须保证按程序顺序提交。这是risc-v所谓“极简指令集”并不“简单实现”的最典型例子。设计CPU的同学一定要提前意识到:异常处理逻辑必须参与到流水线的每个阶段,而不是挂在旁边当配线。

3.2 mepc到底指向哪条指令:中断和异常不一样

这是新手最容易犯错的地方。对于同步异常,mepc保存的是触发异常的那条指令的PC,也就是说,执行完mret后,处理器会重新执行这条指令。比如一条非法指令在地址0x1000触发异常,mepc就等于0x1000,trap handler处理完后mret,CPU再次取0x1000这条指令——如果软件没有修正它,就会再次陷入同样的异常,死循环。

对于异步中断,mepc保存的是被打断指令的下一条指令的PC。假设处理器正在执行0x2000处的指令,中断来了,mepc等于0x2004(假设指令长4字节),mret后从下一条继续。这符合“中断是随机插入、不破坏原程序流”的直觉。

这里有个历史性的对比:在MIPS里,异常返回地址需要软件根据情况减4调整;而在x86里,某些异常保存的返回地址又和“下一条指令”不同。RISC-V直接规定好mepc的含义,不搞延迟槽那一套(标准risc-v没有分支延迟槽),软件不需要做任何加减修正。这个设计真的省了无数个困惑的夜晚。

3.3 硬件完成的七步切换:从事件到入口

一个trap从发生到进入处理函数,硬件按顺序完成以下动作。这段我建议做CPU设计的人逐条对照你的RTL实现:

  1. 把trap原因写入mcause:最高位写1表示中断,写0表示异常;低12位写异常或中断编号。
  2. 把断点PC写入mepc:按上面3.2的规则取值。
  3. 如果该trap有辅助信息,写入mtval。比如页错误写虚拟地址,非法指令写指令编码,地址错位写访存地址。不是所有trap都需要写,ecall就不填。
  4. 更新mstatus:MPIE←MIE,MIE←0,MPP←当前特权级。
  5. 把当前特权级提升到M模式(或更高特权级)。对于机器模式trap,目标就是M模式。
  6. PC←mtvec。mtvec最低两位通常对应模式设置,基本模式就是直接跳到mtvec[31:2]左移后的地址。
  7. 冲刷流水线。因为控制流完全改变,之前预取的所有指令都必须作废,处理器从mtvec指向的地址重新取指。

这里要注意,步骤1~6的完成顺序不是任意的。比如如果先设置PC跳转再写mepc,新指令还没真正执行时mepc被覆盖,那就完蛋了。业内通常把trap路径做成状态机:检测到异常事件→锁存原因→写CSR→跳转,一拍或几拍内完成。实际上如果你用AHB/AXI外设,访存异常往往晚几个周期才回来,这时候流水线里可能已经进了好几条新指令,设计处理逻辑时要对异常事件的“迟到”有充分的容错。

3.4 mret:一切回到正轨

trap handler处理完毕后,用mret指令返回。mret不是普通的跳转,它至少完成三件事:PC←mepc、特权级←MPP、MIE←MPIE,同时把MPP设为最低特权级(U模式,规范是为了安全考虑)、MPIE设为1。严格说,mret也是一个特权指令,在U模式下执行会触发非法指令异常。

直接跳转指令jalr也把某个寄存器值写到PC,但它不会恢复特权级、不会恢复中断使能、不会通知流水线“这是一次异常返回”。所以trap handler结束时必须用mret或sret,而不是jalr ra。很多初学者在这里想当然,最后发现内核偶尔能跑、偶尔崩溃——就是因为特权级和中断使能从没被正确恢复。

4. delegation:为什么跑OS之后真正的handler大多在S模式

4.1 裸机时代根本用不到,跑OS就绕不开

如果你的目标只是写一个裸机程序、跑个呼吸灯,那么所有trap都在M模式处理就行,不需要任何委托。但一旦你打算在risc-v上跑Linux或类Unix内核,情况就变了:内核运行在S模式,用户进程在U模式,U模式主动发起的ecall、用户进程访存造成的页错误,这些事件按规范默认都是报告给M模式的。难道每次系统调用都要先进M模式固件,再由固件转发给S模式内核吗?

当然不行。那样M模式固件将成为所有系统调用的性能瓶颈,每次进程切换都要经过固件,安全边界也变模糊了。于是RISC-V引入delegation机制,允许将一部分trap的处理权直接“委托”给较低特权级。最典型的是把S模式的定时器中断、S模式的外部中断、用户态ecall、页错误等直接交给S模式处理,S模式内核自己设stvec就能接到这些事件,M模式固件完全不参与。

4.2 medeleg/mideleg的位映射玩法

delegation是靠两个CSR控制的:medeleg(机器模式异常委托)和mideleg(机器模式中断委托),它们分别在对应位写1表示“这个异常/中断直接进S模式”。位映射非常简单,直接对应mcause里的异常编号:

  • 异常侧(medeleg):bit 8 对应ecall from U-mode,bit 9 对应ecall from S-mode,bit 12/13/15 分别对应指令页错误、读页错误、写页错误。如果你在跑Linux,这些位基本都要置1。
  • 中断侧(mideleg):bit 9 对应S模式定时器中断,bit 11 对应S模式外部中断。U模式的中断如bit 0、4等,也可以委托,但实际用得少。

我没法在这里给你一个万能掩码,因为具体委托哪些,取决于你的内核怎么分工。但我可以给一个常见组合:一个跑在S模式的简单内核,通常会设置medeleg = (1<<8) | (1<<9) | (1<<12) | (1<<13) | (1<<15),mideleg = (1<<9) | (1<<11)。这样用户程序的ecall、页错误、S模式定时器和外部中断全都不经过M模式。

有个细节容易踩坑:delegation只对“target privileged mode”更低的情况有意义。你想要委托的trap,硬件要把它们重新定向到stvec而不是mtvec。但如果某个M模式中断(比如机器定时器中断mcause=7)被委托,硬件是否允许?规范没有完全禁止所有组合,但基本逻辑是:不能把M模式专属异常委托下去,否则会导致特权级混乱。所以不要尝试把mcause里高于11的各位全置1后再指望能跑起来,会出各种诡异问题。

4.3 设计哲学:硬件做旁路,软件做路由

我理解delegation机制最有意思的点在于:硬件只是提供了“路由能力”,路由策略完全由软件在启动时决定。这就是RISC-V贯穿始终的设计哲学——把选择权留给软件,硬件只负责高效地把选择落到执行上。

它带来的好处是:M模式的固件(比如OpenSBI这类)可以在启动早期完成委托配置,之后操作系统内核就能全权接管trap,固件彻底隐身。系统调用、调度中断的延迟里不会有固件转发的一环,性能上非常干净。硬件上做delegation的实现消耗不大:无非是根据mideleg/medeleg的位状态,决定中断要置入M模式的中断待处理位还是S模式的对应位,以及trap后跳转的目标是mtvec还是stvec。但就是这么一个简单的开关,解决了M固件和S内核职责划分的根本问题。

5. 最小可用的trap handler:从保存现场到mret

5.1 入口第一件事:换栈接客

trap handler的代码和普通函数完全不同,因为进入handler时,所有寄存器的值都是“被打断现场”的值,没有一个保存完好的寄存器可用。特别是sp,它可能指向用户程序的栈,而用户栈在S模式下可能不可访问,或者下面根本没有预留内核栈空间。所以trap handler的第一件事,必须是切换到一段安全的栈,也就是内核栈。

RISC-V为此准备了一个特别的CSR:sscratch(M模式对应mscratch)。它的典型用法是:在正常运行时,sscratch里存着内核栈指针;一旦发生trap进入handler,sp立刻和sscratch交换。交换之后,sp指向内核栈,处理器就能放心地在栈上保存所有寄存器;而sscratch里则放着被打断前的用户sp,稍后把它保存到栈帧里即可。这个交换动作用一条指令就能完成:csrrw sp, sscratch, sp。

提示:绝不要在trap入口的单条指令周期内再次响应中断。这也是为什么MEPC/MIE切换顺序里MIE要清零的原因——在你完成sp交换和寄存器保存之前,任何第二次trap都会用同一个mepc/mcause覆盖现场,返回后程序就疯了。

5.2 SAVE_ALL宏:把32个寄存器逐一安置好

RISC-V的通用寄存器有32个,除了x0硬编码为0之外,其余31个都可能被破坏,trap handler必须全部保存。以S模式为例,我写一个精简的保存宏,你看一下结构即可,完整版就是把所有x寄存器按偏移放进去:

.macro SAVE_ALL csrrw sp, sscratch, sp # sp 交换为内核栈,sscratch 暂存用户 sp addi sp, sp, -32 * 8 # 在当前栈上开辟 32 个通用寄存器大小的空间 sd x1, 1 * 8(sp) # 保存 ra sd x2, 2 * 8(sp) # 2号槽位保存的是“原始sp”,稍后从sscratch读回 sd x3, 3 * 8(sp) # ... x4 ~ x31 依此类推 csrr t0, sscratch # 取出被打断前的用户/旧 sp sd t0, 2 * 8(sp) # 写入 x2 槽位 .endm

对应的恢复宏就是逆操作:从栈帧里加载x1、x3~x31,用2号槽位恢复原始sp到sscratch,然后csrrw sp, sscratch, sp换回用户栈,最后mret。这里我不建议为了图省事只保存“调用者保存寄存器”,因为trap完全可能在任意指令边界中断,比如一条正在执行的长指令被中断,所有寄存器的内容对恢复现场都同等重要。宁可多存几个寄存器,也不要为了省栈空间去赌编译器行为。

5.3 中断分发与处理流程

保存完现场后,handler要读取mcause判断原因,然后分发给对应的服务例程。一个朴素的流程是这样的:

void trap_handler(void) { uint64_t cause = read_csr(mcause); if ((cause >> 63) & 1) { // 中断:低12位是中断编号 switch (cause & 0xfff) { case 7: timer_isr(); break; // M模式定时器中断 default: // 处理其他中断 } } else { // 同步异常:低12位是异常编号 switch (cause & 0xfff) { case 8: syscall_handler(); break; // ecall from U case 12: page_fault(); break; // 指令页错误 default: panic(); } } }

实际工程里会有更细的分类,但骨架就是这样:先看中断位,再查编号,然后调用对应函数。一个很容易忽略的点是:trap_handler返回前,如果修改了mepc值,mret会跳转到修改后的地址。比如系统调用需要返回用户态后跳过ecall这条指令,通常做法就是在handler里给sepc加4,这样mret后直接执行ecall的下一条指令,不会再次陷入系统调用。

5.4 一次时钟中断的完整旅程

我把最常见的定时器中断串起来讲一遍,你会对整条链有一个非常直观的印象。假设使用标准CLINT,mtime在地址0x02000000附近,mtimecmp在其后偏移处,先看看使能流程:

#define CLINT_BASE 0x02000000L #define MTIME_OFFSET 0x0 #define MTIMECMP_OFF 0x0 void timer_init(void) { uint64_t *mtime = (uint64_t *)(CLINT_BASE + 0x4000); uint64_t *mtimecmp = (uint64_t *)(CLINT_BASE + 0x4008); *mtimecmp = *mtime + 100000; // 在 M 模式下:开启机器定时器中断 set_csr(mie, (1 << 7)); set_csr(mstatus, 1 << 3); // MIE = 1 }

当mtime追上mtimecmp的那一拍,CLINT在硬件上向处理器核发出定时器中断请求,处理器核完成前面说的七步切换:mcause写入0x80000007(最高位1+machine timer编号7),mepc保存当前断点,跳到mtvec。handler进入后读取mcause,发现是7号定时器中断,调用timer_isr。此时CPU会去mtimecmp里写下一个到期时间,清掉中断挂起标志(写mtimecmp本身就清除了中断请求),处理完必要的调度动作后mret。下一次trap又会重复这个循环,时钟节拍就稳定地走起来了。

我在调试这个流程时最大的心得是:如果定时器中断只触发一次就再也不来了,优先怀疑mtimecmp没有写入新的到期值。因为不少初学者以为一次性配置就完事,实际上你必须每次在中断服务里更新mtimecmp,否则中断请求永远挂在那边,处理器要么卡在第一次handler里出不来,要么后续进不了新的定时中断。

6. 真正会让你翻车的几个细节

6.1 mret恢复的不只是PC

我前面说过mret做三件事,但实际验证中它还有第四件容易忽略的事:设置MPP=最低特权级(U模式)、MPIE=1。这意味着如果你在一个trap handler里连续执行两个mret,第二个mret的行为会很奇怪——因为它已经不再是“从同一个trap现场返回”的语义了。有些CPU实现的验证模型里还会额外刷新TLB或流水线状态。总之,不要把mret当作“跳回mepc”来用,它是整个trap状态机的收尾动作,前后的特权级、中断使能必须配套。

另外,mret只在M模式下合法(sret对应S模式)。如果你在S模式内核里误用了mret,通常会触发非法指令异常,并再次进入trap handler。这种“异常套异常”是最折磨调试者的。

6.2 ecall编号和特权级绑定

ecall指令没有操作数,它的真实行为取决于执行它的特权级。U模式下的ecall产生异常编号8,S模式下的ecall产生异常编号9,M模式下的ecall产生异常编号11。同一个指令,不同特权级执行,意义完全不同。

这给OS设计带来一个微妙点:trap handler必须知道当前特权级才能正确解释ecall含义。比如用户程序调系统调用,ecall编号是8;但如果你在调试一个S模式内核驱动,也执行了ecall,那编号就是9。如果你用同一个handler处理这两者,必须在代码里区分,否则会把用户态系统调用误认为内核态请求,轻则功能错误,重则安全漏洞。

6.3 WFI的“睡眠”不可依赖

WFI(Wait For Interrupt)指令理论上能让处理器进入低功耗等待状态,直到中断到达。但RISC-V规范里允许实现把WFI简单地当作一条NOP立即执行,也就是说,WFI根本不能保证处理器会停下来。它只是一个“提示”级别的指令。

这个规定给设计带来了灵活性,但给软件带来了坑。如果你依赖WFI做“等待中断然后接着跑”的逻辑,比如用WFI实现自旋锁等待,在有些核上工作正常,换个实现可能就变成忙等待。我的建议是,把WFI当作一种可选的功耗优化,绝不能当作同步原语。真正的等待逻辑应该建立在中断使能+mret流程的完整性上。

6.4 CSR的读写必须是原子的

RISC-V为CSR访问设计了CSRRW、CSRRS、CSRRC及其立即数版本,语义上是原子“读-改-写”。这看起来是基本要求,但CPU实现里很容易出问题。比如经典操作“开中断”:

csrsi mstatus, 0x8

这条指令要读出mstatus、把bit3置1、写回去。如果实现上把它拆成了非原子的多拍操作,这中间来了一个中断,中断可能会在写回完成后才被处理,也可能被吞掉,行为完全不可预测。尤其当CSR访问与mepc/mstatus硬件更新在同一个事件窗口竞争时,设计必须保证CSR指令的原子性不被trap打断。

我在RTL验证时发现,好的做法是把CSR的读-改-写放到CSR流水级内部完成,不让它和中途插入的异常事件交错。这一点在验证阶段就要专门构造测试用例,比如在连续执行CSRRS指令时反复注入中断,看状态一致性。

6.5 嵌套trap的开关控制权

进入trap handler后MIE被硬件自动清零,此时所有中断都被屏蔽,现场保存是安全的。但你在handler里如果想开中断响应更高优先级的事件,就要自己处理“保存现场”的嵌套问题。

嵌套trap最大的陷阱是:mepc和mcause这类CSR只有一个,硬件进入一次trap就会覆盖它们。如果你在第一次handler里保存完现场后开了中断,第二次trap到来,硬件会把mepc改成第二次trap的断点,把mcause改成新的原因。这时候如果你没有先把第一次的mepc/mcause保存到栈里,返回时就会跳到错误地址或处理错误原因。所以正确的嵌套流程是:保存现场→把mepc/mcause存栈→开中断→处理→关中断→恢复mepc/mcause→mret。

有经验的RTOS会通过设置不同的trap入口门槛来管理嵌套深度,比如只允许某一优先级以上的中断嵌套,其余全部延迟处理。这个决策没有标准答案,取决于你的实时性要求和栈深度预算。但我建议第一版先不要开嵌套,等整个trap流程稳定跑通,再去优化嵌套响应。

在我实际调过的risc-v核里,最折腾人的往往不是trap规范本身难懂,而是调试手段少。你插入一条printf都可能触发新的trap,然后整个状态彻底乱掉。写到这里我特别想分享一个土办法:准备一个纯汇编的简易trap handler,进入后先把mcause和mepc写到固定的内存槽,然后死循环。要调试外部中断时,就在中断请求信号上打几个触发点,观察硬件是否真的走到了mtvec。观察点确认无误后,再把C语言级别的handler逐步加上去。这个办法虽然原始,但比起在异常流里加打印,它能让你真正看到硬件状态的每一步。

这个专栏写到现在,我越来越觉得“trap机制最核心”这个说法是有道理的:它既是CPU设计者验证流水线的试金石,也是系统软件接管硬件的入口。后面如果继续写,我应该会顺着trap往下讲讲CSR的流水级实现细节和外部中断控制器的协同设计,那些东西同样是让人掉头发的部分。