做RISC-V CPU设计也好,写RISC-V裸机程序也罢,你最终都会被同一个问题拦住:当非法指令执行、定时器中断、外设请求这些“游离于正常指令流之外”的事情发生时,处理器到底该怎么办?RISC-V给出的答案就是 trap 机制。我专栏前面几篇一步步搭过取指、译码、执行的简单流水线,到这一篇必须停下来把 trap 讲透,因为它是异常、中断、特权级切换的总枢纽,也是从“能跑裸机程序”走向“能跑操作系统”的分水岭。无论你是自己写处理器核,还是只写上层软件,掌握这套机制都能让你少走一大截弯路。
1. 别把 trap 想复杂了:它本质上是“强制过程切换”
1.1 异常、中断、trap 三者到底什么关系
很多初学者第一次看到术语就晕了:exception、interrupt、trap、fault、abort……RISC-V 文档里反复出现这几个词,到底有什么区别?
我的习惯是这样理解的:异常(exception)是 CPU 执行指令时自己触发的同步事件,比如非法指令、访问了不存在的地址、执行了 ecall 系统调用指令。中断(interrupt)是外部设备或定时器产生的异步事件,比如网卡收到数据、定时器计数到达,它们和当前正在执行的指令没有直接因果关系。而 trap 则是这两类事件导致的统一控制流转移过程:CPU 暂停当前任务,跳到一个预先设置好的处理入口,执行完处理程序后再返回原来的任务。
为什么 RISC-V 要把它们统一成一套机制?因为对硬件来说,处理流程是一样的:记录现场、跳转、恢复。与其为异常和中断各搞一套状态机,不如合并成同一个“保存状态 + 跳转”协议,收到事件后先去查 mcause(trap 原因寄存器),软件看到最高位是 1 就知道是中断,是 0 就知道是异常,再具体分类处理。
打个生活化的比方:异常就像你自己走路踩到坑里,中断像别人从旁边扔了块石头过来。你说这两种情况要不要用完全不同的应急流程?不用。都是先停下来,记住刚才走到哪一步了,处理突发事件,然后再回到原来的位置继续走。坑和石头只是原因不同,但“停下来、记位置、处理、回去”的过程高度一致。
1.2 同步与异步的差异:决定底层设计难度
把异常和中断统一处理,不代表硬件实现时可以把它们完全当一回事。有一个核心差异你必须刻进脑子里:同步异常发生在指令执行阶段,是精确的;异步中断则是在指令边界采样插入的。
什么是“精确异常”?假设一条非法指令在执行阶段才被发现,硬件必须保证这条指令之前的所有指令都完成了,这条指令之后的所有指令都还没有产生任何效果,然后把状态记录下来跳转。等处理完之后,从这条指令重新开始执行。这要求流水线设计者在检测到异常时,能把后面已经流进流水线的指令全部冲刷掉,甚至要处理乱序执行中更复杂的回滚问题。这也是为什么我们先把顺序流水线做扎实——顺序流水线做精确异常简单得多。
中断就不一样了。外部设备不知道你现在执行到哪条指令,所以 CPU 必须等当前正在执行的指令完整结束,在下一个指令边界才采样中断信号并响应。也就是说,中断的 mepc 指向“被中断的那条指令”,恢复后要重新执行;异常路径的 mepc 则指向“触发异常的那条指令”,恢复后通常也是重新执行,但 ecall、ebreak 这类指令如果重新执行,会再次触发同样的异常,形成一个死循环。所以软件 handler 在处理 ecall 时,往往需要手动把 mepc 加 4,让它跳到下一条指令。
这个差异直接决定了后续软件恢复逻辑怎么写,也解释了为什么很多 RISC-V 讲义会把同步异步分开讲。因为处理异常时你知道是哪条指令出了问题,而处理中断时你往往只知道“被打断的位置”,并不知道当前寄存器的值是否符合你的预期。
2. 硬件侧的“自动记账”:相关 CSR 才是 trap 真正的主角
2.1 mstatus 里的三位一体状态机
trap 入口处,硬件自动完成的“记账”工作,最核心的载体就是 mstatus(机器状态寄存器)。如果你去看特权级规范,会发现 mstatus 有一大堆位:全局中断使能、历史使能、特权级、地址翻译模式、内存访问特权级等等。但对 trap 本身来说,最关键的是这三个位:MPP、MIE、MPIE。
在一颗最小化的 M 模式 CPU 里,这三个位的配合逻辑是这样的:当 trap 发生时,CPU 把当前特权级(机器模式就是 M,值为 3)写入 MPP,把当前 MIE 的值保存到 MPIE,然后把 MIE 清零,进入关中断状态。当 mret(从机器模式 trap 返回)指令执行时,做相反操作:从 MPP 恢复特权级,把 MPIE 恢复到 MIE,并把 MPIE 重新置 1。
这一套操作的妙处在于,它相当于一个一层的硬件栈,自动完成了“记录来路、关闭中断、返回时恢复”的闭环。为什么需要 MPIE 这个历史位?因为有时候 handler 里我们会手动打开中断(嵌套),如果返回时无脑把 MIE 置 1,那就会破坏最初进入 trap 前的状态。用 MPIE 保存旧值,mret 时恢复,才能保证“来的时候中断是开的,回去的时候还是开的;来的时候中断是关的,回去的时候也是关的”。
顺带提醒一下,mstatus 里还有 SIE、SPIE、SPP 等 S 模式对应位。如果你的核实现了 S 模式,那么它们和 M 模式这些位是层级配合的:M 模式的 MIE 是所有中断的总闸,S 模式的 SIE 又是在 M 模式下能够运行的二级开关。很多新手在这里被绕晕,我建议先只盯着 M 模式把流程跑通,再谈 S 模式。
2.2 mepc、mcause、mtval:三本“病历”
- mepc:记录 trap 发生时的指令地址。同步异常时是触发异常的那条指令的 PC;中断时是被打断的那条指令的 PC。需要注意,mepc 不会自动加 4,需要软件在特殊场景下自行调整。
- mcause:trap 原因寄存器,最高位表示 interrupt(1 为中断,0 为异常),低 12 位是异常编号。软件读一次这个寄存器基本就能确定“发生了什么”。
- mtval:辅助信息寄存器。比如非法指令异常时记录指令编码,访存异常时记录出错的地址。规范允许它在某些异常下不写入有效值,所以软件不能拿它当唯一判断依据,只能当辅助线索。
下面是一张常用 cause 值表,我在调试时打印过无数次,建议收藏:
| 值(低12位) | 含义 | 中断位为1时的含义 |
|---|---|---|
| 0 | 指令地址未对齐 | 用户软件中断 |
| 1 | 指令访问故障 | 监管者软件中断 |
| 2 | 非法指令 | 机器软件中断 |
| 3 | 断点(ebreak) | 用户定时器中断 |
| 4 | 加载地址未对齐 | 监管者定时器中断 |
| 5 | 加载访问故障 | 机器定时器中断 |
| 6 | 存储/AMO地址未对齐 | 用户外部中断 |
| 7 | 存储/AMO访问故障 | 监管者外部中断 |
| 8 | ecall 来自 U 模式 | 机器外部中断 |
| 9 | ecall 来自 S 模式 | 保留 |
| 11 | ecall 来自 M 模式 | 保留 |
| 12/13/15 | 指令/加载/存储页错误 | 保留 |
拿到这张表,你至少能看懂任何一次 trap 的“病历本”了。实际调试中,只要读一下 mcause,八成的 trap 来源都能定位出来。
2.3 mtvec:入口到底在哪,Direct 还是 Vectored
mtvec(机器 trap 向量基址寄存器)决定了 RISC-V 在 trap 发生时跳到哪里。它由两部分组成:高 62 位存放入口地址 BASE,低 2 位是模式 MODE。
MODE 为 0 时是 Direct 模式:所有 trap 都跳到 BASE 地址。MODE 为 1 时是 Vectored 模式:异常仍然跳到 BASE,但中断会跳到 BASE + 4 × cause(即每个中断源占 4 字节的一个跳转项)。Vectored 模式的好处是中断延迟更低,因为硬件直接根据 cause 跳转到对应的 stub,而不用先读 mcause 再软件分发。劣势是 BASE 必须 4 字节对齐,且每个中断项通常只有一条跳转指令的空间。
我在自己的小核里,默认都用 Direct 模式。原因很简单:反正 handler 里都要先保存现场、读 mcause,再决定怎么处理。Direct 模式的开销只多了一条分支开销,但对于中断较少的教学级核来说完全够用。如果你做的是追求低延迟的实时控制器,再考虑 Vectored 模式也不迟。
还有一点容易踩坑:mtvec 寄存器最低两位是模式位,写地址时不要把模式位漏了,也别把地址写得不按 4 字节对齐。Direct 模式下也要求低两位为 00,否则行为未定义。
2.4 另一组“邻居”:mie/mip 与委托相关寄存器
真正负责中断“谁来报、报不报”的是 mip 和 mie 这一对寄存器。mip(机器中断挂起寄存器)反映中断是否有请求挂起,比如外部中断来了,mip.MEIP 会被置 1;mie(机器中断使能寄存器)决定哪些中断允许被响应。两者和 mstatus.MIE 的关系是:中断信号要真正触发 trap,必须满足“对应的 mie 位为 1,对应的 mip 位为 1,且 mstatus.MIE 为 1”这个三重条件。
很多人把 mstatus.MIE 和 mie 寄存器搞混。简单说:mie 是“哪些中断源我关心”,mstatus.MIE 是“我当前整体能不能被中断打断”,就像总开关和分路开关的关系。定时器中断要能触发,分路的 MTIE 位必须打开,总闸 MIE 也必须合上。
如果你的核要支持 S 模式,那还要了解 medeleg(异常委托寄存器)和 mideleg(中断委托寄存器)。这是整个 trap 机制从“裸机专用”走向“OS 可用”的关键,我在第 5 章专门展开讲。
3. 手把手实战:从触发到返回的完整 trap 流程
3.1 硬件侧:一个 trap 周期里究竟发生了什么
我们先从硬件的视角,把陷阱触发的步骤拆成流水账。假设现在 CPU 在执行一条非法指令,流水线到译码/执行阶段检测到非法指令编码,接下来硬件大致要完成六件事:
- 确认 trap 源,并采样对应的信息(例如非法指令的 PC、指令编码)。
- 冲刷流水线中所有处于该指令之后的指令,确保它们不会产生副作用。
- 将 PC 写入 mepc。
- 将 cause 写入 mcause,必要时把辅助信息写入 mtval。
- 更新 mstatus:MPP 保存当前特权级,MPIE 保存当前 MIE,MIE 清零。
- 将 PC 设置为 mtvec 指向的入口,开始执行 handler。
这六件事对软件是透明的,但硬件设计时每一步都可能出问题。比如第三步写入 mepc,如果当前指令同时在写 mepc 这个 CSR,处理器的写回报逻辑就要处理优先级冲突,这也是中断延迟中很难压掉的毛刺之一。又比如第五步的 mstatus 位更新,必须是“原子”的,不能出现 MIE 清了但 MPIE 还没保存完就被下一个中断打断的窗口——当然 MIE 清零后同优先级的机器中断已经不会再触发,但如果是 NMI 等场景,仍需小心设计。
关于中断响应延迟(interrupt latency),业界常把它定义为“从中断信号有效到执行到 handler 第一条指令”的周期数。上述 6 步的跳转本身可能只有一个周期,但在真实设计中,当前指令必须执行完,CSR 写端口要空闲,这些约束都会把延迟拉长到几个甚至十几个周期。
3.2 软件 handler:进入后的“第一件事永远是换栈”
很多新手写 trap handler,上来就试图往栈里保存寄存器。但这里有一个隐蔽问题:进入 handler 的时候,sp 的值还是被打断前那个任务的栈顶,甚至可能已经被破坏得不可信。如果你贸然把通用寄存器压栈,可能把数据写到任务的栈里,覆盖掉原有内容,也可能导致后续 C 函数调用因为栈不对直接崩溃。
RISC-V 规范推荐的做法是用 mscratch 寄存器保存一个独立的“trap 栈指针”。进入 trap 后,利用 mscratch 和 sp 交换,快速把 sp 切换到专用栈。我给出一个简化但实用的入口模板:
# 初始化时:mscratch = trap_stack_top trap_entry: csrrw sp, mscratch, sp # 交换:sp 拿到 trap 栈顶,mscratch 保存旧 sp addi sp, sp, -256 # 在 trap 栈上分配保存区 # 保存通用寄存器 sw x1, 0(sp) sw x2, 4(sp) # 注意 x2 就是旧 sp,已保存在寄存器堆里 # ... 以此类推保存全部通用寄存器 # 保存 mepc/mcause/mtval csrr t0, mepc csrr t1, mcause csrr t2, mtval sw t0, 200(sp) sw t1, 204(sp) sw t2, 208(sp)看到 csrrw 这一步的妙处了吗?执行前 sp 是任务栈指针、mscratch 是 trap 栈顶;执行后 sp 变成 trap 栈顶,mscratch 保存的是任务栈指针。之后你就可以放心地在 trap 栈上压寄存器了。退出时再执行一次csrrw sp, mscratch, sp,把 sp 换回去,然后 mret。
这个交换式用法有一个前提假设:trap 不会在 trap handler 内部再次发生,也就是说不支持嵌套。如果支持嵌套,第一次 trap 已经交换了 mscratch,第二次 trap 进来再交换时,mscratch 里已经不是原始的 trap 栈顶了。如何处理嵌套,我会在第 4 章给建议。
3.3 分发与处理:读懂 mcause,必要时要抬 PC
真正的工作在 handler 主体里。伪代码逻辑通常长这样:
- 读 mcause,判断是中断还是异常。
- 如果是中断,根据 mcause 的低位判断是定时器还是外部中断,调用对应处理函数。
- 如果是异常,再细分:非法指令、访存错误、ecall、ebreak……各自处理。
- 特殊处理:如果 mcause 是 ecall 或者 ebreak,必须在返回前把 mepc 加上 4,否则 mret 后又会执行同一条指令,再次触发 trap,形成“踢皮球死循环”。
- 处理完再恢复现场,执行 mret。
实际操作中,有人喜欢用一个 C 函数统一分发,但要注意:陷入 trap 时,你处于机器模式且栈是 trap 栈,如果想调用 C 函数,必须先确保 C 运行环境(栈指针、gp、tp 等)已准备好。很多教学代码会直接用汇编完成现场保存然后jal到 C 的 handler,这种写法没问题,但必须保证 gp 等寄存器在前面的保存中没有被覆盖,且编译器没有使用 gp 相对寻址导致取值错误。
3.4 mret 的玄机:它不只是一个“跳回去”
mret 指令看似只是jalr zero, mepc,但它额外携带了特权级恢复的语义。执行 mret 时,硬件依次完成:
- PC ← mepc
- MIE ← MPIE
- MPIE ← 1
- 如果实现了 U/S 模式,特权级 ← MPP,并把 MPP 清零
这就是为什么在特权级隔离的环境中,非法执行 mret 会导致灾难性结果。假如你只在 M 模式里开发,可能感觉不到区别;但只要你开始做 S 模式,比如跑一个简单的 RTOS,就会发现特权级的恢复全靠 mret 这一下。我自己早期写过一个小核,把 mret 偷懒当成jalr x0, mepc实现,结果每次中断返回后特权级都留在了 M 模式,所有 S 模式的异常处理全部错乱,查了整整两天才发现是这里的问题。
这里也要再次强调进制关系:mepc 的值不会在硬件里自动增加。RISC-V 选择把“是否跳过当前指令”的决定权交给软件,这样处理 illegal instruction 时想重新执行就重新执行,处理 ecall 时想跳到下一条就手动加 4。这个设计给了软件最大灵活性,但也把责任压到了开发者身上。
4. 中断体系的另一半:CLINT/PLIC 如何与 trap 机制衔接
4.1 定时器中断的完整链路
如果你只在一条裸机 while(1) 循环里写代码,可能永远也不需要关心时钟中断。但要实现任务调度,定时器中断是第一个必须打通的环节。RISC-V 的机器模式定时器通常由 CLINT(Core Local Interruptor)生成,核心是两个寄存器:mtime 和 mtimecmp。前者是一个不断递增的计数器,后者是软件设置的比较值,当 mtime 大于等于 mtimecmp 时,CLINT 会拉高定时器中断请求线,CPU 侧对应 mip.MTIP 位就会置 1。
链路是这样完整走通的:mtime 走到 mtimecmp → CLINT 断言中断 → CPU 看到 mie.MTIE 使能且 mstatus.MIE 使能 → 触发 trap → 跳入 mtvec → handler 读取 mcause 发现是机器定时器中断 → 软件重新设置 mtimecmp 为下一次的中断时间点 → 处理完返回。
新手最容易忽略的一点:mip.MTIP 这个挂起位并不会因为你进入了 handler 就自动清零,因为它反映的是“比较器条件是否成立”这一电平状态。如果你在 handler 里不重新设置 mtimecmp,那么 mtime 会一直大于等于旧比较值,中断会再次触发,表现为“中断风暴”。所以定时器 handler 的第一要务是立刻更新 mtimecmp,把下一次中断时间推远。
设置 mtimecmp 时还要当心:RISC-V 规范要求对 mtimecmp 的写入应当是“比较器安全”的,也就是说在写入新值的中间状态,不能因为 mtime 临时大于了某个中间值而产生一次假的比较匹配。简单的做法是先在禁中断的状态下写高位、再写低位,或者在设计中让比较器仅在完整写入后采样。
4.2 外部中断:PLIC 的 claim 与 complete
外部设备的中断数量往往很多,一个 CPU 核不可能每个设备一根线直接接进来,所以 RISC-V 生态里普遍使用 PLIC(Platform-Level Interrupt Controller)做汇聚和仲裁。PLIC 将多个外设中断源映射成唯一一根到 CPU 的中断线,置位 mip.MEIP,CPU 再从 mtvec 进入 handler。
软件处理外部中断有两个特别重要的步骤:claim 和 complete。在 RISC-V 的规范中,handler 需要先读取 PLIC 的 claim 寄存器,这个操作会返回当前优先级最高的待处理中断编号,同时硬件会把该中断的 pending 状态清除,并暂停对该中断源的继续仲裁。处理完设备后,软件再把同样的中断号写入 complete 寄存器,通知 PLIC 这次中断“处理完了”。
为什么 claim 必须在 complete 之前?因为 PLIC 只有在你 claim 之后才会把该中断源从“挂起”状态解除,并允许后续的中断进入仲裁。如果你不 claim,低优先级中断可能会被高优先级一直压着,甚至出现同一个中断重复上报。而 complete 写得太早也有问题:设备可能还没真正处理完,中断状态没清除,会导致“中断咬尾”式的反复触发。我调试 PLIC 时踩过的坑是,中断号从 PLIC 读出来后发现和设备树里对不上,后来发现是设备号偏移问题,PLIC 的编号通常从 1 开始,0 表示“无中断”,千万别直接用 0 号中断。
4.3 中断嵌套:支持还是不支持
前面我提到,进入 M 模式 trap 后 MIE 会被硬件自动清零,这就是 RISC-V 默认“不支持嵌套”的根源。如果你希望在处理中断时允许更高优先级的中断插入,就必须在保存好现场后手动执行csrsi mstatus, MIE打开全局中断。
但这里有一个非常容易致命的细节:mepc 和 mstatus 是单份的 CSR。第一次 trap 已经把它们写成“被打断点信息”,如果此时开中断,第二次 trap 进入时,硬件会把新的 mepc 和 mstatus 覆盖掉旧值。因此,开中断之前你必须已经把这些旧的 CSR 值保存到内存中,否则第二次 trap 返回时,mret 会跳回一个已经错误的地址。
我自己的经验是,对于教学级小核,就别做嵌套了。非嵌套模型虽然简单,但能覆盖 95% 以上的场景:定时器中断来了就关中断处理,处理完再恢复开中断。等你要跑 OS 了,先利用 S 模式的中断委托,把中断放到 S 模式下处理,再考虑嵌套优先级问题。RISC-V 本身没有 ARM NVIC 那样的嵌套中断优先级硬件,优先级完全靠软件编排,这算是一个架构选择上的差异,习惯了 ARM 的开发者一开始会非常不习惯。
5. 从 M 模式到 S 模式:委托机制与影子 CSR
5.1 delegation:为什么要“下放” trap
最小系统里所有的 trap 都进 M 模式就够用了,但一旦你想跑 Linux 或类 Unix 的 OS,就会发现一个尴尬问题:系统调用、缺页、外部中断这些高频事件,全都先打到 M 模式,再由 M 模式的软件转发给 S 模式。这不仅是性能浪费,还让机器模式的固件逻辑变得异常复杂。
RISC-V 的解法是 medeleg 和 mideleg。这两个寄存器的每一位对应一个 cause code。当某一位为 1 时,对应的 trap 如果在 S/U 模式下发生,就会被直接引导到 S 模式的 trap 入口(stvec),使用 S 模式的 CSR 组(sepc、scause、sstatus、stval)来处理,M 模式完全不参与。典型做法是把 U 模式的系统调用(cause 8)、页面错误(cause 12/13/15)、外部中断等委托给 S 模式,而 M 模式只保留机器定时器中断、机器软件中断和机器自身的异常。
不要以为委托可以随便做。如果 M 模式自身发生了一个异常,它永远不可能被委托到 S 模式——委托的含义是“低特权级的 trap 绕过 M 模式直接进入 S 模式”,高特权级不可能被降级处理。另外被委托到 S 模式的事件,在 S 模式下受 sstatus.SIE 控制,但 M 模式的 mstatus.MIE 仍然是更高层的总闸。这就像一个单位里越是底层经理越有审批权,但遇到更高层领导否决,一切免谈。
5.2 影子 CSR 带来的思维转换
S 模式的 trap 用到一组影子 CSR:sepc、scause、stval、stvec、sstatus。硬件设计上,你可以把 sstatus 做成 mstatus 的一个“视图”,即访问 sstatus 时只是访问 mstatus 中 S 模式相关的位域子集。但 sepc、scause、stval 这三者必须是物理独立的寄存器,否则 S 模式的一次 trap 会把 M 模式正在保存的现场信息覆盖掉。
对软件开发者来说,影子 CSR 意味着你要时刻区分“当前在哪个模式下看到的 trap 状态”。看同一个异常,在 M 模式和 S 模式读到的 mcause 与 scause 可能完全不同。如果核里有简单的中断转发逻辑,M 模式 handler 里如果误用了 S 模式的 CSR 读写指令,轻则污染 S 模式现场,重则让 OS 的异常处理链路彻底崩盘。
我记得有次做 FPGA 调试,S 模式的操作系统里一跑用户程序就挂,寄存器现场一片乱。后来发现是 M 模式的定时器 handler 里为了调试方便顺手打印了 scause,而 scause 是物理独立的没问题,但它在返回前恢复现场时,恢复顺序写错了,把 S 模式的 sepc 覆盖成了 M 模式的 mepc。从那以后我给自己定了一条规矩:M 模式 handler 里绝不读写任何 S 模式影子 CSR,除非你明确知道自己在做什么。
5.3 地址翻译与页故障:异常的精髓场景
为什么要费那么大劲搞 S 模式和委托?因为现代 OS 的核心卖点是虚拟内存和进程隔离,而虚拟内存正是通过页表异常这套 trap 机制落地的。当 CPU 访问一个还没映射的页面时,硬件触发页错误异常,把故障地址写进 stval,OS 的页错误 handler 分配物理页、填好页表、然后 mret/sret 返回用户程序重新执行那条访存指令。
正是在这个场景下,异常返回后“重新执行”的语义显得格外珍贵。硬件自动把 mepc 指向触发异常的指令,OS 补好了映射,sret 回去后同一条访存指令再次执行,这次就能成功了。如果 mepc 指向的是下一条指令,整个虚拟内存模型都得重新设计。这也是 RISC-V trap 的一个核心设计哲学:异常路径返回后重新执行,中断路径返回后也重新执行,哲学一致,细节软件控制。
6. 调试 trap 机制时的常见坑与排查清单
6.1 一上电就莫名进入 trap,或者反复触发同一种异常
这个现象我在 FPGA 上见了太多。最常见的几个原因:
- mtvec 没有初始化,或者初始化时地址写错了,跳转到了一个垃圾区域。那里多半是随机数据,被 CPU 当成指令,读出来自然是非法指令,又触发新的异常,形成死循环。
- mstatus 的初始值不可控。如果复位后 MIE 恰好是 1,外设中断请求又是高有效未屏蔽,可能一进主循环就被拉进中断。
- 外部中断请求线悬空。PLIC 没有配置好的时候,中断输入可能是浮空电平,MEIP 一直为 1,只要 MIE 和 MEIE 一打开,中断就来了。
排查手法很简单:在仿真里 dump PC,看它是不是在一个固定地址附近往复;或者打印 mcause,看原因是否总是同一个。如果 mcause 一直是 2(非法指令),十有八九是入口地址坏了。
6.2 handler 里反复触发,形成“踢皮球”死循环
同步异常返回后重新执行,这是特性,但也最容易让新手抓狂。如果你处理非法指令时没有把问题指令替换掉,也没有修改返回地址,mret 后又是一条非法指令,直接又进 trap,看起来就像系统卡死。
这类问题的标准解法我已经在第 3 章提过:对于 ecall 和 ebreak,必须在返回前把 mepc 加上 4。对于真正的非法指令,程序行为本身就是未定义的,至少要记录 mcause 和 mtval,然后跳到一个错误处理流程,而不是简单地原样返回。
有一个从 ARM 转过来的朋友问我:为什么 ARM 的异常返回地址机制更“贴心”,能自动选择返回到当前指令还是下一条?我告诉他,RISC-V 把这个选择权留给软件,看似麻烦,实则统一。对 CPU 设计者来说,硬件逻辑更简单;对软件来说,你只是需要多写一行csrw mepc, t0。
6.3 中断恢复后寄存器现场错乱
这是 trap handler 里最常见的“低级错误”:保存和恢复的寄存器顺序不一致。你保存寄存器时是从 x1 到 x31,恢复时却从 x31 到 x1,中间某个寄存器被覆盖了;又或者你在恢复现场之前调用了某个 C 函数,那个函数把栈里的数据覆盖了,而你恢复现场时从栈里取出的已经是垃圾数据。
我的建议是用统一的宏来处理保存和恢复,不要手写散装汇编。比如定义一个SAVE_CONTEXT宏,用固定偏移存储每个寄存器;恢复时严格逆序。还有一点:恢复现场前最好先确保 mstatus.MIE 保持关闭,等完全恢复后再用 mret 打开。否则恢复途中来一个中断,现场又乱了。
6.4 PLIC 中断丢失或反复触发
外部中断的丢中断,几乎都出在 claim/complete 时序上。不 claim 就 complete 会破坏清理流程;claim 了但忘了 complete,中断处理完以后 PLIC 还是认为你还在处理当中,不会给后续中断让路。还有一种是设备中断是电平触发的,如果你的设备驱动只清掉了自己的中断标志但没从物理线路上拉低电平,mip.MEIP 会一直为 1,中断会一而再再而三地进来。
如果你的核支持 WFI 指令,这里还有个有趣的现象:在循环里执行 WFI 等待中断,只要对应使能的中断挂起,CPU 就会退出低功耗状态继续执行。有些人误以为 WFI 会一直睡到中断触发 trap 才醒来,其实规范里说 WFI 只是“建议睡一会儿”,即使没有合法中断,也可能提前唤醒。所以 WFI 不能用来做精确的互斥等待,只能作为降低功耗的 hint 使用。
6.5 快速排查清单
| 现象 | 可能原因 | 排查手段 | 处理建议 |
|---|---|---|---|
| 一开机就进 trap | mtvec 未初始化/未对齐 | dump PC、查看 mtvec | 正确写入 4 字节对齐入口 |
| mcause 一直是非法指令 | 入口跳飞,或 handler 未改 mepc | 打印 mtval、单步指令 | 修复入口,必要时抬 PC |
| 中断一直不触发 | mie/mstatus 使能位没打开 | 读 mip/mie/mstatus | 确认分路开关和总闸均开启 |
| 中断反复触发 | 电平未清除,或 mtimecmp 未更新 | 观察 mip 挂起位 | 更新比较器/清设备中断标志 |
| 返回后现场错乱 | 保存恢复顺序不一致/栈冲突 | 对比恢复前后寄存器快照 | 使用统一宏、独立 trap 栈 |
| mret 后特权级不对 | MPIE/MPP 恢复逻辑有误 | 单步进入 S 模式观察 mstatus | 检查 mret 的 CSR 更新实现 |
如果你正在设计自己的 RISC-V 核,我强烈建议先把这些场景做成仿真用例:非法指令、非法地址访问、ecall、定时器中断、外部中断、嵌套中断关闭与打开、S 模式委托切换。每跑通一个,就在验证清单里打一个勾,然后再往里面加乱序、多核这类高难度特性。我自己在第一颗小核上把这一套跑通之后,后面加 MMU、加多核,trap 这部分几乎没有再大改过——因为它就是整个处理器的骨架,骨架正了,后面的血肉才有地方长。
最后再分享一点个人体会:trap 机制不是我翻几遍手册就能真正掌握的,而是靠一次次“机器模式跳到错误地址、寄存器现场一塌糊涂、mret 之后什么都不对”的现场排错练出来的。遇到问题不要慌,先把 mcause 读出来,再对着规范查 cause 表,九成问题都能定位。保存、恢复,顺序对了,世界就安静了。