“RISC-V”、“Trap”、“Exception”这三个词,做底层开发的应该都不陌生。尤其是自己写过CPU或在NEMU这类模拟器上调过操作系统的人,几乎每天都要跟它们打交道。我一开始也分不清Exception和Interrupt到底哪里不一样,更搞不懂为什么一个非法指令就能让整个模拟器报出“bad trap”然后直接挂掉。后面自己动手做了RISC-V流水线CPU,又在基于NEMU的系统里调试过好多次异常,才算把这条链路真正摸透。这篇内容就是一次系统整理:从概念拆解、硬件自动流程、异常委托,到软件handler怎么写,再到CPU设计时容易踩的坑,一次说清楚。无论是刚入门RISC-V、在写裸机程序,还是正在做CPU设计,都值得认真读一遍。
1. 先分清三个词:Exception、Interrupt、Trap
1.1 一类问题,三个名字
在RISC-V里,这三个词经常被混着用,但它们其实不是一个层次的东西。按照RISC-V手册的定义:
- Exception(异常)是CPU执行指令时产生的同步事件,比如非法指令、缺页、访问了不存在的地址。它跟当前指令直接相关,同一个程序、同样的输入,异常每次都会稳定复现。
- Interrupt(中断)是外部异步事件,比如定时器到了、网卡有包到了。它跟当前正在执行的指令没有直接因果关系,任何一条指令执行过程中都可能被中断打断。
- Trap(陷阱)不是一类独立的事件,而是“异常或中断发生后,CPU跳转到特定处理程序”的这个完整过程。换句话说,trap是处理Exception和Interrupt的统一机制。
这个划分很重要。很多文章把trap直接翻译成“陷阱”,好像只有程序主动int 0x80才叫陷阱,这其实误导了不少人。在RISC-V的语境里,所有异常和中断发生后的控制权转移,都叫trap。
1.2 同步和异步,关键是看“谁引起的”
判断一个事件是Exception还是Interrupt,最简单的方法是问一句:它不是当前指令“搞”出来的。
- 如果一条指令自己违法了,比如执行了0xffffffff这种非法编码,或者load地址越界了,那就是Exception,同步。
- 如果CPU正老老实实执行指令,结果时钟中断来了,CPU被迫暂停手头工作去处理中断,那就是Interrupt,异步。
在RISC-V的mcause寄存器里,最高位(第63位)是Interrupt位。如果该位为1,表示这是一个中断;如果为0,表示是一个异常。这个设计很直观,拿到cause值先看最高位,再查低位的异常码就行。
1.3 为什么日常讨论中Trap比Exception更常用
因为写代码的时候,我们并不关心是异常还是中断,只关心“现在控制权要跳到哪里去处理”。RISC-V统一用mtvec/stvec指向trap handler,并没有为Exception和Interrupt设置两套完全独立的入口。于是在实现和调试的时候,大家更习惯说“trap处理了”,而不是区分“这是异常处理”还是“这是中断处理”。这也是为什么NEMU遇到无法处理的坏状态时会报“bad trap”,而不是“bad exception”。
不过在实际设计CPU时,异常和中断的处理优先级、掩蔽方式、返回路径还是有差异的,下面会逐步展开。
2. 异常发生时,硬件到底做了什么
2.1 机器模式下最重要的几个CSR
RISC-V有几个和控制流转移强相关的CSR,理解它们才算真正理解trap。以机器模式为例:
- mtvec:trap入口地址。里面包含BASE(入口地址)和MODE(0表示Direct模式,全部跳到同一个地址;1表示Vectored模式,中断按编号跳到BASE+4×cause)。
- mcause:记录trap原因。最高位是Interrupt,低位是异常码。
- mepc:记录发生trap的那条指令的PC。返回时mret会跳回这个地址。
- mtval:记录附加信息。如果是地址类异常,就是发生故障的虚拟地址;如果是指令异常,可能是非法指令的编码。
- mstatus:状态寄存器,里面的MPIE和MPP位负责保存中断使能和特权级信息。
- mscratch:一个通用临时寄存器,常用来做栈切换。
这些CSR很多是机器模式特有的,S-mode还有一套对应的stvec、scause、sepc、stval、sstatus。
2.2 单步拆解一次trap的完整流程
假设CPU当前运行在U-mode,正在执行一条非法指令,触发了一个Exception。RISC-V硬件会按固定顺序完成下面这些动作:
- 把当前特权级(U-mode)保存到mstatus.MPP字段。
- 把mstatus.MIE(当前中断使能)保存到mstatus.MPIE。
- 在trap期间关闭中断:mstatus.MIE清零。
- 更新mepc为当前异常指令的PC。
- 更新mcause为异常原因,比如非法指令对应值2。
- 如果异常有附加信息,更新mtval。
- 把当前特权级切换到M-mode。
- 跳转到mtvec.BASE指定的地址。
这8步是硬件自动完成的,不需要软件参与。RISC-V的设计哲学就是“硬件做最小必须操作,剩下的交给软件”。
注意:上述顺序中,关闭中断(第3步)非常关键。如果异常处理过程中又来了新的中断,很可能会嵌套,导致现场混乱。RISC-V选择在进入trap时自动关中断,软件做好现场保护后再决定是否重新打开。
2.3 中断和异常的返回路径:mret与sret
处理完trap后,软件最后执行一条mret(如果是从M-mode进入了handler)或sret(从S-mode进入)。mret会执行这些动作:
- PC恢复到mepc的值。
- 特权级恢复到mstatus.MPP。
- 把mstatus.MPIE的值恢复给MIE。
- 将MPP设为最低支持特权级(通常是U-mode)。
这里有个容易忽略的细节:mret指令本身在RISC-V规范里是可写CSR的访存类指令,在某些流水线实现中会被视为带副作用指令,影响分支预测和指令淘汰。实际设计CPU时要注意mret的“终止指令”语义,它类似于分支,但恢复PC的来源不是分支目标,而是mepc。
2.4 异常优先级:CPU设计必须搞清楚的顺序
在流水线CPU里,一条指令可能同时有取指异常、译码非法、访存异常。比如取指时发现页错误,同时这条指令又是一个非法指令,该怎么办?RISC-V规范给出了异常优先级,从高到低大致为:
- 指令地址访问错误
- 指令地址页错误
- 指令地址未对齐(如果实现支持)
- 非法指令
- 断点
- load地址未对齐
- load访问错误
- load页错误
- store/AMO地址未对齐
- store/AMO访问错误
- store/AMO页错误
实际设计时,还要区分同一指令的多个异常按此优先级选择,同时还要考虑前后指令之间异常的回滚。比如第3条指令发生了异常,但第1、2条指令已经提交了,处理器需要能够正确恢复现场,把mepc指向第3条指令,并且保证第1、2条指令的效果不丢失。这不是简单的事,我在自研CPU时就被“异常精确性”(precise exception)折磨过很久。
3. 异常委托:S-mode和操作系统怎么接住trap
3.1 为什么需要异常委托
如果每一个异常都跳转到M-mode处理,操作系统(运行在S-mode)就无法直接处理系统调用、缺页等常见事件,每次都要经过M-mode的固件转发,代价太高。RISC-V因此引入异常委托机制:可以把某些中断和异常直接路由到S-mode的stvec,由S-mode自己处理。
委托控制寄存器是mideleg和medeleg。mideleg的bit i为1,表示第i号中断委托给S-mode;medeleg同理,是异常。实际是否支持某一位的委托取决于实现,但大多数应用处理器都会把常见的异常和中断委托给S-mode。
3.2 medeleg和mideleg怎么看
比如Linux操作系统运行在S-mode,它希望自己处理系统调用(ecall from U-mode,异常码8)、缺页(异常码12/13/15)和外部中断(中断码11,取决于PLIC实现)。那么在固件启动阶段,会把medeleg的bit 8、12、13、15置1,把mideleg的bit 11置1。
委托之后,U-mode执行ecall时,硬件不再跳转到M-mode的mtvec,而是直接跳转到S-mode的stvec。此时记录原因的是scause,返回PC保存在sepc,而不是mcause和mepc。
有个细节:委托并不意味着M-mode的mstatus.MIE变化,而是特权级直接转换为S-mode,并且进入S-mode的处理流程。如果S-mode处理不了,仍然可以通过ecall(M-mode的异常码11)重新回到M-mode。
3.3 系统调用(ecall)在委托后的完整路径
以Linux/裸机程序为例,用户程序执行ecall:
- 硬件发现ecall来自U-mode,且scause被设置为8(环境调用U-mode)。
- 特权级切换为S-mode,PC跳转到stvec。
- S-mode的异常入口保存现场,解析scause。
- 如果scause是8,查syscall number,执行系统调用。
- 执行sret,回到sepc指向的下一条指令(ecall的下一条)。
如果没有委托,这个流程会变成:先到M-mode,M-mode再转发给S-mode,S-mode处理完还要mret回M-mode,再mret回U-mode,效率差很多。这也是为什么几乎所有现代RISC-V平台都会配置委托。
4. 软件侧:写一个能用的trap handler
4.1 上下文保存:别让handler毁掉现场
硬件自动保存的信息只有mepc、mcause这些CSR,通用寄存器是一个都没帮你存的。所以trap handler的第一件事就是保存现场。
保存现场要考虑两个问题:保存到哪?保存哪些寄存器?
保存到哪,通常有两种方案:
- 直接在异常之前的内核栈(S-mode则可能用内核栈)上保存。
- 使用mscratch指向一个独立的trap栈,通过交换sp来切换。
保存哪些,至少是所有调用者保存和临时寄存器(x1、x2、x3、x5-x31),以及可能的浮点寄存器。如果handler用到了某些寄存器,而它又不打算再保存,就必须全部保存。
一个经典的汇编模板长这样:
trap_handler: # 用 mscratch 换栈 csrrw sp, mscratch, sp # 如果原来就在目标栈上,则跳过保存 # 为保存上下文分配空间 addi sp, sp, -256 # 保存通用寄存器 sd x1, 0(sp) sd x2, 8(sp) sd x3, 16(sp) # ... sd x31, 248(sp) # 读 mcause,判断异常类型 csrr a0, mcause # 转给 C 函数处理 call handle_trap_c # 恢复寄存器 ld x1, 0(sp) ld x2, 8(sp) # ... ld x31, 248(sp) addi sp, sp, 256 # 换栈换回去 csrrw sp, mscratch, sp mret注意,这里的x2就是sp,所以先分配好栈空间再保存x2,或者在分配之前先把原始sp保存到临时寄存器。实际操作里很多人会在这里翻车,因为换栈的时序没理清。
4.2 用mscratch实现“换栈”的经典技巧
为什么要“换栈”?因为异常发生时,原始栈可能是用户栈,也可能是栈指针本身就是非法的(比如栈被破坏、栈指针越界)。如果trap handler继续用原来的sp,可能连第一条保存指令的数据都写不进去,现场就丢了。
RISC-V提供的mscratch寄存器就是干这个的。常见做法:
- 开机初始化时,把mscratch设置为一个可靠的trap栈顶地址。
- trap发生时,执行
csrrw sp, mscratch, sp。这条指令交换sp和mscratch。 - 现在sp变成了trap栈顶,mscratch保存了原来的sp。
- 处理完后,再次执行
csrrw sp, mscratch, sp,把原始sp换回来。
这个技巧好就好在只需要一条指令,而且不会覆盖任何通用寄存器。我第一次看别人这样写时觉得简单,等自己调时才发现,最怕的是trap嵌套:第一次trap还没处理完,又来了中断,如果mscratch已经被交换过,第二次进入时会又把sp换到某个未知地方。因此,在设计长期运行的系统时,中断嵌套必须小心处理,通常要在handler开头就关中断,或者用“双栈切换”方案。
4.3 收尾:mret之前必须检查的三个状态位
很多新手写完handler,恢复完寄存器直接mret,结果程序跑飞。我在调试时总结出三个必须检查的地方:
- mstatus.MPIE是否等于1:如果进入trap前,MIE是1(允许中断),硬件会把MPIE置1。你必须在mret前保证MPIE保持为1,这样mret执行后MIE才能恢复为1。
- mepc是否正确:如果handler在处理过程中修改了mepc(比如要跳过某条指令或模拟某条指令),一定要在mret前确认mepc已经是你想要的地址。
- MPP是否等于异常发生前的特权级:如果MPP被错误设置为U-mode,但实际异常发生在S-mode,mret就会错误地回到U-mode,导致权限崩溃。
在S-mode下,对应的是sstatus.SPIE和spp。同理,也需要检查。
这里我给一个非常简单的C函数处理例子:
void handle_trap(void) { uint64_t cause = csr_read(mcause); uint64_t epc = csr_read(mepc); if ((cause >> 63) & 1) { // 中断:调用中断处理 handle_interrupt(cause & 0xff); } else { switch (cause) { case 2: printf("Illegal instruction at 0x%lx, inst=0x%lx\n", epc, csr_read(mtval)); csr_write(mepc, epc + 4); // 跳过非法指令 break; case 8: // u-mode ecall, 处理系统调用 syscall_handler(); csr_write(mepc, epc + 4); break; default: panic("Unknown exception, cause=%lx, epc=%lx\n", cause, epc); } } }注意这个例子里,对mepc加4是假设非法指令或ecall都是32位指令。在RV64下如果压缩指令扩展(RVC)开启了,非法指令长度可能是2字节,不能简单加4。实际项目里一定要检查指令编码长度,这是一个常见坑。
5. 在NEMU和自研CPU中踩过的Trap坑
5.1 “bad trap”到底是什么
在NEMU(南京大学PA项目中自研的模拟器)里,如果程序执行了无法处理的指令或状态,NEMU会报“bad trap”并停机。这个“bad trap”并不是RISC-V规范里的正式术语,而是模拟器里一个兜底处理:当模拟器发现自己无法继续模拟下去,比如遇见了未知指令、CSR访问异常,或者程序主动触发了一个“神秘指令”用来结束运算时,它就把当前trap状态当作“bad trap”打印出来。
常见触发原因有:
- CPU执行了0x00000000(全零指令)——没有实现或不支持。
- 访问了未映射的物理内存地址。
- CSR读写时权限不足。
- 程序跳转到了一个奇怪的地方,取指失败。
我调试NEMU时,遇到“bad trap”后的第一步就是看它打印的nemu_trap寄存器或某个状态值,以及对应的PC。PC很多情况下能直接指出问题:如果PC指向了奇怪的地址,多半是返回地址被破坏;如果PC指向了一个数据段,那可能是栈溢出写坏了返回地址。
5.2 流水线CPU实现异常时容易漏的点
如果你在写RISC-V流水线CPU,这里有一个我踩过的大坑:异常必须在指令“提交”(commit)时才正式生效,不能在流水线前段就修改CSR。
为什么?因为流水线里有分支预测和乱序(如果有),前面的指令可能还在猜测执行。如果在EX阶段检测到异常就立刻写mepc,但这条指令最后被取消了,mepc就写错了。正确的做法是:把异常信息跟着指令流水往下传,在提交阶段(通常是写回后或访存后)才真正更新CSR,并冲刷流水线。
另一个容易漏的点是Ecall和非法指令的PC保存。异常指令的mepc应该保存那条指令自己的PC,Ecall允许在返回时跳到下一条指令,但mepc本身还是指向Ecall那一条。很多人把mepc保存成下一条PC,结果mret回来少执行了一条指令。
还有异常优先级:在硬件里,同一拍要检查多个指令的异常。比如访存阶段发现load地址越界,同时这一条load又是非法指令,必须按规范优先级选。如果选错,调试时就非常诡异——明明有更严重的非法指令,硬件却报了load fault。
5.3 调试异常问题的四个实用招
我调试陷阱处理时,会按下面顺序来:
- 检查mtvec是否设置正确。很多裸机程序跑飞,就是因为没有初始化mtvec,异常直接跳到了随机地址。
- 在trap handler入口加一个打印。打印mcause、mepc、mtval,以及发生异常时的sp。这三板斧能解决90%的问题。
- 检查CSR的读写权限。在U-mode访问M-mode的CSR会触发非法指令异常;在S-mode写mstatus的某些位也可能被忽略或触发异常。
- 对比RISC-V规范里的异常码表。有时候你以为报的是“非法指令”,其实是“指令访问错误”,两者处理方式完全不同。
下面是一张常用异常码表,建议收藏:
| 异常码 | 描述 | 典型场景 |
|---|---|---|
| 0 | 指令地址未对齐 | 跳转到非对齐地址 |
| 1 | 指令访问错误 | 取指时物理地址不可读 |
| 2 | 非法指令 | 指令解码失败 |
| 3 | 断点 | ebreak指令 |
| 4 | load地址未对齐 | lw访问未对齐 |
| 5 | load访问错误 | load到非法物理地址 |
| 6 | store/AMO地址未对齐 | sw访问未对齐 |
| 7 | store/AMO访问错误 | store到只读或非法地址 |
| 8 | 环境调用U-mode | ecall from U |
| 9 | 环境调用S-mode | ecall from S |
| 11 | 环境调用M-mode | ecall from M |
| 12 | 指令页错误 | 取指MMU缺页 |
| 13 | load页错误 | load MMU缺页 |
| 15 | store/AMO页错误 | store MMU缺页 |
在S-mode下,下表对应scause。操作系统缺页异常就是13/15,Linux中do_page_fault会查看这些值。很多时候你看内核日志里报“Unable to handle kernel paging request at virtual address ...”,背后就是这些异常码。
最后一个实用技巧是:在实现异常处理时,可以在每个可能出错的地方设置一个“哨兵CSR”或者全局变量,记录“上次经过的函数”。这样一旦发生bad trap,不仅能打印PC,还能顺着记录找出是哪个调用路径进入了异常。我在NEMU里加了一个简易的backtrace,打印最近的函数调用点,调试效率提升了一个档次。
总的来说,Trap和Exception看着是几个词,深挖下去其实是CPU和操作系统协作最核心的机制。把异常优先级的硬件实现、CSR的原子更新、软件的上下文保存都串起来,你就掌握了RISC-V中断异常体系的骨架。我最大的体会是:不要只背概念,一定要亲手写一次trap handler,再在模拟器里故意制造一个非法指令,亲眼看完处理前后的所有CSR变化,这个知识点就彻底长在身上了。