做嵌入式这几年,我接触的芯片基本逃不开这三类:RISC-V、ARM、x86。每次带新人入门,大家最爱问的一个问题就是:它们的中断流程到底差在哪?直接丢三份手册太劝退,所以我习惯先讲一条主线,再往三个架构里套。这篇文章就把这条主线理清楚,从向量表、特权级切换、现场保存到中断结束,一条线看懂三种架构。
1. 中断主线:先抓住五步,再谈架构差异
1.1 中断的本质:从“正常执行”切换为“突发事件处理”
中断和异常说白了就是CPU正在好好执行指令,突然被一件事打断,需要转去执行另一段更紧急的代码,处理完再回到原来被打断的地方继续跑。区别只在“打断”的来源:外部硬件发来的是中断,指令本身触发的是异常。但在大多数现代CPU里,处理机制是高度统一的,所以RISC-V干脆管它们都叫trap。
理解中断前,先抓五步就能把骨架立起来:
- 触发:外设拉高中断线,或CPU内部检测到异常。
- 仲裁与路由:多个中断源同时到来时,由中断控制器决定谁先到CPU。
- 向量定位:CPU根据中断类型或中断号,跳转到对应的处理函数入口。
- 处理:执行中断服务程序,完成具体业务。
- 结束与恢复:通知中断控制器处理完毕,CPU恢复保存的现场,回到原任务继续执行。
无论哪家架构,本质都是在走这五步。差别在于:向量表放在哪、跳转时硬件帮你做了多少、现场保存在哪、结束中断时谁来写确认寄存器。把这几个点对比着看,复杂度就降下来了。
1.2 影响中断流程的三个关键变量
先看硬件“帮你保存”了什么。x86每次进中断都会自动把EFLAGS、CS、RIP压栈,异常还会压入错误码;ARM的Cortex-M会硬件自动压一堆寄存器;但ARM64体系的A/R核、RISC-V则大部分靠软件自行保存通用寄存器。这个差异决定了你在写中断入口汇编代码时的工作量。
再看特权级怎么切换。x86有Ring0到Ring3,ARM有EL0到EL3,RISC-V有U、S、M三种模式。中断来了,CPU通常从低特权跳到高特权,这个跳转过程决定了谁能被中断打断、谁的资源不能碰。
最后是向量表的组织方式。x86用门描述符管理0到255的向量号;ARM64用异常等级加异常类型组合查表;RISC-V更像是拿“异常原因”直接做索引。理解了这三点,三种架构的页面读起来就不会再一团乱麻。
2. x86:一张IDT管天下,向量号写得明明白白
2.1 从IVT到IDT:实模式到保护模式的变化
x86在8086时代用固定地址的IVT中断向量表,表固定在内存最低地址,每个表项4字节,直接存段地址和偏移。从80286进入保护模式后,改用IDT,也就是中断描述符表,位置由IDTR寄存器指向,不再固定在地址0。
IDT里的每个表项叫门描述符,常见的有中断门、陷阱门,老系统里还有任务门。中断门除了能指定处理函数地址,还有一个属性:进入中断门时CPU会自动清IF标志,也就是自动关中断,防止同一中断在服务期间再次触发。陷阱门则不会重置IF。这些细节别小看,关中断的时机和范围,直接决定了你写驱动时的嵌套策略。
2.2 一次完整的中断进入:CPU替你干了哪些活
以现代x86的长模式为例,假设用户态程序运行在Ring3,外设发来中断。CPU会这样走流程:
- 根据中断向量号查找IDT,找到对应门描述符,并做一系列特权级检查。
- 确认需要切换特权级后,从TSS中取出内核栈指针RSP0,把原来的栈指针切换过去。
- 依次压入旧的SS、RSP、RFLAGS、CS、RIP。如果异常有错误码,还会压入错误码。
- 跳转到门描述符中指定的处理函数,处理器开始执行ISR。
也就是说,x86在硬件层面帮你完成了切换栈和压入关键上下文的工作。这个设计比RISC-V省事不少,代价是IDT的层级结构、权限检查、TSS结构复杂,理解门槛高。
2.3 外部中断如何走到CPU:LAPIC和中断向量号
x86外部设备的中断,不是直接拉一根中断脚到CPU内核就完事的,而是要先经过中断控制器。现代x86把中断控制器拆成两部分:IOAPIC收集外设的中断,本地APIC挂在CPU内部。IOAPIC把中断映射成一个中断向量号,发给本地APIC,本地APIC再依据该向量查找IDT。
所以x86的世界观里,中断向量号是一等公民。外部中断一般占用从0x20往后的一段向量,你写驱动时注册的是“IRQ号”,而CPU真正看到的是经过映射后的向量号。IRQ号和向量号之间没有固定的算术关系,具体看系统怎么配置APIC,这也是刚接触x86的人比较容易绕晕的地方。
3. ARM:异常等级与向量表之间的配合
3.1 别把ARM当成一种:A/R和M走的是完全不同路子
说ARM中断前一定要先分清方向,否则会越聊越乱。ARMv7-A/R、ARMv8-A使用的AArch32/AArch64走的是“异常等级 + 向量表”那套;而Cortex-M系列走的是内置NVIC中断控制器,配合向量表+硬件自动压栈的机制。
本文主要说A/R核,因为它在嵌入式Linux、服务器、车规芯片里更常见。但我也提一句M核:在Cortex-M上,中断来临时硬件会自动把R0到R3、R12、LR、PC、xPSR等寄存器压到当前栈,NVIC负责优先级仲裁和尾链优化,开发者几乎不用手写现场保存汇编。这套思路对新手友好得多,但和下面要说的ARM64模式相反。
3.2 ARM64的异常向量表:按“身在何级+何种异常”找入口
AArch64的中断入口不叫IDT,也不叫中断向量,而是叫“异常向量表”。每级异常等级都有自己的向量表基地址,放在VBAR_EL1、VBAR_EL2、VBAR_EL3中。每个向量表内部不是一长串顺序排列的中断号,而是按“栈指针选择 + 异常等级来源 + 异常类型”分成若干固定偏移的入口。
向量表里大致会看到同步异常、IRQ、FIQ、SError几大类。发生异常时,CPU根据当前异常等级和被打断的等级,跳转到VBAR_ELx加上固定偏移量的地址。每个入口之间通常间隔固定大小,有的留32条指令的空间,有的需要你主动跳到更远处。
我调试ARM64平台上经常用到的几个系统寄存器:
- VBAR_EL1:指向EL1的异常向量表基地址。
- ELR_EL1:被打断位置的返回地址。
- SPSR_EL1:被打断前的程序状态。
- ESR_EL1:异常原因寄存器,用于区分同步异常、IRQ、FIQ。
- DAIF:中断屏蔽位。
3.3 ARM多级安全与中断路由:异常等级决定处理归属
ARM64最大的特点,是把特权级抽象成EL0到EL3四个异常等级。操作系统内核跑在EL1,Hypervisor跑在EL2,固件和安全监控跑在EL3。外部中断来了之后并非一定跳到EL1,而是由异常路由配置决定跳到哪一级。
如果在虚拟化场景下把中断配成直通虚拟机,IRQ可能被路由到EL2,再由虚拟机管理程序决定怎么处理。普通裸机系统则通常让中断上到EL1。这个路由配置是通过HCR_EL2、SCR_EL3等寄存器里的开关位来控制的。这就是为什么ARM架构下说“中断处理在哪一层运行”比单纯说“进入中断函数”更重要,毕竟固件、内核、虚拟机可能都在等同一根中断线。
发生IRQ后,CPU并不会自动帮你把一堆通用寄存器压栈。它只做三件事:把返回地址写入ELR_ELx、把原状态存入SPSR_ELx、设定ESR_ELx的原因值。接下来靠内核异常入口汇编指令把通用寄存器保存到栈上。这也是很多人看ARM64的异常入口汇编时发现有一大段保存指令的原因。
如果需要扩展外部中断源,ARM一般配套GIC。GIC会向CPU核发送IRQ或FIQ信号,CPU跳转到异常向量,然后软件读取GICC_IAR寄存器,通过这个寄存器拿到中断号,并隐含完成中断确认动作。中断处理完,软件还要写GICC_EOIR寄存器,告诉GIC可以清掉这个中断了。读IAR和写EOIR这两个操作,对很多刚从x86平台转过来的人特别容易遗漏。
4. RISC-V:极简CSR,把现场交给软件自己管
4.1 只用几个CSR,就把trap讲清楚了
RISC-V可以看作把x86和ARM的复杂度拨开后的极简派。它没有复杂的门描述符结构,也没有必须分四级的异常等级体系。RISC-V规定的基础特权级有三种:U(用户)、S(系统)、M(机器模式)。如果芯片只用嵌入式场景,通常U和M两级就够了,Linux则是U、S、M三态配合。
发生trap时,核心寄存器就这么几个:
- mtvec/stvec:存放trap入口地址,即异常向量表基地址,也可以配成向量模式。
- mcause/scause:记录trap原因,最高位表示是不是外部中断,低位置存放原因编号。
- mepc/sepc:记录被打断的那条指令地址。
- mstatus/sstatus:记录处理器状态,其中MIE/SIE是全局中断开关,MPIE/SPIE用来保存进入trap前的中断使能状态。
- mtval/stval:记录导致异常的附加信息,比如缺页地址。
这套CSR设计最直观的好处是,你只要看懂这几个寄存器,整个中断流程就揭开了一半。
4.2 trap进入与退出:硬件只做最小动作
以机器模式处理trap为例,CPU收到中断后会自动完成:
- 把当前PC写入mepc。
- 把trap原因写入mcause。
- 保存当前中断使能状态到MPIE,然后清除MIE,关掉M模式中断。
- 处理器切换到M模式,跳到mtvec指向的地址。
注意,RISC-V硬件不会像x86那样帮你把返回地址、状态字压栈,更不会像Cortex-M那样自动保存一堆通用寄存器。通用寄存器的保存完全由软件完成。进入trap后,你写的第一段汇编通常就是把通用寄存器保存到内存或栈里,退出前再从栈里恢复出来。
这种极简做法让RISC-V的实现开销很低,但也把复杂度留给了编译器、操作系统和BSP开发者。你在看RISC-V的Linux内核代码时,会看到类似trap_entry这样的汇编宏,作用就是把sp切换到内核栈并保存现场。以U模式到S模式的切换为例,Linux会通过CSR操作将sscratch与实际sp对调,再把必要的寄存器压栈。想弄懂RISC-V中断,最好先熟悉这些汇编宏中保存与恢复的顺序。
4.3 外部中断源怎么确认:靠可编程中断控制器
RISC-V在指令集层面只定义了“中断来了”,却没有规定外部中断源号如何编码。因此具体外设怎么区分,由中断控制器实现决定,常见的有PLIC、APLIC以及CLINT等。PLIC收集来自多个外设的中断请求,进行优先级仲裁后,向某个CPU核发送一条外部中断信号。
CPU核看到的是“外部中断”这个类别,其mcause对应的是机器外部中断,或间接反映为sstatus中的S外部中断。随后软件必须去访问中断控制器的claim寄存器,拿到真正的中断源编号,相当于告诉控制器“我要处理这个中断”。处理完毕后还要写complete寄存器,表示“这个中断我已经结束了”。
对比一下就看得出:x86直接把每个中断做成一个向量号作为IDT索引,ARM需要读GIC中断号,RISC-V需要访问外部控制器claim寄存器。三个架构都把“谁触发的中断”这个问题剥离开,只是完成方式完全不同。
5. 三架构对照:现场、向量、结束三张牌各有不同
5.1 一张表看懂硬自动保存的差异
我常建议手头备一张自己整理的对照表,尤其是面试或移植驱动时临时翻一翻很管用。以下是我平时常用的一套维度:
| 对比维度 | x86 | ARM64 + GIC | RISC-V + PLIC |
|---|---|---|---|
| 入口表 | IDT,按中断向量号找门描述符 | VBAR_ELx指向的异常向量表,按异常类型找入口 | stvec指向的trap入口,可选direct或vectored模式 |
| 一般寄存器如何保存 | 硬件压栈关键上下文,通用寄存器靠软件 | 通用寄存器靠软件保存 | 通用寄存器靠软件保存 |
| 返回地址/状态保存 | 返回地址和状态由硬件压栈 | ELR_ELx与SPSR_ELx保存 | mepc/sepc与mstatus/sstatus保存 |
| 特权级 | Ring0到Ring3 | EL0到EL3 | U、S、M三级 |
| 外部中断源获取 | 中断向量号由IDT管理器直接映射 | 读GICC_IAR获取中断号 | 读中断控制器的claim寄存器 |
| 结束后通知 | LAPIC发送EOI | 写GICC_EOIR | 写PLIC completion寄存器 |
这张表只作为纲领,不能覆盖所有实现细节,因为哪怕同样是ARM64,不同SoC的GIC版本不同,中断处理都有细微差异。但抓这几点去看代码,基本方向不会跑偏。
5.2 中断“结束”动作,是最容易写错的一环
很多人调试中断卡死,问题不在处理逻辑,而在结束动作没做对。x86平台处理完外部中断后,软件要告诉本地APIC中断已经处理完毕,否则APIC不会清除当前正在服务的中断状态,后续同级别中断无法继续上报。
ARM平台是写GIC的EOIR寄存器完成EOI操作,而且如果用了优先权,还要注意中断优先级组的配置,写错EOI可能会关掉不该关的优先级组。RISC-V平台则要回写PLIC的complete寄存器,把当前中断源标记为完成。
这里有个很容易犯的错:把“中断号确认”和“中断结束”混为一谈。读ARM的IAR、读PLIC的claim,相当于告诉中断控制器“我已经受理了”,但不等同于“已经干完了”。干完活后必须再写EOIR或者complete,中断控制器才认为这个中断彻底结束。如果只确认不完成,表面上看第一个中断能进来,但后续同优先级中断很可能被压住,这时候排查方向基本都在中断控制器状态。
5.3 为什么会有人觉得“x86中断更像异常,ARM中断更像消息”
这个感受其实挺有意思。x86的IDT把外部中断和CPU异常都放在同一张表,而且CPU依靠一个向量号就能跳转,驱动开发者的心智模型是“中断号就是入口编号”,和异常处理几乎平行。ARM的向量表只是把IRQ/FIQ领进门,具体是哪个外设,必须从GIC拿IAR,所以更像“来了一条消息,我得查一下是谁发的”。RISC-V也一样,trap里只标记了中断类别,具体来源要问PLIC。
三种模型的差别并不代表孰优孰劣,更多是历史包袱和应用场景决定的。x86为了兼容积累了几十年的软件生态,接口复杂但稳定;ARM一开始面向移动和嵌入式,把安全隔离做成了异常等级;RISC-V从零出发,核心指令集做得很干净,把复杂功能留给外置控制器和软件。理解这个演进逻辑,你就能明白为什么RISC-V常被称为“用来做控制器的CPU”,而x86更像“一台什么中断都能接的通用机”。
6. 实践者才关心的几个坑和排查习惯
6.1 在写中断入口之前,先把五问答清楚
一个人说他会写中断,通常体现在翻手册和看反汇编的能力。我个人写底层中断入口或移植操作系统时,会先强制自己回答五个问题:
- 中断发生在哪个特权级,目标处理器会跳到哪一级?
- 向量表地址是谁提供的,启动代码是否已经设置好了?
- 进入中断后硬件关不关中断?如果不关,嵌套中断怎么保护现场?
- 现场保存是在硬件自动完成还是需要软件汇编处理?
- 中断结束该写哪个寄存器,确认后会不会误清其他状态?
这五个问题如果都能当场答出,八成不会出现“中断一开就死机”的尴尬。如果答不上来,别急着写代码,先把对应架构的启动代码和中断控制器驱动读明白。
6.2 调试中断卡死时,我最常用的三种策略
第一是看返回地址。x86上发生异常时,RIP指向的可能是触发异常的指令,也可能是返回点,要看异常类型。ARM上直接读ELR_ELx,RISC-V上读mepc或sepc。如果发现返回地址落在奇怪的地址,比如零地址或全是0xFFFF,多半是现场保存顺序出了问题,而不是中断逻辑本身。
第二是查向量表和入口地址有没有对齐。ARM64要求异常向量表按一定边界对齐,RISC-V也要求stvec按至少4字节对齐,如果你在运行时动态改向量表,得注意对齐属性。某些平台在链接脚本里没有把向量段放在正确位置,中断一跳就跳到空地址,这种问题用示波器看中断线已经拉高,但CPU根本没动作,基本就是向量表没配对。
第三是打印中断控制器状态。GIC、APIC、PLIC都有寄存器可以查当前pending中断和当前servicing的中断状态。如果Pend一直拉高但CPU不进中断,优先查中断是否被mask,或者目标CPU使能位没开。如果中断能进一次但之后再无响应,优先查结束寄存器有没有写,再查优先级配置。
6.3 最后说两条经验
调试RISC-V时,多利用GDB的CSR查看功能,直接读mcause和mepc,比反复看日志快得多。比如:
- mi/v $mcause 查看中断原因。
- mi/v $mepc 查看被打断的位置。
- mi/v $stvec 查看当前S模式trap入口。
ARM平台上用调试器查看VBAR_EL1时,经常发现它被内核改写成了某个动态地址。这时你要知道,那不是bug,而是内核在启动初期用临时向量表跑了一段时间后,又切换到了真正的内核向量表。如果断点打在旧地址上,自然永远不触发。
做这套工作久了,我的切身体会是:与其把所有中断细节死记硬背,不如记死一条主线、五个问题,再按架构把“向量表、特权级、现场保存、结束通知”这四个锚点填进去。每次拿到新SoC,哪怕厂商文档写得再乱,按这条思路也能很快定位到底层中断该怎么上手。