1. 为什么我把中断流程当作理解三种指令集的钥匙
先交代一下这篇文章的来头。我最近在几块不同架构的开发板上做同一个内部分发逻辑的移植,x86 上跑得好好的代码,挪到 RISC-V 和 ARM 上就频繁丢中断,甚至死锁。排查到最后,问题全部出在同一个环节:我把 x86 的“硬件会自动压栈、自动关中断”的思维惯性,硬套到了其他架构里。RISC-V 的中断入口好像什么都没帮你做,ARM 帮了一部分,x86 却几乎把能做的都做了。
这种差异,恰恰给了我们一条特别好的学习主线:中断流程。
不管哪一种指令集,一次中断的完整旅程都绕不开同样几件事:外设发出事件、中断控制器收集和仲裁、CPU 核在合适的时机响应、跳转到固定的入口、保存当前任务的现场、识别中断来源、调用对应的处理函数、处理完后恢复现场并返回。区别在于,这一路上每一步的“工作量”在硬件和软件之间如何分配。RISC-V、ARM、x86 在这条流程上的每个分岔路口,都做出了完全不同的选择。
这些选择不是拍脑袋决定的,背后是各自的诞生场景和主打市场。x86 诞生于个人电脑时代,软件形态极度多样,一个处理器要走遍无数操作系统,所以硬件把现场保存这种基础操作包办得越彻底,兼容性越好;ARM 长在 SoC 和移动终端里,要同时兼顾能效、可配置性和 RTOS 实时性,于是选择了给软件留灵活的接口;RISC-V 则走极简开源路线,宁可把保存现场这种活全部交给编译器、汇编模板和操作系统,也要把指令集自身的状态机做得小而清晰。
如果你只是看指令手册里的某几个寄存器,很难感受到这三种架构的“性格”。但跟着一次中断从设备触发到返回用户态的完整路径走一遍,你会很清楚看到:一种架构在设计时把复杂度放在哪里,它未来适合干什么。换句话说,中断流程就是观察体系结构设计哲学的一条主线。
这篇东西适合三类人:正在学操作系统或者计算机组成原理的学生,想从某一个具体机制切入理解三种指令集;做嵌入式或底层开发的工程师,需要把代码在不同核之间移植,尤其想搞明白 trap、异常向量、中断控制器的差异;还有一个群体我尤其推荐——只熟练 x86 或 ARM 其中之一、想跨到 RISC-V 的人。中断是所有“世界切换”的必经之路,看懂这一条线,再去看任务调度、系统调用、虚拟化,都会顺很多。
2. 设备触发与中断控制器:PLIC、GIC、APIC 的分工逻辑
2.1 中断的第一站:谁来决定“CPU 现在需要管这件事”
中断流程的起点不在 CPU 核里,而在中断控制器里。CPU 本身不认识网卡、UART、定时器这些外设,它只认识有限的几个输入信号,比如 RISC-V 上的外部中断引脚、ARM 上的 IRQ/FIQ 输入、x86 上的 LINT0/LINT1 输入。一台机器可能有几十上百个中断源,CPU 不可能给每个设备都准备一根线,所以中间必须有一个“中转站”负责收集、仲裁、屏蔽并按优先级把最有资格的那个事件提交给 CPU。
这个中转站的形态,直接决定了后续 CPU 侧所有处理流程的复杂程度。
RISC-V 在规范里把中断源分成了两类:本地中断和全局中断。本地中断包括软件中断和定时器中断,通常由一个叫 CLINT 的组件管理,每个 CPU 核各有一份,独立发给本核;外部设备中断则统一交给 PLIC(Platform-Level Interrupt Controller)。对于跑裸机或小 RTOS 的单核场景,PLIC 可以做得非常简单:它把所有外设中断源收集起来,按照配置好的优先级,把最高优先级的 pending 中断路由到目标 CPU。
ARM 这边你基本绕不开 GIC(Generic Interrupt Controller)。GIC 比 PLIC 复杂了一个量级,因为它要同时处理 SPI 共享外设中断、PPI 私有外设中断和 SGI 软件生成中断,还要服务于多核系统里“哪个 CPU 处理哪个中断”的路由决策,甚至要支持安全世界和非安全世界之间的中断隔离。GIC 内部维护了一整套中断状态机:每个中断可以是 inactive、pending、active、active and pending 这几种状态之一,硬件会帮系统跟踪这个中断正在被谁处理,处理到哪一步。
x86 的处理方式又不一样。现代 x86 系统用的是 APIC 架构,细分成 Local APIC 和 I/O APIC 两部分。I/O APIC 负责把外部设备的中断请求收集起来,根据一张重定向表,写入对应 CPU 的 Local APIC;Local APIC 收到后,直接向 CPU 核提交一个具体的中断向量号。x86 的特别之处在于,它在中断控制器这一层就已经把“哪个设备”翻译成了“哪个编号”,这个编号在后面几乎全程跟着流程走。
2.2 RISC-V 的 PLIC 是裸机风格的中断交换机
PLIC 的编程模型相当“裸”。拿最常见的操作方式来说,外设中断到达后,PLIC 会把对应的中断源标记成 pending,如果它的优先级大于等于阈值,并且使能寄存器里允许它发给当前 CPU,PLIC 就会向 CPU 核拉高外部中断请求线。
然后关键来了:CPU 进入中断入口之后,软件要去读 PLIC 的一个 claim 寄存器,才知道“到底是谁触发了这次中断”。读完 claim,这个中断的状态就从 pending 变成 active,相当于硬件把号码交到你手里,同时告诉你“这件事现在由你接管了”。处理完之后,软件再写一个 complete 寄存器,告诉 PLIC 这单我可以收工了,PLIC 才会允许相同来源的下一次中断再进来。
中断号不是 CPU 通过某条指令或者某个系统寄存器自动拿到的,而是 MMIO 读取。这跟 x86 的“中断号自动送到 CPU”相比,多了一步软件参与,但它带来的好处是路由逻辑可以做得极其简单,不需要在 CPU 内部为“自动读取中断号”这种操作准备专用总线协议。很多 RISC-V 板子的 PLIC 实现,中断源数量、优先级位数都可以裁剪,OpenSBI 和几种主流 RTOS 对它的封装也都很薄,一眼看懂。
2.3 ARM 的 GIC 是一个带状态机的分发服务台
GIC 的架构分两部分看:一个叫 Distributor,负责全局中断的使能、优先级配置和路由;另一个叫 CPU Interface,负责跟具体的 CPU 核打交道。中断状态机主要是 Distributor 在维护。
外设触发后,中断进入 pending。Distributor 根据优先级仲裁,决定把它发给哪一个 CPU Interface。CPU Interface 产生 IRQ 信号给core,core 进入中断异常后,软件去读 CPU Interface 里的ICC_IAR1_EL1寄存器。这个读操作有两个作用:一是拿到中断 ID,二是硬件级执行了 acknowledge——中断从 pending 变成 active。之后软件处理中断,写完ICC_EOIR1_EL1做 end of interrupt,硬件才允许下一个同优先级或更低优先级的中断被确认。
这里有一个很多人第一次接触时不适应的地方:ARM 的处理程序和 RISC-V 一样,都需要主动去控制器“要”中断号,而不是 CPU 入口就知道。但 GIC 的状态机比 PLIC 完整得多,它允许软件把“acknowledge”和“deactivate”拆开,这对嵌套中断和虚拟化场景有实际价值。GIC 并不只是简单转发一个电平,它像一个服务台,帮你记录一个工单从登记到完结的整个生命周期。
2.4 x86 的 APIC 偏爱“把所有中断变成号码”
再看 x86。I/O APIC 的每一路中断源在重定向表里都对应一个条目,条目里写着目标 CPU、触发方式、中断向量号。设备触发时,I/O APIC 把中断作为一条消息发给目标 Local APIC,Local APIC 根据向量号直接向 CPU 请求“处理向量 35”。CPU 响应后,处理器本身就知道应该跳到 IDT 的哪一项。
x86 的向量号是一个 0 到 255 的数字空间,其中 0 到 31 被处理器异常和 NMI 占用,32 以上可以由外设映射。也就是说,从外设到达 CPU 的那一刻起,中断就已经被“编号化”“列表化”了。软件不需要通过读取控制器寄存器去反推中断源,它只要看自己是从哪个向量进来的就够了。
代价是这个模型不够灵活。它把中断源和向量号绑得比较死,路由策略、优先级模型都建立在“编号”这个概念之上,一旦你要实现 MSI 消息中断、多核负载均衡,就得引入更多复杂机制。不过在 x86 生态里,这种“全部编号化”的思路换来了巨大的软件兼容性,这也是它能从 1980 年代一路进化到今天仍然不换底子的原因之一。
2.5 一张表总结中断控制器差异
| 对比项 | RISC-V PLIC | ARM GIC | x86 APIC / IOAPIC |
|---|---|---|---|
| 中断来源分类 | 本地中断 + 全局中断 | SGI / PPI / SPI / LPI | Local中断 + I/O中断 + MSI |
| 仲裁主体 | PLIC,简单优先级比较 | Distributor,完整状态机 + 优先级 | I/O APIC路由 + Local APIC本地仲裁 |
| CPU如何知道中断源 | 软件读 claim 寄存器反查 | 软件读 ICC_IAR1_EL1 反查 | CPU硬件直接获得向量号 |
| 完成通知方式 | 写 complete 寄存器 | 写 ICC_EOIR1_EL1 | 写 Local APIC EOI 或自动EOI |
| 多核路由支持 | 基础支持,按 Hart 使能 | 强,可配置中断亲和性 | 支持,通过重定向表指定目标 CPU |
| 状态机复杂度 | 低,直观 | 高,适合大型SoC | 中高,偏向固定模型 |
从这张表能看出来,ARM 和 RISC-V 都是“控制器认识中断,CPU 之后才认识中断”,x86 是“控制器和 CPU 在中断号上达成共识”。这一差别贯穿了整个中断流程,后面的所有动作都受它影响。
3. 从入口地址到向量表:CPU 如何知道自己该跳去哪里
3.1 RISC-V:跳到一个异常的“总接待室”
外设中断信号到达 CPU 核后,能否触发 trap,取决于两个条件:全局中断开关 mstatus.MIE 是否为 1,以及对应中断源在 mip/mie 寄存器里的位是否使能。满足条件后,CPU 会在当前指令边界响应,然后做一件看起来非常“极简”的事:跳转到 mtvec 寄存器指定的地址。
不熟悉 RISC-V 的人可能会问:跳过去之后怎么知道是哪种中断?答案是查 mcause 寄存器。硬件会把本次 trap 的原因写进 mcause,最高位是 1 表示中断,低位置表示具体类型,比如 3 是机器定时器中断、7 是机器外部中断。随后软件自己判断这是定时器、外设还是软件中断。
在 mtvec 的设计上,RISC-V 提供了一个可选模式:MODE 位为 0 时是 direct 模式,所有 trap 都跳到同一个基地址;MODE 位为 1 时是 vectored 模式,发生中断时跳转到 BASE + 4 × mcause 对应的表项。这里我提醒一句:很多资料里“RISC-V 中断不能向量化”的说法并不准确。标准是支持 vectored 模式的,只是一些常见的软件栈为了统一分发代码,刻意选择了 direct 模式,所有中断先进同一个入口,再根据 mcause 分派给软件层。
从工程角度看,direct 模式更接近一个大楼的总接待室:不管你是来报修电路还是找人的,先到前台登记,登记完再被领到具体办公室。好处是入口代码只需要维护一套,对 I-Cache 友好;坏处是多了一次软件分发,中断延迟会比直接跳转到对应处理函数略高。对于通用操作系统,这点延迟差别通常可以接受;对于超低延迟实时场景,就需要仔细评估是走 vectored 模式放一套跳板,还是接受 direct 模式的开销。
3.2 ARM:同一份异常向量,外设依赖 GIC 的“号牌”
ARM 在 Cortex-A 系列上使用 VBAR 寄存器指向异常向量表。以 AArch64 为例,每个异常级别都有自己的向量表,即 VBAR_EL1、VBAR_EL2、VBAR_EL3。向量表里的槽位按异常来源和执行状态划分:有同步异常、IRQ、FIQ、SError,再分使用的是 SP_ELx 还是 SP_EL0,排列组合成一组固定布局。每项之间相隔 0x80 字节,通常放一个跳转指令,跳到真正的处理函数。
注意,这里的“向量表”不是给每个外设中断源一个独立表项,而是所有 IRQ 中断共享同一个 IRQ 槽位。比如树莓派上无论网卡、USB 还是 GPIO 触发的中断,在 CPU 侧都是走进 VBAR_EL1 里的 IRQ Exception 这一格。真正的中断源区分,要等软件去读 GIC 的ICC_IAR1_EL1拿到中断 ID 之后才能完成。这一点跟 RISC-V 有相似之处——入口统一,具体来源靠软件查控制器;跟 x86 的“一个外设一个独立入口”完全不同。
但是在 Cortex-M 系列上剧情反转了。Cortex-M 的中断向量表是真的向量化:向量表按中断号排列,每个外设中断源都有自己独立的一个入口地址,CPU 响应中断时直接从这个向量表里取出地址跳转,不需要软件去反查。这也就是为什么许多做 Cortex-M 实时开发的工程师,习惯了“每个外设想有自己的 ISR 就直接放表里”的模式,跑到 Cortex-A 上会很不适应。
3.3 x86:每个中断号就是一条门,打开门有地址
x86 的设计天然跟向量化的思路绑定。中段向量号从 0 到 255,IDT(中断描述符表)里最多有 256 个门描述符,每个描述符包含目标代码段选择子和入口偏移。当 CPU 收到一个外部中断向量,例如 33,它会拿这个 33 直接索引 IDT,做权限检查后跳转到第 33 项指定的处理函数。
所以严格说,x86 的中断入口天然是“每个向量一条路”,不存在“先进一个总入口再分类”的通用模式。当然操作系统可以刻意把所有向量都指向同一个汇编入口,再根据 vector 寄存器去分辨,但从硬件角度看,它设计之初就是编号索引。
IDT 门描述符还区分了中断门和陷阱门:中断门在进入时会自动清 IF,起到禁止后续外部中断的作用;陷阱门不清 IF,主要用于系统调用这类场景。这意味着,x86 把“现场保护前是否允许再被中断”这个策略,直接在门类型层面做成了硬件特性。
3.4 向量化与统一入口,不是优劣问题
很多人看到这里会纠结一个问题:到底向量化好,还是统一入口好?
以我自己的项目经验来说,要分层看。在 Cortex-M 和 x86 这种“中断号到向量一一对应”的模型里,进入 handler 的速度非常快,省了反查控制器的一步。代价是硬件和中断号空间必须设计得足够确定,哪怕某个系统里只用 5 个外设中断,向量表也得预备好足够的表项,或者依赖软件做一定跳板。
而在 ARM Cortex-A 和 RISC-V 的主流 Linux 路径里,统一入口 + 软件查控制器反而更合理。因为 Linux 本身的中断子系统有复杂的优先级、线程化、动态分配 IRQ 的机制,它希望把底层异常入口压缩到最小,让所有中断在软件这一层重新组织。如果你硬把每个外设都做成一个硬件向量入口,表项会非常庞大,而且 Linux 的 irq domain 还需要额外的一层映射,有点画蛇添足。
本质上这是一种取舍:硬件替你分类,延迟低但僵硬;软件替你分类,灵活但多几次读操作。RISC-V 给两种模式,ARM 在不同产品线里做两种模式,x86 因为历史包袱和软件兼容性,锁死了“门描述符+向量号”这一条路。沿着这条主线继续看,你会发现更妙的还在后面的现场保存。
4. 现场保存与特权切换:三种硬件替软件干了多少活
4.1 中断现场到底要保哪些东西
中断发生时,正在运行的程序可能执行到任何一条指令。处理器必须把“将来还要接着执行”所需的信息保存起来,否则一进中断,原来的状态就被覆盖了。具体来说,现场至少包括:返回地址、处理器状态字(如标志位、中断使能位、当前特权级)、通用寄存器、栈指针。其中返回地址和状态字是 CPU 自身状态的一部分,通用寄存器是程序状态的一部分。
不同架构的分歧在于:硬件自动保存哪些,软件负责保存哪些。这个选择会直接影响中断延迟、上下文切换开销、可重入性和异常处理代码的复杂度。
x86 选择了“硬件尽量多保存”。普通程序从用户态被中断打断时,CPU 会先根据目标特权级找到新的内核栈,然后把用户态的 SS、ESP、EFLAGS、CS、EIP 依次压入新栈;如果这是带错误码的异常,还会把错误码也压进去;如果发生了特权级切换,处理器还会从 TSS 里自动加载新的 SS0 和 ESP0。也就是说,x86 硬件已经把“从用户态切换到内核态”的半个现场保存做完了,软件只需要再保存通用寄存器。
ARM 的 Cortex-A 选择了“保存关键状态到专用寄存器”。进入异常时,PC 会保存到 ELR_ELx,PSTATE 会保存到 SPSR_ELx,异常原因写进 ESR_ELx,中断标志自动屏蔽。但是通用寄存器完全由软件保存,栈不一定自动切换——虽然 EL 切换时会选择对应级别下的 SP,但具体栈顶地址还是要由软件提前布置好。
RISC-V 则是最“省”的状态。它只把 PC 写到 mepc、把中断原因写到 mcause、把原 mstatus 里的 MIE 搬到 MPIE、全局关中断、记录之前的特权级到 MPP。通用寄存器、栈指针一概不管,更不会帮你把任何东西压到内存栈里。你进入 trap 的第一行代码通常就是几十条保存指令。
4.2 RISC-V 把保存活全留给软件,却提供了交换 SP 的暗门
RISC-V 的 trap 入口最开始实现的时候,很多写过 x86 汇编的人会心里犯嘀咕:这哪里是中断入口,分明是让我手搓一个函数调用。
但 RISC-V 也不是完全不管。它在机器模式里提供了一个叫 mscratch 的 CSR,专门用来加速上下文保存时的栈切换。常规套路是这样的:系统启动时把 mscratch 指向一块专用中断栈;进入 trap 后,第一条指令执行
csrrw sp, mscratch, sp这条指令会把 sp 和 mscratch 的值互换,一次完成两件事:当前程序的原始栈指针被存进 mscratch,sp 被切到专用中断栈。这之后你就可以在专用栈上压入通用寄存器,而不用担心破坏了被中断任务自己的栈。退出中断时,再执行一次csrrw sp, mscratch, sp,把原始 sp 换回来,然后用mret返回。
这里有一个很关键的经验:csrrw是原子交换,不是简单的 load 和 store。在写裸机 trap 时,一定要保证执行这条指令之前,mscratch 里放的是中断栈顶,而不是某个随便的值。否则第一次进中断就会把未知地址写进 sp,随后所有压栈操作都会写到不可预知的内存区域,排查起来非常痛苦。我早期调试时就遇到过这种诡异崩溃,打印出来的 PC 还在正常跑,sp 却变成了一个完全离谱的值,最后发现是启动汇编里忘了初始化 mscratch。
RISC-V 把现场保存开销压到最低的办法也依赖这条思路:只交换 sp,不拷贝任何数据。它在硬件上只花了一条 CSR 读改写的时间,就把“当前任务栈”和“中断处理栈”切开了,这个设计的精巧程度,虽然刚上手时觉得不方便,理解之后会印象很深。
4.3 ARM 在硬件帮一半与软件灵活之间找平衡
ARM Cortex-A 的态度是:把“跟 CPU 本身强相关的状态”存进专用寄存器,通用寄存器这堆“跟程序强相关的状态”留给软件。
举例来说,AArch64 进入 EL1 的 IRQ 异常时,硬件会做这几件事:把当前 PC 复制到 ELR_EL1,把当前 PSTATE 复制到 SPSR_EL1,把异常类型写进 ESR_EL1,自动把 PSTATE 里的 I 位设成 1 屏蔽 IRQ,同时根据目标异常级别切换 SP。这套动作的好处是,软件进入异常处理代码时不需要为“我刚才处于什么模式、被打断的指令在哪里”发愁,状态都锁在专用寄存器里了。
但是通用寄存器的保存,仍然需要一个通用的汇编宏或者代码块来处理。因为 ARM 的可变子集和不同 EL 级别,很多人会写一套kernel_entry宏,在 IRQ 入口统一保存 x0—x18 等寄存器,再加上原始 SP 和 LR。在 Linux 内核里,el1_irq这个 vector 入口就是先保存寄存器帧,再调用handle_arch_irq。
ARM 之所以不学 x86 那样自动压栈普通寄存器,我理解是为了保留调度和实时性上的自由度。如果硬件把所有通用寄存器都自动压栈,那每次中断无论轻量与否都要承担很大的栈写带宽;改用软件按需保存,RTOS 可以针对性地省去部分保存,也能自由决定中断使用哪个栈、是否启用双栈。这是 ARM 面向多样化 SoC 市场的自然选择。
4.4 x86 的自动压栈,是最难被别的架构复制的部分
x86 的现场保存里我最欣赏的其实不是“保存了很多东西”,而是它把“在特权级切换时自动找新栈”这件麻烦事给硬件化了。当用户态程序发生中断,CPU 会读取当前任务 TSS 里的 SS0 和 ESP0,把栈切到内核栈,然后自动压入用户态 SS、ESP、EFLAGS、CS 和 EIP。这套机制保证了无论用户态把栈搞成什么样,中断入口总有一个可靠的内核栈可用。
这个特性在 x86 上运行了数十年,兼容了无数操作系统,有一个显著后果:操作系统的中断入口代码非常简单。Linux 或 Windows 的汇编入口不需要像 ARM/RISC-V 那样先做一轮栈切换,因为硬件已经替你做了;直接从栈顶开始存取现场寄存器就可以了。
另外,x86 还提供了 IST(Interrupt Stack Table)机制,可以对特定中断向量指定独立的栈。比如 NMI、机器检查这类不可屏蔽异常,即使当前在内核态且栈已经损坏,也能通过 IST 切换到一块安全栈上执行处理逻辑。这种“故障时兜底”的设计,在服务器可靠性场景里非常重要。相比之下,RISC-V 的 mscratch 也好、ARM 的 SP_ELx 也好,都需要软件自己预先配置,逻辑上等价,但没有 x86 全套硬件自动加载这么顺手。
当然,自动压栈并不是没有代价。硬件把这些信息按固定格式压栈,意味着处理器的异常入口格式被锁死,任何想改动这个栈帧布局的尝试都会破坏软硬件兼容性。更麻烦的是,每次中断即使只是处理一个非常简单的事件,用户态到内核态的切换也必然伴随完整的一轮栈切换和旧栈信息保存。好在这部分开销对现代 CPU 来说相对可控,而且换来的是软件极低的入口复杂度。
4.5 现场保存职责的对照表
| 对比项 | RISC-V(机器模式) | ARM(Cortex-A AArch64) | x86(保护模式) |
|---|---|---|---|
| 返回地址 | 保存到 mepc | 保存到 ELR_ELx | 压入内核栈(CS:EIP) |
| 状态字 | 压缩进 mstatus.MPIE/MPP | 保存到 SPSR_ELx | 压入内核栈(EFLAGS) |
| 通用寄存器 | 软件保存 | 软件保存 | 软件保存 |
| 栈切换 | 软件用 mscratch 交换 | 硬件按 EL 选择 SP_ELx | 硬件从 TSS 加载 SS0:ESP0 |
| 异常原因 | 写入 mcause | 写入 ESR_ELx | 向量号已经跳转到特定门 |
| 出错码 | 不自动提供 | 不自动提供 | 部分异常硬件压栈错误码 |
看完这张表,你应该能理解为什么三种架构的“坏栈”表现不一样。x86 在用户态坏栈不会立即坑到中断入口,因为硬件会从 TSS 取新栈;ARM/RISC-V 的软件入口必须自己做好栈准备,否则中断一来,第一脚就踩空。
5. 屏蔽、优先级与嵌套:中断之间的“谁能打断谁”
5.1 中断屏蔽位:先关自己,再保护现场
中断处理需要一段“不被打扰的窗口”,至少要把刚进中断时最关键的现场保存代码执行完。三种架构都提供了相应的屏蔽手段,但实现位置和粒度差别很大。
RISC-V 的屏蔽主要靠 mstatus.MIE 与 mie 寄存器。发生 trap 时硬件自动把 MIE 清零,机器模式下的所有可屏蔽中断都被挡在门外。这样从 trap 入口到保存完上下文这段代码,天然不会被中断打断。如果你想开嵌套,就必须在保存完现场后,显式用csrsi mstatus, 0x8把 MIE 重新置 1。
ARM Cortex-A 的做法类似,进入异常后 PSTATE 的 I 位自动置 1,IRQ 被屏蔽。但 ARM 多了 FIQ 一说,如果 FIQ 没有被屏蔽,高优先级 FIQ 仍然可能打断 IRQ handler。这类精细的控制在某些安全敏感或实时场景很有价值。
x86 则通过中断门自动清 IF,同时拥有 RFLAGS.IF 作为总开关。有意思的是,x86 还允许执行STI手动重新打开 IF,开启嵌套中断,但操作系统在实际使用中通常不会在同一个内核栈上毫无节制地嵌套开中断,否则栈溢出风险很高。
5.2 嵌套为什么高风险
嵌套中断天然是 bug 高发区。最常见的问题就是:第一次中断还没保存完现场,第二次更高优先级中断就进来了,并且复用了同一个栈,把第一次中断的上下文踩掉。
这个问题在 x86 上容易理解:用户态到内核态自动切栈,内核态同级中断却不切栈,如果在内核 handler 里 STI 打开中断允许嵌套,新中断会压在同一内核栈上。每嵌套一次,栈就往深处走一点,你无法预知最深会到哪一层,极端情况下栈溢出。RISC-V 和 ARM 一样面临这个问题:专用中断栈大小必须预留好嵌套深度,否则更容易踩踏。
嵌套的另一个风险是重复 EOI 或者漏掉 EOI。在 GIC 里,软件读 IAR 之后中断才变成 active;处理完后必须写 EOIR。如果嵌套过程中没有管理好顺序,低优先级中断的 EOIR 写晚了,高优先级中断也许永远无法再次触发,形成活锁。
5.3 三种硬件的嵌套安全套路
先说 RISC-V 的常用套路。进入 trap 后先绝不开启 MIE,用一个“临界区”把通用寄存器保存到专用栈里。保存完成后,软件主动把 MIE 置 1,这时候更高优先级中断才被允许进入。但为了实现优先级逻辑,你会配合 PLIC 的优先级阈值寄存器:把当前正在处理的中断优先级设为阈值,这样只有比它优先级更高的中断才能触发 CPU。处理完高优中断后,返回前必须再把阈值降回来,并保持 MIE 关闭,直到 mret 退出。
ARM 的 GIC 把优先级控制和 CPU 接口做得更一体化。GIC 会对每个中断分配优先级;你要嵌套,核心是读完ICC_IAR1_EL1拿到中断号、确认当前中断已经成为 active 状态之后,再重新打开 DAIF 的 IRQ 位。这时 GIC 硬件知道当前 active 中断的优先级,若另一个更高优先级中断到来,它会再次向核发 IRQ。软件保存好第一层现场之后,整个过程就能安全嵌套。
x86 的做法比较直白:中断门自动清 IF,然后由软件决定何时 STI。现代服务器操作系统为避免嵌套导致的内核栈膨胀,大量使用“中断线程化”或者“软中断延迟处理”的思路,尽量让硬件中断处理阶段保持关中断状态,真正的业务逻辑放到下半部慢慢做。x86 还提供了 TPR/PPR 这类本地 APIC 寄存器,让操作系统可以给当前 CPU 设定一个中断接受阈值,管理哪些中断能打进来。
从我实际调试经验看,无论哪种架构,安全嵌套的黄金法则是:保存完现场之前绝不重新开中断,重新开中断之前必须先确认自己使用的栈已经被切到安全的中断专用栈上。这个法则在三种架构上通用,只是 RISC-V 需要你写更多汇编来亲自落实。
5.4 实际操作里常见的中断丢失 bug
顺着这个话题,分享几个我在项目里踩过的或者帮别人排查过的典型问题。
第一个是 RISC-V 下忘记在 trap 里写 complete 寄存器。如果你读了 PLIC claim 拿到中断号,处理完却没有写 complete,PLIC 会一直认为这个中断仍处于 active 状态,后续同源中断永远不会再变 pending。表现出来就是外设只触发一次中断,之后就“清零”了。排查思路是观察该中断源在 PLIC 里的状态寄存器,一直停在 active。
第二个是 GIC 下 EOI 写错了寄存器或者根本没有确认。GIC 的 EOIR 和 IAR 在 GICv3 中的寄存器地址是不同的;如果代码在中断处理中断言了ICC_IAR1_EL1,但结束时忘了写ICC_EOIR1_EL1,结果就是这个中断始终停在 active 状态。我遇过的真实表现是高优先级中断还能继续进来,低优先级中断被卡死,整个系统表现出非常奇怪的优先级反转。
第三个是 x86 下的 STI 延迟效应。x86 的 STI 指令并不会在它后面紧接着的那条指令生效,而是延迟到下一条指令执行完后才真正打开 IF。很多人在 STI 之后马上写一条敏感操作,以为已经开中断了,实际还没开;反过来,在 CLI 之前假设中断还开着,也可能埋雷。如果你在内联汇编里频繁开关中断,要多加一条空指令或者使用带 lock 前缀的操作来保证边界。
6. 返回路径:mret、eret、iret 里藏着体系的记忆
6.1 从流水线角度看返回指令的特殊之处
中断流程的最后一步经常被教程一笔带过,其实是整个设计里最容易出错的一环。返回指令不只是“跳回到原来的 PC”,它必须一并恢复特权级、恢复中断标志、恢复栈指针,有时候还要处理延迟的异常。
这类指令还有一个共性:对 CPU 流水线来说它们是“序列化指令”。CPU 在执行 mret、eret、iret 时,不能简单地把前面预取的指令继续执行,因为那些指令是旧的上下文环境下的,必须被冲刷掉;新上下文的指令从 PC 开始重新取指。因此这些返回指令的代价通常比普通跳转高很多。我在看一些性能 profile 数据时,曾把一个频繁触发中断的用户态程序中断入口和返回开销占到了将近 25%,很大一部分就是花在返回路径上。
6.2 RISC-V 的 mret 如何撤销一次 trap
RISC-V 的 mret 指令做的事情可以理解为“把 trap 发生时硬件保存的状态恢复回去”。它会从 mstatus.MPP 恢复之前的特权级,把 mstatus.MPIE 写回 MIE,再把 PC 设置为 mepc。这样一来,中断入口处软件手动保存的通用寄存器需要提前从栈里 pop 回来,但 CSR 层面的状态恢复,由 mret 一条指令完成。
这里有一个容易忽略的坑:RISC-V 的 mret 不会自动把 mepc + 4 或者做任何偏移修正。mepc 保存的是被中断指令的 PC,普通中断返回后,处理器应该重新执行那条指令。但对于某些需要“跳过当前指令”的异常场景,比如非法指令异常,软件必须在使用 mret 前手动修改 mepc,让它指向下一条合法指令,否则会原地再次触发异常,死循环。这个逻辑跟 ARM 的做法不太一样,ARM 的 ELR 同样保存发生异常的指令地址,也需要软件决定是否需要调整。
有些 RISC-V 实现里,mret 还有一个隐蔽性质:硬件会清除 mstatus.MPRV 等附加位。这影响的是地址翻译模式,如果你开了 MPRV 来模拟访存权限,mret 返回前记得重新设置,或者使用专门的上下文方案。
6.3 ARM 的 eret:一步把 PSTATE 换回来
ARM 的异常返回指令是eret。在 AArch64 中,执行 eret 时处理器会把 SPSR_ELx 里的值恢复到 PSTATE,同时跳转到 ELR_ELx。这样,异常进入时硬件保存的“处理器状态”就完整恢复到被打断之前的水平了。
需要特别注意的是,eret 不会自动决定你要返回 EL0 还是继续留在 EL1,它完全看 SPSR_ELx 里记录的前一个 EL 是几。所以如果你的