2. 中断控制器:APIC、GIC、IOAPIC 架构与工作原理
好,咱们接着聊中断。上一章我讲了中断从设备到 CPU 的整个路径,但有个关键角色还没细说——中断控制器。
说白了,CPU 本身没法直接处理几十上百个设备的中断请求。它需要一个「管家」来帮它排队、过滤、分发。这个管家,就是中断控制器。
不同架构下,管家的脾气不一样。x86 世界用的是 APIC 家族,ARM 世界用的是 GIC 家族。我当年从 x86 嵌入式转到 ARM 平台时,第一件事就是啃 GIC 的手册。嗯,那感觉就像从 Windows 换到了 Linux 命令行——功能更强了,但上手也更复杂了。
2.1 从 PIC 到 APIC:x86 中断控制器的进化
老一点的工程师可能还记得 8259A 可编程中断控制器(PIC)。这东西在 8086 时代是标配,最多管理 8 个中断源,两片级联能扩展到 15 个。但缺点很明显——
- 只能发给一个 CPU(单核时代没问题)
- 中断优先级固定,不好动态调整
- 不支持中断向量重映射
到了多核时代,Intel 搞出了 APIC(Advanced Programmable Interrupt Controller)。我个人习惯把 APIC 分成两部分来看:
- Local APIC:每个 CPU 核自带一个,处理本地中断和核间中断(IPI)
- I/O APIC:南桥芯片里的一个独立单元,负责收集外设中断,再转发给 Local APIC
关键点:I/O APIC 和 Local APIC 之间通过 APIC 总线(老版本)或系统总线(新版本)通信。每个 I/O APIC 有最多 24 个中断输入引脚,对应 24 个 IRQ 号。
2.2 IOAPIC 的工作流程
我在调试一个 PCIe 网卡中断不触发的问题时,曾经把 IOAPIC 的寄存器翻了个底朝天。它的核心数据结构是 重定向表(Redirection Table),每个中断输入对应一个表项。
一个典型的重定向表项长这样:
// IOAPIC 重定向表项(64位)
struct ioapic_redir_entry {
u64 vector : 8; // 中断向量号
u64 delivery_mode : 3; // 传送模式(固定、最低优先级、SMI、NMI等)
u64 dest_mode : 1; // 目标模式(物理/逻辑)
u64 delivery_status : 1; // 发送状态(空闲/待发送)
u64 polarity : 1; // 中断极性(高电平/低电平)
u64 remote_irr : 1; // 远程 IRR(边沿触发时不用)
u64 trigger_mode : 1; // 触发模式(边沿/电平)
u64 mask : 1; // 屏蔽位
u64 reserved : 39; // 保留
u64 destination : 8; // 目标 CPU(APIC ID)
};
当外设拉高中断引脚时,IOAPIC 会做这几件事:
- 查重定向表,找到对应的表项
- 根据 delivery_mode 决定怎么发(固定发给某个核,还是找最闲的核)
- 把中断向量号和目标 APIC ID 打包,通过总线发给 Local APIC
- Local APIC 收到后,检查自己的 IRR(中断请求寄存器),如果 CPU 允许中断,就进入 IDT 查表执行
避坑指南:我曾经遇到一个奇怪的问题——电平触发的中断在驱动卸载后一直挂着,导致后续中断再也进不来。后来发现是 IOAPIC 的 remote_irr 位没有在驱动中正确清除。记住:电平触发的中断,驱动必须在中断处理完成后,通过操作外设寄存器来释放中断线,否则 IOAPIC 会认为中断还在 pending。
2.3 ARM 的 GIC:更灵活的架构
ARM 的 GIC(Generic Interrupt Controller)发展到今天,已经有好几个版本了。GICv2 是大多数 Cortex-A 系列 SoC 的标准配置,GICv3/v4 则用在服务器和最新移动芯片上。
GIC 的逻辑分两块:
- Distributor(分发器):全局控制,管理所有中断源的使能、优先级、目标 CPU
- CPU Interface(CPU 接口):每个 CPU 核一个,负责和核内的异常处理逻辑交互
GIC 支持三种中断类型:
| 类型 | ID 范围 | 说明 |
|---|---|---|
| SGI(软件生成中断) | 0-15 | 用于核间通信,一个核可以给另一个核发中断 |
| PPI(私有外设中断) | 16-31 | 每个核私有的中断,比如每个核的本地定时器 |
| SPI(共享外设中断) | 32-1019 | 所有核共享的中断,比如 PCIe MSI、GPIO 中断 |
你想想看,GIC 的 SPI 和 x86 的 IOAPIC 其实干的是同一件事——把外设中断路由到合适的 CPU 核。但 GIC 的优先级管理更精细,支持 256 级优先级(GICv2 是 16 级)。
2.4 GICv2 的中断处理流程
以 SPI 为例,一个典型的中断处理流程是这样的:
// 伪代码:GICv2 中断处理
1. 外设拉高中断线 → GIC Distributor 检测到
2. Distributor 查寄存器:
- 中断使能了吗?(GICD_ISENABLER)
- 优先级够高吗?(GICD_IPRIORITYR)
- 目标 CPU 是哪个?(GICD_ITARGETSR)
3. Distributor 把中断转发给目标 CPU 的 CPU Interface
4. CPU Interface 检查当前 CPU 的优先级掩码 (GICC_PMR)
5. 如果中断优先级 > PMR,则向 CPU 核发送 IRQ 信号
6. CPU 跳转到异常向量表,执行中断处理函数
7. 处理函数中必须写 GICC_EOIR(End of Interrupt)寄存器,通知 GIC 中断处理完毕
注意:GICv2 有个坑——如果你在中断处理函数中忘了写 EOIR,GIC 会认为中断还在处理中,后续所有同优先级或更低优先级的中断都会被阻塞。我见过一个产品因为这个原因导致触摸屏偶尔卡死,查了两天才定位到是某个中断处理路径漏了 EOIR。
2.5 GICv3/v4 的新特性
GICv3 相比 v2,最大的变化是支持了更多的 CPU 核(超过 8 个),并且引入了 Re-distributor 的概念。每个 CPU 核配一个 Re-distributor,分担了 Distributor 的压力。
另外,GICv3 支持 LPI(Locality-specific Peripheral Interrupt),这是一种基于消息的中断,类似 PCIe 的 MSI。LPI 的配置不放在 Distributor 的寄存器里,而是放在内存中的配置表中。这样中断数量可以扩展到 16K 以上。
GICv4 更进一步,加入了 Direct Injection 支持,允许虚拟化场景下直接将中断注入到虚拟机,不需要 Hypervisor 介入。这个在云计算场景下特别有用。
2.6 实际项目中的选择建议
说了这么多理论,最后给点实战建议:
- x86 平台:IOAPIC 的配置通常在 BIOS/UEFI 中完成,驱动开发者只需要关注 MSI/MSI-X。我个人建议新项目尽量用 MSI-X,每个设备可以分配多个中断向量,而且不需要共享 IRQ 线。
- ARM 平台:如果 SoC 支持 GICv3,优先用 GICv3。GICv2 的 8 核限制在服务器场景下已经不够用了。而且 GICv3 的 LPI 对 PCIe 设备支持更好。
- 中断亲和性:不管是 APIC 还是 GIC,都可以把特定中断绑定到特定 CPU 核。我在做网络驱动时,会把网卡的所有中断绑定到同一个核上,避免缓存抖动。但要注意,别把所有中断都塞给 CPU0,否则 CPU0 会忙死。
一句话总结:中断控制器就是 CPU 和外设之间的「调度员」。x86 用 APIC + IOAPIC,ARM 用 GIC。理解它们的寄存器模型和工作流程,是写好中断驱动的第一步。
下一章我会讲中断处理函数怎么写,以及那些让人头疼的竞态条件和锁的问题。到时候见。