前阵子帮朋友把一块 ARM 平台的网卡驱动往 RISC-V 开发板上挪,驱动本身的逻辑很简单,结果两天时间全花在中断处理上。这个问题让我意识到,与其一个一个平台去背中断相关的寄存器和汇编,不如把中断流程本身吃透——因为在 RISC-V、ARM、x86 这三条主流架构里,中断从发生到返回的主线其实是一致的,差异只体现在每个阶段用了什么硬件机制。这篇文章就沿着这条主线,把三种架构的中断流程从头到尾拆开讲一遍,重点讲清楚中断控制器、CPU 响应入口、现场保存与恢复这三个关键环节。无论是做裸机程序、RTOS 移植,还是想理解 Linux 内核里中断子系统的代码,这条主线都能帮你快速定位“我现在到底卡在哪一步”。
1. 为什么用“中断流程”作为三条架构的主线
1.1 中断路径是软硬件协作的“最短主干道”
中断是处理器与外设、操作系统之间最重要的协作机制之一。拿日常开发举例子:串口收到一帧数据、网卡来了一个包、定时器计数到点,这些事件如果都要 CPU 轮询,那 CPU 大部分时间都在空转;中断机制允许外设在事件发生时主动通知 CPU,CPU 暂停当前任务,去处理这个事件,处理完再回到刚才的现场继续执行。
这套机制看似简单,但它在 RISC-V、ARM、x86 三种架构上落地的方式完全不同。x86 有 IDT(中断描述符表),CPU 会在响应中断时自动压栈,把 CS、RIP、RFLAGS 都保存下来;ARM 有 GIC 中断控制器和异常向量表,CPU 会切到 IRQ 模式,用 banked 寄存器保存部分现场;RISC-V 则是精简到极致,只有一个 mtvec/stvec 寄存器指向入口,现场保存全靠软件自己来。
我见过不少工程师在一种架构上写了很久驱动,换到另一种架构就蒙圈:明明外设寄存器配置得一模一样,中断就是触发不了。原因往往是他们把“这套架构的某个实现细节”当成了“中断这件事本身”。所以这篇文章选中断流程作为主线,就是想帮大家把“机制”和“实现”分开:机制是那条主干道,实现是主干道上的不同路况。
1.2 一条主线的五个阶段:从触发到恢复
不管哪种架构,一次完整的中断流程都可以拆成五个阶段:
- 外设产生中断信号:比如 UART 收到数据、定时器计数到零,外设向中断控制器发出电平或脉冲信号。
- 中断控制器仲裁与路由:多个外设同时发中断时,中断控制器要处理优先级、屏蔽、挂起,并决定把中断送给哪个 CPU 核心。
- CPU 响应并跳转:CPU 收到中断信号后,保存一部分现场,找到对应的处理入口地址并跳转过去。这个阶段是三种架构差异最大的地方。
- 软件识别中断源并调用处理函数:驱动程序读取中断控制器或外设的状态寄存器,确定是哪个设备产生的中断,调用对应的 handler。
- 恢复现场并返回:做清理工作,把状态寄存器和通用寄存器恢复原样,回到被中断的指令继续执行。
后面所有内容都围绕这五个阶段展开,每个阶段去对比三种架构的做法。你会发现只要把这条主线记在脑子里,哪怕遇到没接触过的架构,也能按图索骥找到对应的寄存器和技术手册章节。
2. 三种架构的中断硬件骨架:CLINT/PLIC、GIC、APIC
2.1 RISC-V:CLINT + PLIC 的组合拳
RISC-V 的中断控制器设计思路非常“教科書化”,它把中断源分成两类,用两套硬件分别管理。
第一套叫 CLINT(Core Local Interruptor),负责处理每个核心私有的中断:软件中断(software interrupt)和定时器中断(timer interrupt)。前者通常由核间通信(IPI)产生,后者由 RISC-V 的mtime与mtimecmp比较器产生。CLINT 的寄存器一般包括msip(软件中断挂起)、mtimecmp(定时器比较值)和mtime(实时计数器)。
第二套叫 PLIC(Platform-Level Interrupt Controller),负责收集 SoC 上所有平台级外部中断源,比如 UART、SPI、GPIO 等。PLIC 的典型结构是:外部设备通过中断号接入 PLIC,PLIC 做仲裁后,把中断信号送到某个 CPU 核心的外部中断引脚。
这套方案的好处是分层清晰、实现简单,适合各种规模的 SoC。但要注意一点:RISC-V 规范对 PLIC 的具体寄存器布局并没有完全统一(虽然有广泛采用的 SiFive 风格),不同厂商的 PLIC 可能存在差异。相比之下,CLINT 和 CSR(控制和状态寄存器)部分基本一致,这也是为什么 RISC-V 中断入口的汇编代码可以高度通用。
2.2 ARM:GIC 的分发器与 CPU 接口
ARM 体系下,中断控制器的标准方案是 GIC(Generic Interrupt Controller),常见的有 GIC-400(GICv2)、GIC-500(GICv3)等。GIC 从逻辑上分成两大块:
- 分发器(Distributor):缩写是 GICD,负责管理所有中断源。它决定每个中断是启用还是屏蔽、优先级是多少、路由给哪个 CPU。分发器还维护中断的 pending 状态和 active 状态。
- CPU 接口(CPU Interface):缩写是 GICC,每个 CPU 核心独有一个。它接收分发器送来的最高优先级中断,与 CPU 核对接。CPU 通过读
GICC_IAR来获取中断号,处理完写GICC_EOIR来结束中断。
GIC 把中断分成三类:SGI(软件触发中断,用于核间通信)、PPI(私有外设中断,每个核独有,比如每个核的通用定时器)、SPI(共享外设中断,多个核共享,比如板载 UART、网卡)。这个分类概念非常重要,在配置设备树时会直接体现。
ARM 的中断流程与 RISC-V 目前看有本质区别:ARM 的 GIC 会把“中断优先级仲裁”直接做到硬件里,CPU 接口只向核提交一个最高优先级中断号;而 RISC-V 的 PLIC 虽然也做仲裁,但 CPU 进入异常后通常还需要通过读取 PLIC 的 claim 寄存器才知道具体是哪个中断。这种差异影响的是软件处理流程的起点。
2.3 x86:从 8259A 到 APIC 的成熟体系
x86 架构早期使用 8259A 可编程中断控制器(PIC),最多支持 8 个中断输入,级联后扩展到 15 个。现代 x86 已经普遍采用 APIC(Advanced Programmable Interrupt Controller)体系,包括 Local APIC 和 I/O APIC 两部分。
- Local APIC:位于 CPU 内部,负责接收本地中断源(比如 APIC 定时器、性能计数器),也负责处理外部中断的最终投递和核间中断(IPI)。它还包含 LVT(Local Vector Table),用来配置每个本地中断源对应的中断向量。
- I/O APIC:负责收集外部设备的中断请求,通过总线把中断重定向到某个 CPU 的 Local APIC。
x86 的中断向量是一个 0~255 的数字,操作系统在启动时会把每个向量对应的入口地址填进 IDT。外设中断经过 I/O APIC 路由后,会携带一个向量号送到 CPU,CPU 直接拿这个向量号去查 IDT,跳转到对应入口。
老式 8259A 在兼容模式下仍然存在,所谓“传统 IRQ”(IRQ0~IRQ15)就是通过 8259A 级联映射到 IDT 向量的。这个历史包袱会让 x86 的中断初始化代码比其他两种架构多出一大截,因为你要先把 8259A 屏蔽掉,再配置 I/O APIC 和 Local APIC。
2.4 一张表看懂三种中断控制器的差异
| 对比项 | RISC-V | ARM (GIC) | x86 (APIC) |
|---|---|---|---|
| 私有中断源 | CLINT(软件/定时器) | PPI/SGI | Local APIC(LVT) |
| 外部中断集合 | PLIC | SPI | I/O APIC |
| 中断号映射 | 平台相关,PLIC 中自定义 | GIC 中断号 0~1019(GICv2) | 向量号 0~255 |
| 优先级仲裁 | PLIC 支持,多级仲裁 | 分发器硬件仲裁 | 两级仲裁(I/O APIC 与 LAPIC) |
| CPU 进入入口 | mtvec/stvec | VBAR + 偏移 | IDT[vector] |
| 中断号获取 | 读 PLIC claim 寄存器 | 读 GICC_IAR | 向量号由中断本身携带 |
这张表可以在移植时当速查手册用。实际写代码前,先把当前平台的“中断控制器长什么样”和“中断号从哪里来”搞清楚,能少踩很多坑。
3. 从响应到入口:三套底层处理流程对比
3.1 RISC-V:stvec/mtvec + mret,把“简单”进行到底
RISC-V 的异常入口只有一个基地址寄存器:机器模式下是mtvec,监管者模式下是stvec。中断发生进入异常时,PC 会直接跳转到mtvec/stvec指向的地址,同时把原来的 PC 保存到mepc/sepc,把原来的特权级和中断使能状态保存到mstatus的 MPP/MPIE(或 SPP/SPIE)字段。
关键在于:RISC-V不会自动保存任何通用寄存器。所以 trap entry 的汇编代码,第一件事就是把 32 个通用寄存器(或者你打算用到的那些)保存到当前栈上。这段代码在不同实现里略有差异,但逻辑都一样——保存寄存器、读取 mcause 判断中断类型、读取 mepc 等 CSR、调用 C 处理函数:
# M-mode trap entry (简化的示意代码) trap_entry: # 在栈上为所有通用寄存器分配空间 addi sp, sp, -256 sd x1, 0(sp) sd x2, 8(sp) # ... 保存 x3~x31,这里省略 ... sd x31, 248(sp) # 读取 mcause 判断是不是中断 csrr t0, mcause bgez t0, handle_exception # 最高位为0表示异常,为1表示中断 handle_interrupt: # 读取 mepc、mstatus 等 csrr a0, mepc csrr a1, mcause call handle_trap_c # 返回后恢复现场 ld x1, 0(sp) # ... 恢复 x2~x31 ... addi sp, sp, 256 mretmret是 RISC-V 的专用返回指令,它会从mepc恢复 PC,从mstatus恢复 MIE 和 MPP。这个设计看起来很“简陋”,但好处是所有规则都一致、透明,软件掌握完全控制权,适合做轻量级内核或 RTOS。
3.2 ARM:向量表 + 异常模式,硬件帮你保存一半
ARM(这里以 AArch32 为例,AArch64 类似但寄存器布局不同)把异常入口设计成一张向量表。每个异常类型(Reset、Undefined、SVC、Prefetch Abort、Data Abort、IRQ、FIQ)占用 4 字节,表基地址由VBAR(或 VBAR_EL1)指定。IRQ 异常对应向量表偏移0x18。
CPU 响应 IRQ 时,会发生这些事:
- 把当前 CPSR 保存到
SPSR_irq。 - 把返回地址保存到
LR_irq(注意 ARM 流水线导致实际返回地址要调整,IRQ 需要LR - 4)。 - 切换 CPU 到 IRQ 模式,并置位 I 位屏蔽中断。
- PC 跳转到
VBAR + 0x18。
通用寄存器r0~r12并不会自动保存,但 IRQ 模式有自己的sp_irq、lr_irq和spsr_irq,这算是硬件提供的“半自动现场保存”。所以 ARM 的中断入口代码一般长这样:
_vector_table: b reset_handler @ 0x00 Reset b undef_handler @ 0x04 Undefined b svc_handler @ 0x08 SVC b pabort_handler @ 0x0C Prefetch Abort b dabort_handler @ 0x10 Data Abort b . @ 0x14 Reserved b irq_handler @ 0x18 IRQ b fiq_handler @ 0x1C FIQ irq_handler: # 切换到 IRQ 模式时的栈,保存被中断现场的寄存器 sub lr, lr, #4 stmdb sp!, {r0-r12, lr} # 读取 GICC_IAR 获取中断号 ldr r0, =GICC_BASE ldr r0, [r0, #0x0C] @ GICC_IAR # 调用 C 处理函数 bl handle_irq_c ldmia sp!, {r0-r12, pc}^ @ ^ 表示恢复 CPSR这个^后缀是 ARM 汇编里的特殊技巧,它会从SPSR恢复 CPSR 并同时将 LR 赋值给 PC,从而完成异常返回。整个过程比 RISC-V 多了一些硬件辅助,但依然需要软件管理通用寄存器。
3.3 x86:IDT + 自动压栈,CPU 大包大揽
x86 的中断响应流程是三条架构里硬件介入最深的。CPU 响应外部中断时,会依据中断向量号在 IDT 中找到对应的门描述符(interrupt gate 或 trap gate),然后自动完成以下操作:
- 如果发生了特权级切换,先从 TSS 中加载新的 SSP/RSP 指向内核栈。
- 依次压栈:SS、RSP、RFLAGS、CS、RIP(如果有错误码还会压入错误码)。
- 清 TF、IF 位(如果是中断门,IF 会被清除以屏蔽可屏蔽中断)。
- 跳转到门描述符中指定的段内偏移。
这也意味着 x86 的“现场保存”很大程度靠硬件自动完成,中断入口代码只需要把剩余的通用寄存器压栈,调用 C 处理函数,退出前弹出寄存器并执行iret:
interrupt_entry_32: pushad # 保存 EAX, ECX, EDX, EBX, ESP, EBP, ESI, EDI # 调用 C 处理函数,参数可以是中断向量号等 push dword [esp+32] # 示例:将错误码或向量号传给 C call handle_interrupt_c add esp, 4 popad # 恢复所有通用寄存器 iret # 恢复 CS/EIP/RFLAGS,以及可能的 SS/ESP这里有个细节:如果 CPU 压入了错误码,那入口汇编必须显式地把错误码去掉,否则iret会从错误的地方恢复寄存器。新手常在这里摔跟头。
3.4 为什么硬件介入程度差别这么大
核心原因在于设计哲学和历史背景不同。x86 从一开始就是 CISC 路线,为了兼容性和“对操作系统友好”,硬件做了很多自动化操作;ARM 走的是嵌入式路线,追求中断延迟可控和硬件面积可控,所以提供 part-time 的辅助;RISC-V 则是最彻底的精简哲学,把现场保存这类“策略性操作”全部交给软件,以便适应从 MCU 到数据中心 CPU 的各种场景。
理解这点后,你再看 Linux 内核代码里arch/arm/kernel/entry-armv.S、arch/riscv/kernel/entry.S、arch/x86/entry/entry_64.S的差异,就不会觉得奇怪了。本质上都是同一套五阶段流程,只是在“谁负责保存现场”上做了不同的分工。
4. 实操:在 QEMU 上把同一个定时器中断跑通
4.1 环境准备:三套工具链与三个最小工程
理论讲再多不如动手跑一遍。我推荐用 QEMU 虚拟机做实验,不需要真实开发板,而且 QEMU 的机器模型比较固定,适合对比学习。三个目标分别是:
- RISC-V:
qemu-system-riscv64 -machine virt - ARM:
qemu-system-aarch64 -machine virt -cpu cortex-a53 - x86:
qemu-system-i386 -machine pc
工具链方面,RISC-V 可以用riscv64-unknown-elf-gcc,各种发行版仓库里都有;ARM 裸机交叉编译常见的是aarch64-none-elf-gcc,或者用arm-none-eabi-gcc配合 AArch32 模式;x86 就比较方便,直接gcc -m32 -fno-pic -ffreestanding或者i386-elf-gcc都行。
我建议每个平台先写一个最小裸机程序:能够进入 C 语言环境、能在串口打印一行字符就行。然后把定时器中断加进去,打印中断触发次数。这样整个工程控制在几百行内,方便定位问题。
4.2 RISC-V 实操:mtvec 指向 trap 入口
在 QEMU virt 平台上,我习惯直接跑 M 态裸机,省去 SBI/OpenSBI 的配置环节。核心步骤就四步:
- 设置
mtvec指向 trap 入口。 - 使能 M 态定时器中断:
mie寄存器里置位 MTIE。 - 设置 CLINT 的
mtimecmp:*(uint64_t *)0x02004000 = mtime + INTERVAL;QEMU virt 平台的 CLINT 基址是0x02000000,mtimecmp在0x4000偏移处。 - 全局开中断:置位
mstatus.MIE。
trap 入口的汇编代码参考第 3.1 节,C 处理函数里判断mcause == 0x8000000000000007(M 态定时器中断),是的话把mtimecmp加上一个时间间隔,然后打印计数值。
我第一次跑通这个流程时,困扰最久的问题是为什么mstatus.MIE置位了还是进不了中断。后来发现 CLINT 的mtimecmp必须大于等于当前mtime,否则中断条件会立即满足,进入死循环式的重复中断。这个在 QEMU 里特别容易触发,初始化顺序一定要对:先写mtimecmp,再开全局中断。
4.3 ARM 实操:VBAR 向量表与 GIC 初始化
ARM 这边的 QEMU virt 平台,裸机程序的链接地址通常放在0x40080000附近(和实际机器有关),关键要在启动汇编里设置好 VBAR:
_start: ldr x0, =_vector_table msr vbar_el1, x0 # 初始化栈指针 ldr x0, =stack_top mov sp, x0 # 清 DAIF 的 I 位,开 IRQ msr daifclr, #2 bl c_entryQEMU virt 平台的 GIC 基址是0x08000000(GICD)和0x08010000(GICC),但不同 QEMU 版本可能有差异,建议启动时从设备树里读,或者直接查询 QEMU 源码的virt.c。初始化时要做的有:
- 设置
GICD_CTLR启用 group0/group1。 - 设置
GICC_CTLR启用 CPU 接口。 - 配置定时器中断为 PPI(每个核独占),对应 GIC 中断号 30。
ARM 通用定时器的触发方式与 RISC-V 的 CLINT 类似,但寄存器在不同异常级有区别,常用CNTP_TVAL_EL0和CNTP_CTL_EL0。中断发生进 IRQ 向量后,读GICC_IAR可以得到中断号,处理完写GICC_EOIR。如果没有写 EOIR,GIC 会一直认为中断还在 active 状态,后续同样优先级的中断永远进不来。
4.4 x86 实操:IDT 表项与 Local APIC 定时器
x86 部分最麻烦的不是中断本身,而是 IDT 和 APIC 的初始化顺序。我的经验是:
- 先用
lidt加载 IDTR,确保 IDT 描述符有效。 - 配置 8259A 屏蔽掉所有传统中断,避免和 APIC 冲突。
- 通过 MSR
IA32_APIC_BASE找到 Local APIC 基址(通常是0xFEE00000),读取SVR寄存器并启用 APIC。 - 配置 LVT Timer 寄存器(偏移
0x320),选择周期模式。 - 写初始计数寄存器(偏移
0x380),开始计数。
IDT 里的每个描述符是 8 字节,需要设置段选择子、偏移地址和类型属性。下面是一个中断门描述符的写入示例:
static void set_idt_entry(int vec, uint32_t handler) { uint32_t *desc = (uint32_t *)(idt_base + vec * 8); desc[0] = (handler & 0xFFFF) | (CODE_SEL << 16); desc[1] = (handler & 0xFFFF0000) | 0x8E00; }这里的0x8E00表示 present、DPL=0、32 位中断门。如果 descriptor 写错,CPU 触发中断时会直接 triple fault 重启,所以建议先在 QEMU 里用-d int参数观察中断是否进入,再逐步跟踪。
4.5 跑通之后:三段主线的异同一眼看清
三个平台跑通后,把代码并排放在一起看,你会发现共性的部分非常明显:
- 第一是“使能链”:每个平台都有外设级使能、控制器级使能、CPU 级全局使能这三层。
- 第二是“入口跳转”:都是某个基地址寄存器 + 特定偏移或直接跳转。
- 第三是“清状态”:RISC-V 靠更新 mtimecmp,ARM 靠写 EOIR,x86 靠写 EOI 寄存器,本质都是让中断控制器知道“这个中断处理完了”。
差异主要体现在现场保存的职责分配上。x86 的压栈操作最自动化,但这也让它的入口底层细节最多;ARM 的异常模式切换提供了不错的中间方案;RISC-V 则把控制权完全交给了软件。跑通一次,比背十篇文档都管用。
5. 移植避坑:中断问题常出在哪几个环节
5.1 中断没反应:先查使能链路上的每一道“开关”
中断不触发是最常见的问题,而且通常不是一处配置错,是好几处开关没全开。我的排查习惯是沿着“外设 → 中断控制器 → CPU”这条链路逐级确认:
| 检查点 | RISC-V | ARM (GIC) | x86 (APIC) |
|---|---|---|---|
| 外设级 | 外设中断使能寄存器 | 外设中断使能寄存器 | 外设中断掩码寄存器 |
| 控制器级 | PLIC enable + priority | GICD 的 enable/priority | I/O APIC 重定向表项 |
| CPU 级 | mstatus.MIE / mie | DAIF 的 I 位 / GICC_CTLR | IF 位 / 本地 APIC SVR |
在 QEMU 上调试时,还可以用 QEMU 自带的 monitor 命令看中断状态。RISC-V 可用info mtree查看内存映射,x86 用info lapic查看 Local APIC 寄存器,ARM 则要看 GICD/GICC 的内存内容。
5.2 中断返回就崩溃:上下文恢复的顺序不能错
中断返回崩溃往往是现场没恢复对。RISC-V 这边常见的是在 trap entry 里忘了恢复某个 callee-saved 寄存器,或者在mret之前意外改写了mepc;ARM 那边常见的是返回地址调整错了,IRQ 应该是LR - 4,有人写成LR - 8,导致返回到错误指令;x86 这边最常见的是iret前栈不平衡,尤其是 CPU 自动压入错误码的情况下,软件没把错误码清理掉。
有一个通用技巧:在入口汇编的开头和结尾分别写入两个固定的魔数到某个内存地址,崩溃后看这个魔数是否存在来判断是入口崩还是出口崩。这个小技巧帮我省过很多次调试时间。
5.3 中断重入死锁:嵌套与临界区要分开设计
三种架构进入中断入口时,默认都会屏蔽同级或全部中断:RISC-V 通过硬件清零 MIE/SIE,ARM 置位 CPSR.I,x86 通过中断门清 IF。这样可以保证中断处理函数默认不嵌套,简化设计。
但如果你的系统确实需要中断嵌套,比如高优先级中断打断低优先级中断,那就需要格外小心。RISC-V 里要在 trap handler 中显式重新开中断,并把通用寄存器的保存/恢复组织成可重入的结构;ARM 的情况会好一些,因为 IRQ 模式自带独立的 sp_irq 和 lr_irq,不会覆盖主栈;x86 如果开嵌套,必须保证每次iret的栈都对应正确的现场,不然很容易栈错乱。
我的建议是:前期尽量别开嵌套,用一个全局 pending 标志记录挂起的高优先级中断,等当前 handler 结束后再处理。这个方案简单可靠,延迟通常在可接受范围内。
5.4 排查工具与调试心得
QEMU 是学习中断流程最好的环境,因为它支持-d int,cpu_reset打印中断和异常事件。比如 x86 平台可以用qemu-system-i386 -d int -D qemu.log,然后看日志里每次中断向量的进出。RISC-V 平台可以用 OpenOCD + GDB 连 QEMU 的 GDB stub,在 trap entry 下断点看 CSR。
我自己调试中断时有几个习惯:第一,串口或 VGA 输出尽量早初始化,中断处理函数里每到关键步骤就打一行日志;第二,写一个简单的“虚假中断处理器”,先确认所有中断入口能进得去,再挂真正的设备处理逻辑;第三,不要一次性配完所有中断源,从单个定时器中断开始,跑通后再加外设。
写在最后的实操体会
我把这三个平台的中断流程都跑通之后,最大的感受是:不要被寄存器名字和汇编差异吓住,中断的底层逻辑其实是相通的。你在 RISC-V 上理解了 PLIC 的 claim 流程,看 ARM 的 GICC_IAR 就会觉得似曾相识;你在 x86 上理解了 IDT 的门描述符,再看 ARM 的异常向量表也不会觉得陌生。
如果让我给刚入门的工程师一个建议,我会说:先拿 RISC-V 下手,因为它逻辑最简单、规范最少,你被迫亲手写出保存/恢复现场的全部代码,这个过程能帮你建立最扎实的中断心智模型。等 RISC-V 跑通后,再去看 ARM 和 x86 时,你会发现剩下的工作主要是学新寄存器名和硬件约定,而不是重新理解中断这件事。