中断管理系列
你写了个中断处理函数,注册到内核里。外设触发中断,GIC 把信号送给 CPU。然后你的函数就被调用了。中间发生了什么?
从 CPU 收到 IRQ 信号,到 C 语言函数第一行代码执行之间,硬件和汇编联手完成了一整套「现场保护 + 状态切换」。这套机制你看不见,但每一条中断都走一遍。
中断处理的第一行代码不是你写的,是硬件写的。

01 ARM64 的异常等级
ARM64 有四个异常等级(Exception Level),从低到高是 EL0、EL1、EL2、EL3。
- EL0——用户态,应用程序跑在这里,权限最低
- EL1——内核态,操作系统内核跑在这里,能访问所有硬件资源
- EL2——虚拟化层,Hypervisor 跑在这里,管理虚拟机
- EL3——安全监控层,TrustZone 安全世界切换的入口,权限最高
中断属于异步异常。IRQ 和 FIQ 都是异步的——它们随时可能来,和当前执行的指令没有关系。
当 CPU 在 EL0 跑用户程序时收到 IRQ,硬件会自动切到 EL1,然后跳转到内核指定的异常向量入口。
异常等级只能升不能降。
低等级收到异常,跳到高等级处理。处理完用 eret 指令返回原等级。硬件保证你在用户态不能直接跳到内核代码里执行——除非通过异常入口。
02 硬件自动做的五件事
IRQ 信号到达 CPU 的那一瞬间,硬件自动完成五件事。不需要一条指令,纯硬件行为。
| 序 |
动作 |
存到哪 |
| 1 |
保存当前 PSTATE 状态 |
SPSR_EL1 寄存器 |
| 2 |
保存返回地址 |
ELR_EL1 寄存器(下一条指令地址) |
| 3 |
PSTATE.DAIF 全部置 1 |
自动关 Debug / SError / IRQ / FIQ |
| 4 |
切换异常等级 + 换栈 |
切到 EL1,使用 EL1 的栈指针 |
| 5 |
跳转到异常向量表 |
VBAR_EL1 + 对应偏移地址 |
第三件事最容易被忽略——硬件自动把 PSTATE 的 D、A、I、F 四位全部置 1。这意味着中断一进来,IRQ 和 FIQ 就已经被关掉了。
不需要软件手动关中断。硬件在跳转到异常向量之前就已经关了。
很多人以为中断处理函数开头需要自己关中断。
不用。硬件已经关了。你在中断处理入口处看到的那些 enable_da_f 之类的指令,是开 D(调试异常)、A(SError)、F(FIQ),I(IRQ)位仍然保持关闭——IRQ 还是关着的。
03 异常向量表
VBAR_EL1 是异常向量表基地址寄存器。内核启动时把向量表的地址写进这个寄存器。
向量表里每一项占 128 字节(用 .align 7 对齐),正好能放几条跳转指令。总共 16 个入口,分成四组。
| 组别 |
Sync |
IRQ |
FIQ |
Error |
| EL1t(EL1 用 EL0 栈) |
+0x000 |
+0x080 |
+0x100 |
+0x180 |
| EL1h(EL1 用 EL1 栈) |
+0x200 |
+0x280 |
+0x300 |
+0x380 |
| EL0 64 位 |
+0x400 |
+0x480 |
+0x500 |
+0x580 |
| EL0 32 位 |
+0x600 |
+0x680 |
+0x700 |
+0x780 |
内核态(EL1)触发的 IRQ,走的是 EL1h IRQ 入口,偏移量 0x280。
用户态(EL0)触发的 IRQ,走的是 EL0 64 位 IRQ 入口,偏移量 0x480。
为什么分 EL1t 和 EL1h?t 代表 SP_EL0(用 EL0 的栈指针),h 代表 SP_ELx(用当前 EL 的栈指针)。Linux 内核在 EL1 始终用 SP_EL1,所以只关心 EL1h 那一组。
04 el1_irq 汇编流程
从向量表入口跳到 el1_irq 标签后,汇编代码按以下顺序执行:
- kernel_entry——把所有通用寄存器压栈保存
- enable_da_f——打开 D、A、F 位(IRQ 仍关)
- irq_handler——调用 C 语言层面的中断处理函数
- el1_preempt——如果支持内核抢占,检查是否需要调度
- kernel_exit——恢复所有寄存器,执行
eret 返回
enable_da_f 不是开中断。
它只开 D(调试异常屏蔽)、A(SError 异步异常屏蔽)、F(FIQ 屏蔽)这三位。I 位(IRQ 屏蔽)保持为 1,也就是 IRQ 仍然关闭。整个硬中断处理期间,本地 CPU 的 IRQ 都是关着的。
05 pt_regs 栈框结构
kernel_entry 把寄存器全部压到内核栈上,形成一个 pt_regs 结构体。栈从高地址向低地址增长。
这个栈框里存了中断发生时的完整 CPU 状态,C 代码里能通过指针直接读。
| 字段 |
来源 |
说明 |
| x0 ~ x30 |
通用寄存器 |
共 31 个,全部压栈 |
| sp |
栈指针 |
中断前的栈地址 |
| pc |
ELR_EL1 |
中断返回地址(下一条指令) |
| pstate |
SPSR_EL1 |
中断前的程序状态 |
| orig_x0 |
x0 原始值 |
系统调用时保存,中断里也保留 |
| syscallno |
系统调用号 |
中断路径上通常为 -1 |
pt_regs 是中断上下文和进程上下文的桥梁。Oops、信号投递、系统调用返回值,都靠修改栈框里的值来完成。
06 保存与恢复
kernel_entry 做两件事:
- 把 x0 到 x30 全部
stp 压栈。
- 从 ELR_EL1 和 SPSR_EL1 读出返回地址和状态,也存进栈框。
kernel_exit 做对应的逆操作:
- 从栈框里取出返回地址和状态,写回 ELR_EL1 和 SPSR_EL1。
- 把 x0 到 x30 从栈里
ldp 恢复。
- 最后执行
eret 指令——硬件把 SPSR_EL1 写回 PSTATE,跳到 ELR_EL1 指向的地址,同时切回原异常等级。
eret 不是普通的 ret。
普通 ret 只改 PC。eret 同时恢复 PC、PSTATE 和异常等级。三件事一起做,原子完成。中间不可能被打断。
07 三个常见坑
坑 1:以为中断能嵌套
IRQ 一进来硬件就关了本地中断。同一个 CPU 上,硬中断处理函数执行期间不会再响应新的 IRQ。只有在中断处理中显式调用 local_irq_enable 打开中断,才可能发生嵌套。Linux 默认不这么做。
坑 2:在中断处理里改了 PSTATE 却没恢复
中断返回时 kernel_exit 从栈里的 pt_regs 恢复 pstate。如果你在 C 代码里直接改了 PSTATE 寄存器(比如用 msr 指令),返回时会被栈里的值覆盖掉。想改中断返回后的状态,要改 pt_regs 里的 pstate 字段,不是改当前 PSTATE 寄存器。
坑 3:中断栈溢出
ARM64 有四个异常等级(Exception Level),但内核栈通常只有 16KB 或 32KB。中断来了,pt_regs 要占一大块,再加中断处理函数的栈帧,如果嵌套调用深了很容易溢出。所以中断处理里别开大数组,别搞深度递归。重活丢给软中断或工作队列。
本篇小结
| 阶段 |
谁做的 |
核心动作 |
| 中断到达 |
硬件 |
存 SPSR/ELR、关 DAIF、切 EL1 + EL1 栈、跳 VBAR_EL1+偏移 |
| 入口汇编 |
kernel_entry |
x0-x30 + ELR + SPSR 全部压栈,形成 pt_regs |
| 开部分异常 |
enable_da_f |
开 D/A/F,IRQ 仍关 |
| C 层处理 |
irq_handler |
调用 gic_handle_irq,走 irq_desc → action 链 |
| 返回 |
kernel_exit + eret |
恢复寄存器,写回 ELR/SPSR,eret 原子返回 |
硬件做的事永远比你以为的多。你写的中断处理函数,只是整条链路上最后一环。更多底层系统知识,欢迎到 云栈社区 一起交流。