354|中断控制器:硬件是怎么打断 CPU 的
金句:你按下一个键,屏幕几乎同时出现字符——这背后,CPU 根本没在”等”键盘,它一直在跑自己的代码,直到硬件强制把它”打断”。
1. 问题:键盘是怎么把字符送到屏幕的?
你在终端敲一个 a,字符瞬间出现在屏幕上。这个过程看似简单,但背后隐藏着一套硬件协作机制:
键盘硬件 → IRQ1 中断 → 8259A PIC → CPU 引脚 → 中断处理程序 → 终端驱动 → 屏幕关键问题是:CPU 在被中断之前完全不知道键盘发生了什么。它不可能停下来轮询(polling)每个设备,那样效率太低。
解决方案是中断(Interrupt):CPU 只管跑自己的代码,设备有事时”主动敲门”——通过硬件信号线向 CPU 发起中断,CPU 被迫停下当前工作,去执行专门的中断处理程序。
本文就来拆解这个链路:8259A PIC、IOAPIC、LAPIC、MSI——这些硬件是怎么一步步把外设的中断送到 CPU 的。
2. 中断控制器是什么
CPU 只有几根专门用来接收中断的引脚(比如 x86 的 INTR 和 NMI)。但一个电脑上有几十个设备(键盘、鼠标、硬盘、网卡……),每个都能发中断。
中断控制器就是连接所有外设和 CPU 的”接线员”:它接收各个设备的中断请求(IRQ),经过优先级排序后,转换为 CPU 能理解的向量号,通过 INTR 引脚通知 CPU。
键盘 ──IRQ1──┐
鼠标 ──IRQ12─┤ ┌────────────┐ ┌──────┐
硬盘 ──IRQ14─┼──→ │ 8259A PIC │ ───→ │ CPU │
网卡 ──IRQ5──┘ └────────────┘ └──────┘
(主片 + 从片级联)没有中断控制器,CPU 就得为每个设备配一根专属的中断线——这在有几十个设备的 PC 上是不可能的。
3. 8259A PIC:经典的可编程中断控制器
3.1 历史背景
Intel 在 1976 年推出了 8259A PIC(Programmable Interrupt Controller)。它有 8 根中断输入引脚(IRQ0-IRQ7),通过级联可以扩展到 15 个。IBM PC/AT 的标准配置是两级级联:主片(IRQ0-IRQ7)和从片(IRQ9-IRQ12 共享 IRQ2)。
CPU
↓ INTR
┌─────────┐
│ 8259A │ 主片(主控制器)
│ (Master)│
└────┬────┘
│ IRQ2(级联)
┌────┴────┐
│ 8259A │ 从片(从控制器)
│ (Slave) │
└─────────┘3.2 内部结构
8259A 内部有几个关键寄存器:
- IRR(Interrupt Request Register):记录哪些 IRQ 正在等待处理
- ISR(In-Service Register):记录哪些 IRQ 正在被 CPU 处理
- IMR(Interrupt Mask Register):软件屏蔽某些 IRQ
优先级仲裁逻辑每次从 IRR 里挑最高优先级的请求,送往 CPU。
3.3 中断向量号
8259A 支持两种模式:
- 非缓冲模式:自己放向量号到数据总线
- x86 真实模式:起始向量基址为 0x08(主片)和 0x70(从片)
x86 PC 的默认映射:
| IRQ | 用途 | 向量号(默认) |
|---|---|---|
| IRQ0 | 定时器(PIT) | 0x08 |
| IRQ1 | 键盘 | 0x09 |
| IRQ2 | 级联(从片) | — |
| IRQ3 | 串口 COM2 | 0x0B |
| IRQ4 | 串口 COM1 | 0x0C |
| IRQ5 | 并口 LPT2 | 0x0D |
| IRQ6 | 软盘控制器 | 0x0E |
| IRQ7 | 并口 LPT1 | 0x0F |
| IRQ8 | RTC | 0x70 |
| IRQ9 | 声音卡(SB) | 0x71 |
| IRQ10 | 空闲 | 0x72 |
| IRQ11 | 空闲 | 0x73 |
| IRQ12 | PS/2 鼠标 | 0x74 |
| IRQ13 | 协处理器(FPU) | 0x75 |
| IRQ14 | 主 IDE | 0x76 |
| IRQ15 | 从 IDE | 0x77 |
3.4 初始化序列
8259A 需要通过 I/O 端口写入初始化命令字(ICW)。典型初始化:
; 初始化主片 8259A
mov al, 0x11 ; ICW1:边沿触发,级联,需要 ICW4
out 0x20, al ; Command port
mov al, 0x08 ; ICW2:向量基址 = 0x08
out 0x21, al ; Data port
mov al, 0x04 ; ICW3:IRQ2 上有从片
out 0x21, al
mov al, 0x01 ; ICW4:8086 模式
out 0x21, al
; 初始化从片 8259A
mov al, 0x11
out 0xA0, al
mov al, 0x70 ; ICW2:向量基址 = 0x70
out 0xA1, al
mov al, 0x02 ; ICW3:从片接在主片的 IRQ2 上
out 0xA1, al
mov al, 0x01
out 0xA1, al
; 设置中断屏蔽(允许键盘和定时器)
mov al, 0xFC ; 允许 IRQ1(键盘)和 IRQ0(定时器)
out 0x21, al ; 主片 mask
mov al, 0xFF ; 禁止从片所有中断
out 0xA1, al解释:这段代码完成了 8259A 的完整初始化:设置向量基址、建立级联关系、开放所需中断。
3.5 EOI(End Of Interrupt)告知
每个中断处理完成后,必须告诉 8259A”当前中断已处理完毕”——否则 8259A 的 ISR 里那位不会清除,优先级仲裁会被卡住,优先级更低的中断永远得不到服务。
; 向主片发送 EOI
mov al, 0x20
out 0x20, al ; OCW2:发送普通 EOI 到从片
; 如果是从片的中断,还要再发一个 EOI 给从片坑1:忘记发 EOI 会导致该 IRQ 上的所有后续中断永久丢失。
4. APIC:多核时代的中断控制器
8259A 只能服务于单核 CPU,而且 IRQ 线有限(最多 15 个)。在多核时代,它被APIC(Advanced Programmable Interrupt Controller)取代。
APIC 实际上包含三个组件:
设备 IRQ → IOAPIC → 系统总线 → LAPIC(每个 CPU 核心一个)→ CPU4.1 IOAPIC
IOAPIC 替代了 8259A,负责接收外设的中断并通过系统总线发送出去。它有 24 个 IRQ 引脚,比 8259A 更多,且支持MSI(Message Signaled Interrupt)。
IOAPIC 的配置通过 MMIO 访问:
// IOAPIC 寄存器
#define IOAPIC_REG_SELECT 0x00
#define IOAPIC_REG_WINDOW 0x10
// 选择红黄表项(Redirection Table Entry)
void ioapic_write_entry(int irq, uint32_t entry) {
*((volatile uint32_t*)(IOAPIC_BASE + IOAPIC_REG_SELECT)) = 0x10 + irq * 2;
*((volatile uint32_t*)(IOAPIC_BASE + IOAPIC_REG_WINDOW)) = entry;
}4.2 LAPIC
每个 CPU 核心有一个本地 APIC(LAPIC),负责接收来自 IOAPIC 的中断并投递到核心内部。LAPIC 有自己的 ID 寄存器(APIC ID),用于多核系统中的中断路由。
关键寄存器:
- LAPIC ID 寄存器(0xFEE00020):标识每个 CPU 核心
- TPR(Task Priority Register):控制 CPU 屏蔽某些优先级的中断
- SVR(Spurious Interrupt Vector Register):处理”虚假”中断(无对应处理程序时)
- EOI 寄存器(0xFEE000B0):中断处理完成告知
4.3 中断路由到多核
LAPIC 的关键能力是把中断送到任意 CPU 核心。这是通过 IOAPIC 的红黄表项(Redirection Entry)配置的:
+------+--------+--------+--------+------+------+
| 最高固定优先级 | 最低优先级 | SMI | NMI | INIT | ExtINT |
+------+--------+--------+--------+------+------+Linux 里可以用 cat /proc/interrupts 看到每个 IRQ 被哪个 CPU 核心处理:
$ cat /proc/interrupts
CPU0 CPU1 CPU2 CPU3
0: 123456 78910 55660 33440 IO-APIC 2-edge timer
1: 120 98 112 90 IO-APIC 1-edge i8042
8: 40 35 28 22 IO-APIC 8-edge rtc0
12: 230 198 210 189 IO-APIC 12-edge mouse
NMI: 100 80 90 70 Local-APIC non-maskable解释:可以看到 timer(IRQ0)被分配到 CPU0 处理,键盘(IRQ1)也在 CPU0 上。多核系统里,内核调度器会把中断分散到不同核心,实现负载均衡。
5. MSI:绕过硬件线的中断
传统中断依赖 IOAPIC 的物理引脚,但 PCI/PCIe 设备越来越多,引脚不够用了。MSI(Message Signed Interrupt)用内存写事务代替物理信号线:
PCIe 设备 → 写内存事务(包含向量信息)→ 系统总线 → LAPIC → CPUMSI 的优势:
- 不需要 IRQ 引脚,理论上支持 2048 个 MSI 向量
- 延迟更低(内存写比硬件中断线更快)
- 支持多向量MSI(multiple MSI),每个设备可以申请多个中断向量
在 Linux 里查看 PCI 设备的 MSI 能力:
$ cat /proc/interrupts | grep MSI
259: 12340 0 0 0 MSI 0000:00:1f.2 storage
260: 8901 0 0 0 MSI 0000:00:1f.3 audio6. Linux 实践:看 /proc/interrupts
实验:观察中断分布
# 先安装 stress 增加调度压力
sudo apt install stress
# 终端1:观察中断计数
watch -n 1 'cat /proc/interrupts | head -30'
# 终端2:触发大量系统调用(产生软中断)
stress -c 4 &输出示例:
CPU0 CPU1 CPU2 CPU3
0: 15432 8921 10234 7832 IO-APIC 2-edge timer
1: 120 98 112 90 IO-APIC 1-edge i8042
8: 40 35 28 22 IO-APIC 8-edge rtc0
9: 11234 9876 8765 7654 IO-APIC 9-edge acpi
12: 230 198 210 189 IO-APIC 12-edge mouse
LOC: 1234567 987654 876543 765432 Local-APIC calibration观察:
- 定时器中断(IRQ0)在所有核心上都在增加,说明时钟中断均匀分布
LOC(Local-APIC calibration)是 LAPIC 自己的校准中断- 鼠标和键盘中断只在少数核心上出现(它们的驱动是单线程的)
查看 MSI 设备
$ lspci -v | grep -A 5 "MSI"
Capabilities: [70] MSI-X: Enable- Count=32 Masked-
IRQ: 28
Vector table: BAR=4 offset=00006800解释:MSI-X: Enable- Count=32 说明这个设备支持 32 个 MSI 向量。
7. 从零实现:wandos 的中断处理
⚠️ wandos 说明:wandos 目前实现了 APIC 基础初始化和时钟中断(IRQ0),但 8259A 兼容层和 IOAPIC 完整路由尚未实现。本节以 Linux 为主,wandos 的差距在文末说明。
7.1 APIC 初始化
wandos 的 APIC 初始化(在 arch/x86/ 下):
// APIC 基址从 MSR 获取
uint64_t apic_base = read_msr(0x1B); // IA32_APIC_BASE_MSR
uint32_t lapic_base = apic_base & ~0xFFF;
void lapic_enable() {
// 设置 APIC Enable 位(bit 11)
uint32_t svr = lapic_read(0xF0);
lapic_write(0xF0, svr | 0x100);
}
void lapic_eoi() {
// 写 EOI 寄存器,告知 LAPIC 当前中断处理完毕
lapic_write(0xB0, 0); // offset 0xB0 = EOI
}wandos 差距:wandos 写了 LAPIC 初始化,但没有实现完整的 IOAPIC 中断路由表配置,导致目前只能处理本地中断(LAPIC 自身产生的 timer/SMI 等),外部设备的中断还未完全打通。
7.2 IDT(中断描述符表)设置
中断处理程序的入口在 IDT 里登记:
; IDT 表项格式(Gate Descriptor)
; 每项 8 字节:offset low(2) + selector(2) + 0 + type(1) + offset high(2) + offset v.high(4)
setup_idt:
mov eax, handler_irq0 ; 中断处理函数地址
mov [idt + 8*32], ax ; IDT[32] = timer
mov [idt + 8*32 + 2], 0x08 ; selector = kernel code
mov [idt + 8*32 + 4], 0x8E ; type = 386 interrupt gate, DPL=0
lidt [idt_ptr] ; 加载 IDT
sti ; 开中断解释:IDT 里登记了 32 号向量(IRQ0 映射到 32 开始)对应的处理函数地址。CPU 在收到 APIC 递送的中断后,查 IDT 执行对应 handler。
7.3 Linux 的中断处理框架
Linux 把中断处理分为两部分:
上半部(Top Half):立即响应,做最紧急的事(读取硬件寄存器、清除中断标志)
下半部(Bottom Half):延迟处理,做耗时操作(网络包处理、文件系统写回)以键盘中断为例:
// Linux 键盘驱动(drivers/input/keyboard/atkbd.c)
static irqreturn_t atkbd_interrupt(int irq, void *dev_id) {
// 上半部:快速读取键盘扫描码
unsigned char scancode = inb(0x60); // PS/2 数据端口
// 清除 8259A 中断标志(发送 EOI)
if (irq >= 8)
outb(0x20, 0xA0); // 从片 EOI
outb(0x20, 0x20); // 主片 EOI
// 把扫描码交给工作队列(下半部)
tasklet_schedule(&atkbd->tasklet);
return IRQ_HANDLED;
}解释:中断处理函数必须尽可能短,因为在它执行期间,CPU 是被这个中断打断的,其他设备的中断可能被屏蔽。所以键盘驱动的上半部只做了读扫描码一件事,其余处理都推到下半部。
8. 动手环节:实现键盘中断处理
今天的目标:在 wandos 里实现一个键盘中断处理程序,让按键触发屏幕输出。
-
下载 wandos:
git clone https://github.com/zhangfuwen/wandos.git cd wandos -
找到对应模块:查看
arch/x86/目录和kernel/core/下的中断初始化代码 -
实现作业:
- 在 IDT 里登记 IRQ1(键盘)的中断处理函数
- 在处理函数里从 0x60 端口读取扫描码
- 发送 EOI 到 8259A
- 把扫描码打印到屏幕或通过串口输出
-
提示:
- PS/2 键盘控制器端口:数据 = 0x60,命令 = 0x64
- 8259A 主片 EOI 端口:0x20,发送
0x60 | (irq_number & 7) - 从片 EOI 端口:0xA0
-
提交作业:Fork 仓库,提交你的改动,在 GitHub 上开一个 Pull Request
9. 踩坑与注意事项
坑1:8259A 和 APIC 不能同时使能
老系统用 8259A,多核系统用 APIC。如果同时打开两套中断体系,它们会互相干扰,导致某些 IRQ 的中断永远得不到响应。Linux 在启用 APIC 之前会先禁用 8259A。
坑2:IRQ 共享时 EOI 要慎发
共享 IRQ(比如多个 PCI 设备共用 IRQ11)时,每个设备的中断处理程序都要负责清除自己的中断标志。如果某个驱动忘记发 EOI,同一 IRQ 线上的其他设备就会”饿死的”。
坑3:ACPI 中断路由依赖固件
在 APIC 模式下,操作系统要通过 ACPI 的 DSDT 表来获知每个 IRQ 应该路由到哪个 CPU 核心。如果固件配置错误,某些设备的中断可能被路由到一个不存在的 CPU 核心,导致系统挂死。
10. 总结
|| 知识点 | 关键结论 |
|——–|———|———|
| 8259A PIC | 8 位可编程中断控制器,PC/AT 两片级联最多 15 个 IRQ | IRQ 线上设备众多,8259A 做仲裁和向量分配 |
| APIC | 替代 8259A,支持多核,支持 MSI | IOAPIC 接收设备中断,LAPIC 投递给 CPU 核心 |
| MSI | 内存写事务替代物理中断线 | 延迟低,扩展性强,PCIe 设备标配 |
| Linux 中断处理 | 上半部快速响应,下半部延迟处理 | 中断处理函数必须尽可能短 |
| EOI | 中断处理完成后告知控制器 | 忘记发 EOI 会导致同类中断永久丢失 |
下篇预告(355):虚拟内存——从 CR3 到完整页表建立。CPU 是怎么从虚拟地址一步步找到物理内存的,页表结构、三级页表转换、CR3 寄存器,一文打通。
关注公众号「AI不着急」,回复”资料”获取内核学习路线图。
评论