RISC-V、ARM、x86三个架构的中断流程,平时大家各讲各的,网上资料也多是单一架构的深入分析。我做了几年底层固件和系统移植,三种架构的中断代码都写过,最大的体会是:如果只看一张架构图去背寄存器,很容易绕晕;但如果顺着“中断请求进来之后,CPU到底替软件做了哪些事”这条主线去对照看,三个架构的差异其实非常直观。这篇文章我就用一条主线串起来讲,帮你一次性理清 x86、ARM、RISC-V 的中断流程到底各自高效在哪、开销在哪、陷阱在哪。
先说明一下适合谁看:正在做内核移植、驱动开发、RTOS BSP,或者对底层机制有好奇心的朋友。不需要你三种架构都写过,只要你对其中一种有基础认识,跟着主线走完,剩下的两个就很容易触类旁通。文章里我不会堆大段架构图,更多是把我实际调试中遇到的细节和经验写出来。
1. 中断的主线:从请求到返回,三种架构都逃不过的六个环节
我在带新人时经常问一个问题:CPU 收到一个中断,从硬件角度到底要干几件事?大多数人都能说出“跳转、执行、返回”,但再往细问就会卡壳。其实不管是 x86 还是 RISC-V,所有中断处理都绕不开六步。
第一步是中断请求的产生。外设、定时器、调试事件、软中断指令,甚至 CPU 内部异常,都会产生一个中断/异常请求。这个请求会进到中断控制器(x86 的 APIC、ARM 的 GIC、RISC-V 的 PLIC/CLINT),由控制器做优先级仲裁,再把有效信号送进 CPU 核。第二步是入口地址的确定。CPU 拿到中断后,要从预先设置好的表里找到对应的处理函数入口。x86 管这张表叫 IDT,ARM 叫异常向量表,RISC-V 叫 trap vector。第三步是硬件自动保存现场。这个环节差异极大——x86 会往栈上压一堆寄存器,ARM 会偷偷切换寄存器组,RISC-V 几乎什么都不替你保存。
第四步是跳转到处理函数。这里有一个容易忽略的点:进入中断后,CPU 通常会关闭中断使能位,防止同一中断反复嵌套;但要不要重新打开,各架构策略不同。第五步是软件在处理函数里手工保存剩余现场。注意,硬件保存的那部分只够让中断处理程序“能跑”,如果要支持 C 语言函数嵌套调用,剩下的通用寄存器就得由软件入栈。第六步是返回,也就是把入口时保存的现场原样恢复,跳到被中断的指令继续执行。这一步依赖一条专门的返回指令:x86 是 iret,ARM 是 movs pc, lr 或者 eret,RISC-V 是 mret/sret。
如果把六步拆开看,你会发现三种架构在设计哲学上的分歧点在哪里:x86 偏向硬件多做、软件少做,代价是硬件逻辑复杂;ARM 偏向硬件提供几个“切换副本”,兼顾硬件和软件;RISC-V 则是最小化硬件干预,把尽可能多的现场保存决策交给软件。理解了这条主线,文章后面的所有细节都是往这六个环节里填东西。
2. x86:IDT 驱动的“重栈”响应,现场由硬件一手包办
2.1 IDT 与门描述符:一张表决定中断去哪
x86 的中断入口查找,核心是 IDT(Interrupt Descriptor Table)。CPU 有一个 IDTR 寄存器,里面存着 IDT 基地址和限长。中断来的时候,CPU 拿到中断向量号,直接用“向量号 × 8 + 基地址”去查表,拿到一个门描述符(gate descriptor)。这个描述符里最关键的是目标代码段选择子和偏移量,以及类型位和 DPL(描述符特权级)。
门描述符有三种类型:任务门、中断门、陷阱门。任务门现在基本没人用了,主要存在于老式操作系统教材里,因为它会把 CPU 强制切换到另一个 TSS 任务,硬件开销极大。实际保护模式系统里,中断向量基本都用中断门或陷阱门。中断门和陷阱门的差异特别容易踩坑:中断门在进入时会自动把 EFLAGS 里的 IF 标志清零,也就是自动屏蔽新中断;陷阱门不会清 IF。当年我在一个教学内核里把时钟中断配成陷阱门,导致中断处理过程中不断被同一时钟中断打断,最后整个系统软死锁。所以写裸机代码时,千万别觉得两者只是名字不同。
2.2 特权级切换与 TSS 栈跳变
x86 的硬件现场保护,是三种架构里最“重”的。如果中断前的 CPL(当前特权级)和中断处理函数所在的 DPL 不同,比如用户态触发了系统调用或外设中断,CPU 会自动做栈切换:从 TSS 段里取出对应特权级的 SS 和 RSP,换到内核栈上执行。整个过程硬件替软件干了“换栈”这么一个大操作,对应的代价是 TSS 结构必须提前准备好,否则一进中断就是 triple fault。
栈切换之后,硬件会依次压入 SS、RSP、RFLAGS、CS、RIP;如果是异常,还会额外压入错误码。这意味着,一个用户态程序在执行过程中触发缺页异常,CPU 自动压到内核栈上的信息已经是六七个字段,软件什么都不干就能知道“我该从哪里恢复”。这种设计的历史包袱很重,但好处也非常实际:中断处理函数只要把这些值原样保存到任务结构里,就可以安全地做任务切换了。Linux 的进程上下文切换正是基于这一点。
2.3 NMI 与异常处理的分工
x86 的中断向量成为两个阵营:外部中断由 APIC 分配向量,典型从 0x20 到 0xFF;CPU 异常则占据前 0x00 到 0x1F。NMI(不可屏蔽中断)固定在向量 2 上,不受 IF 标志管控,进入方式和普通外部中断不同,硬件不会修改 IF,也不会被 APIC 屏蔽。因此 NMI 进来之后,现场保存更依赖软件自觉。
实操层面,我在 Linux 内核调试时最喜欢看的就是 /proc/interrupts 和 /proc/softirqs,前者告诉你每个 IRQ 线在哪个 CPU 上产生了多少次中断,后者告诉你软中断统计。这两个文件能快速验证中断分发是否均衡、有没有异常的中断风暴。写裸机代码时也建议从一开始就在中断入口放一个计数器,每进一次中断加一,配合串口打印,可以快速定位“中断有没有进来”和“是不是在疯狂重进”这两种截然不同的问题。
2.4 x86 实操易错点
切勿以为中断门自动清了 IF,嵌套就一定安全。如果中断处理函数里主动开了 IF,又没有给栈留足够余量,递归中断会直接吃爆栈。我调试过一个项目,中断处理函数里调用了 printf,printf 内部触发了别的中断,又因为栈是共享的,返回时栈指针全乱了,崩溃现场极其难看。后来规范做法是:中断处理函数里绝不主动开 IF,必须开时也先检查当前栈深度。
3. ARM:banked 寄存器与模式切换,硬件替你藏好了半边现场
3.1 异常向量表的 8 条指令布局
ARM 的异常向量表,经典布局是 8 个向量,每个向量占 4 字节(ARM 状态下)。前 8 个地址分别是:复位、未定义指令、SVC(软中断)、预取中止、数据中止、保留、IRQ、FIQ。向量表基址由 VBAR 寄存器指定,某些老架构还支持高向量模式,把向量表放到 0xFFFF0000。每个向量只有 4 字节空间,所以里面通常只放一条跳转指令,跳到实际处理函数的标签。
这个设计给调试带来的一个好处是,你可以在向量表里直接放一条死循环或者断点指令,看看特定异常是否被触发。我写引导代码时经常这么干:数据中止向量暂时填成一条 b . 指令,一旦发生非法访存,CPU 就停在向量表附近,PC 值直接告诉我异常来自哪里。这样定位内存问题非常快。
3.2 GIC 是怎么把外设中断送进 CPU 的
ARM 核外部的中断控制器事实标准是 GIC,现在常见的是 GIC-400 和 GIC-500/600 系列。GIC 由两个主要部分组成:Distributor 负责收集各 SPI/PPI/SGI 中断源,管理 enable、priority、pending 状态;CPU Interface 是和 CPU 核对接的接口,设备树里常看到的 reg 地址就是这么来的。
中断触发时,GIC 会通过 IRQ 或 FIQ 线向 CPU 核发信号。CPU 进入异常后,软件的第一件事是从 GICC_IAR(Interrupt Acknowledge Register)读出中断号。注意,读 IAR 这个动作本身就告诉了 GIC“中断我已经接受了”,硬件会把 pending 状态改成 active。处理完毕后,软件写 GICC_EOIR(End of Interrupt Register)结束中断。这里最容易漏的是:不读 IAR 就操作外设,或者忘了写 EOIR,导致中断一直被挂起、无法再次触发。我见过不止一次丢中断的 bug,最后发现是 EOIR 没写。
3.3 banked 寄存器:省掉压栈时间的巧妙设计
ARM 处理中断,有一个 x86 和 RISC-V 都没有的设计——banked 寄存器。进入 IRQ 模式时,CPU 自动使用物理上独立的 R8-R12、SP、LR,同时把 CPSR 保存到 SPSR_irq,把返回地址放到 LR_irq。这意味着进入中断的那一刻,硬件已经帮你完成了一部分“换寄存器组”的动作,而不是像 x86 那样往内存栈里压数据。
这个设计的好处是延迟低。中断响应路径上,ARM 不需要先执行一堆 STR 指令就能安全地调子程序。坏处是 banked 寄存器数量有限:只有 IRQ 和 FIQ 各自有一组 R8-R12 和 SP/LR,剩下 R0-R7 依旧共享。所以一旦中断处理函数里要调用 C 函数,软件还是得先把低寄存器组压到栈上。FIQ 模式因为有更多的私有寄存器,通常用于极低延迟场景,但这几年用 FIQ 的中断方案不多了,大部分系统直接 IRQ 一把梭。
3.4 ARMv8 AArch64: 寄存器不再 banked,现场保护回归软件
到了 AArch64,ARM 把 banked 寄存器模式基本抛弃了。从 EL2/EL1 进入异常时,硬件只自动保存 PSTATE 到 SPSR_ELx、保存返回地址到 ELR_ELx,其他通用寄存器全部由软件保存。异常向量表也变成了四张表:EL0 同步异常、EL0 异步异常、EL1 同步异常、EL1 异步异常,每张表 16 个入口,偏移地址有严格规定。
这个变化意味着 AArch64 的异常处理路径和 RISC-V 更接近了:靠 CSR 记录异常状态,靠软件保存大现场。好处是统一了各异常等级的处理方式,不再区分 IRQ 和 FIQ 各自的私有寄存器副本;坏处是中断入口处必须包含完整保存现场指令,不能像老 ARM 那样“裸奔”一小段。如果是从 ARMv7 老代码迁过来,最容易犯的错就是以为硬件会自动保护更多寄存器,导致中断里用到了 X19-X28 等 callee-saved 寄存器没保存,随机崩溃。
3.5 FPU/NEON 上下文:谁动谁保存
ARM 的 FPU(VFP/NEON)寄存器,也就是 D0-D31 或 AArch64 的 V0-V31,并不在异常自动保存范围内。如果中断处理代码里用了浮点运算或者 NEON 指令,而这些寄存器当前正被被中断的任务使用,那结果就是数据被踩得稀烂。所以大多数 RTOS 在任务切换时才保存 FPU 上下文,中断上下文默认禁止浮点操作。
我在一个项目里就翻过车:中断处理函数里用编译器自带库算了一个除法,编译器优化出了 NEON 指令,结果把用户任务的浮点寄存器改了,整个控制系统输出乱跳。排查了很久,最后查到是中断里意想不到地用了浮点指令。现在的建议是:中断处理函数里明确禁止浮点运算,如果非用不可,就得把 FPSCR/VFPSCR 和 D0-D31 全部保存,这种代价通常不值得。
4. RISC-V:CSR 支撑的极简入口,现场交给软件才是默认哲学
4.1 trap 入口:stvec/mtvec 只负责给地址
RISC-V 的异常入口非常朴素。机器模式下,trap 入口地址存放在 mtvec 寄存器;监管者模式下,存放在 stvec 寄存器。这两个寄存器最低两位是 MODE 字段:MODE=0 表示所有中断都跳到同一个地址,MODE=1 表示向量化中断(每个中断有独立偏移,大约相隔 16 字节)。比较新的规范还引入了 CLIC 等方案,但多数嵌入式平台用的是 mtvec/stvec + PLIC 的组合。
很多刚转过来的人会产生一个困惑:RISC-V 没有类似 x86 IDT 的完整“门”结构,也没有 ARM 的八条异常向量表。它更像只有一个入口,然后 CSRs 告诉你“发生了什么、从哪断的、之前的中断状态是什么”。所以 RISC-V 的 trap handler 开头通常是一长串汇编:先读 mcause 判断是中断还是异常,再读 mepc 拿返回地址,再读 mstatus 的 MPP/MPIE 字段,然后才轮到软件把现场压栈。这里没有捷径,哪怕是 Linux 内核,进入 trap 后也有一大段汇编在做这些事。
4.2 核心 CSR:mepc、mcause、mstatus 的分工
硬件在 RISC-V trap 时到底做了什么?只有三件半:把当前 PC 存入 mepc(或 sepc),把 trap 原因写入 mcause(或 scause),把 mstatus 中原来的中断使能位 MIE 保存到 MPIE,并清掉 MIE 禁止后续中断。如果是指令异常,还会把触发异常的指令编码或地址放到 mtval/stval。除此之外,通用寄存器不碰,栈不碰。
这里要注意顺序:mret 指令会把 mstatus.MPIE 写回 MIE,并把 PC 设置成 mepc。所以一个常见的坑是,如果你在 trap handler 里修改了 mepc 附近的寄存器,却没有同步修改 mstatus.MPIE,返回时中断使能状态就和你预期的不一致。我写过一个小型调度器,刚开始没有保存/恢复 mstatus 的 MPIE 位,导致每次任务切换后中断使能标志错乱,时好时坏。后来规范是:任何时候在 trap handler 里要嵌套执行代码,先整体保存 mstatus、mepc、mcause 三个寄存器,返回前再整体恢复。
4.3 中断委托:为什么 Linux 不在 M 模式处理外设中断
RISC-V 默认所有中断都进 M 模式。但 Linux 等操作系统跑在 S 模式,如果每个外部中断都先进 M 模式再由软件转发给 S 模式,会产生两轮 trap 开销。所以规范提供了中断委托机制:通过 mideleg 和 medeleg 两个寄存器,把一部分中断和异常直接导向 S 模式,由 stvec 来接手处理。
实际平台里,嵌入式 SoC 通常把外部中断(MEI)通过 mideleg 委托给 S 模式。机器模式只保留少数关键异常。这一设计的好处是,S 模式的操作系统可以直接操作 PLIC,而 M 模式的固件只负责启动和电源管理等特权操作。如果你做的系统直接用 M 模式跑 RTOS,那就不需要过多关注委托配置,MTVEC 一路到底就行。
4.4 中断嵌套与抢占:规范留白,全靠软件自觉
RISC-V 在进入 trap 时会自动清 MIE/SIE,也就是说默认不支持嵌套中断。要支持更高优先级中断抢占低优先级处理,需要软件在保存现场后手动写回 MIE,同时保证自己的栈深度足够。很多 RISC-V 平台在这一点上吃了亏:以为写回 MIE 就能像 ARM 那样安全嵌套,却忽略了 PLIC 的优先级配置和各中断的使能状态。
更稳妥的做法是用临界区机制代替嵌套。比如在 trap handler 里,明确分成两个阶段:紧急阶段(关中断、读中断号、保存上下文)和普通阶段(开中断、执行处理逻辑)。普通阶段可以被打断,但必须有嵌套深度计数器,防止同一中断反复进入导致活锁。我用这个方法在 RISC-V 上做过一个低功耗蓝牙协议栈的中断驱动层,稳定性明显好于无脑开 MIE 的方案。
4.5 PLIC 并无统一寄存器布局
RISC-V 规范没有把中断控制器写死,但事实标准是 PLIC。PLIC 通常提供两组关键寄存器:pending 和 enable 用于查询/屏蔽中断,priority 配置优先级,claim 和 complete 用于中断确认和结束。claim 寄存器读操作拿到当前最高优先级待处理中断号,complete 写操作结束该中断,语义上和 GIC 的 IAR/EOIR 类似。
不过不同厂家的 PLIC 实现可能把寄存器偏移改得不一样。我一个项目里从 SiFive 的 PLIC 换到某国产 RISC-V 芯片,寄存器位宽都变了,这导致驱动代码不能直接套用。经验就是:RISC-V 的中断控制器驱动必须针对芯片手册重写,不要指望一份代码通吃所有平台。
5. 三条主线的对照清单:一张表看懂 80% 的架构差异
5.1 核心环节对比表
把六个环节放到一张表里,差异看得最清楚。
| 环节 | x86 | ARM(AArch32/AArch64) | RISC-V |
|---|---|---|---|
| 入口表 | IDT,向量号查门描述符 | 异常向量表,AArch64 分四种表 | stvec/mtvec,单入口或向量化 |
| 现场保存策略 | 硬件压栈 6-8 个字段,特权级切换自动换栈 | AArch32 用 banked 寄存器省一部分;AArch64 靠软件保存 | 硬件只存 mepc/mcause/mstatus 三件套,其余全软件 |
| 是否自动关中断 | 中断门自动清 IF | AArch32 进入 IRQ 模式自动关 IRQ;AArch64 自动屏蔽 | 自动清 MIE/SIE |
| 返回指令 | iret(或 iretq) | AArch32: movs pc, lr;AArch64: eret | mret/sret |
| 中断控制器 | APIC | GIC | PLIC/CLINT(平台相关) |
| 嵌套机制 | 软件改 IF 后可嵌套 | 软件重开 IRQ 后可以 | 软件写回 MIE/SIE,但需谨慎 |
5.2 换架构时最容易翻车的地方
第一,入口地址必须对准。x86 的 IDT 用基址加偏移查表,ARM 的向量表基址要 32 字节对齐,RISC-V 的 stvec 要对 4 字节对齐且 MODE 字段不能被地址覆盖。这些都是要么运行直接死机、要么随机死机的问题。
第二,现场保存不完整。x86 有硬件兜底,但软件样也要额外保存通用寄存器;AArch32 看似硬件帮了一部分,但低寄存器组还得自己来;AArch64 和 RISC-V 几乎全靠软件。我用一句话总结给团队:中断入口汇编第一件事永远是“把你能看到的寄存器全压栈”,宁可多存,不能少存。
第三,返回地址易出错。x86 如果误改了栈上存 CS 的字段,iret 直接异常;ARM AArch32 的 LR 在异常时指向的地址通常要减 4 才是真正被中断的指令;RISC-V 的 mepc 则已经是精确值。这个差异在移植汇编代码时经常引入 bug,我检查代码时每次都会特意加注释标清楚“这个架构的返回地址语义是什么”。
5.3 选型视角:你的中断负载适合哪类架构
如果中断特别频繁,并且希望响应延迟低,ARM 的成熟度最有优势,GIC 的分发能力和 AArch64 的处理能力都经过大规模生产验证。如果注重灵活性、希望中断的全部行为都掌控在自己手里,RISC-V 的简单硬件设计非常合适,但代价是软件要做更多事、开发周期拉长。x86 更适合复杂多任务、重负载的操作系统场景,硬件帮软件扛了大量现场保存工作,甚至能直接配合硬件任务切换,但单个中断的延迟不见得最优。
6. 调试中断时的实操心得:顺着链路一点点点亮
6.1 先把中断源头关掉,点亮最基础的路
我做任何新的中断驱动,第一步都不是写完整 handler,而是先保证系统能稳定运行。把中断控制器里所有中断源 disable 掉,只保留一个定时器中断,写一个空 handler:进来自加一个计数器,然后返回。先看计数器是否增加,确认最基础的“中断能进来、能返回”已经成立。这个阶段如果出问题,往往是向量表、入口地址、返回指令这三处的问题。
6.2 向量表符号导出与栈回溯
早期的裸机调试阶段,我习惯把向量表和 trap handler 的符号导出到 map 文件里,确保链接后的地址和实际设置到 IDT/向量表的地址一致。ARM 链接脚本里常出现 .vectors 段因为 LMA/VMA 不同导致异常向量找不到的情况;RISC-V 则容易把 mtvec 写在链接前地址上。这些问题的共同特征是:代码看着没问题,一旦中断触发,PC 飞到完全不相关的位置。
如果已经能进入 handler,就用 JTAG/调试器抓栈回溯。x86 查栈上 CS/RIP 的残留值,ARM 查 LR 和 SPSR,RISC-V 查 mepc 和栈顶附近的返回地址,配合 map 文件基本都能定位到被中断的代码位置。
6.3 用一个环形缓冲记录中断进出时间点
我比较推荐在每个中断入口放一个 trace 缓冲,记录中断号和时间戳,出口再记录一次。这个小工具能直观地暴露三类问题:中断风暴、中断处理时间过长、中断丢失。它的实现成本很低,就是一个环形数组加几个共享内存变量,但在三种架构上都能用。
6.4 每个架构都有的独有坑
最后记录几个踩过的具体坑,都很典型:x86 上如果错误修改了栈上保存的段选择子,iret 会爆异常,要花很久才能把现场还原清楚;AArch32 的中断处理函数用 bx lr 返回而不是用 movs pc,lr,结果因为没恢复 CPSR,回到任务后中断状态全乱;RISC-V 早期实现里 PLIC 的 claim 读操作必须在重新开中断之前完成,否则高优先级中断反复抢占,后面低优先级中断永远处理不完。
这些坑看似各不相同,但追根溯源,都是没有吃透本架构“硬件替你做了什么、没替你做什么”。回到开头那条主线:中断请求、入口查找、现场保存、跳转处理、软件恢复、返回,只要你在实际编码前先问自己一个问题——“这一行指令是在补硬件的缺,还是在利用硬件已经做好的部分”,就不太会写歪。三种架构的差异看着大,底层逻辑其实是同一个故事的不同讲法。