354|中断控制器:硬件是怎么打断 CPU 的

金句:你按下一个键,屏幕几乎同时出现字符——这背后,CPU 根本没在”等”键盘,它一直在跑自己的代码,直到硬件强制把它”打断”。


1. 问题:键盘是怎么把字符送到屏幕的?

你在终端敲一个 a,字符瞬间出现在屏幕上。这个过程看似简单,但背后隐藏着一套硬件协作机制:

Bash
键盘硬件 → 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。

Bash
  键盘 ──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)。

Bash
         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)。典型初始化:

Asm
; 初始化主片 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 里那位不会清除,优先级仲裁会被卡住,优先级更低的中断永远得不到服务。

Asm
; 向主片发送 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 实际上包含三个组件:

Bash
设备 IRQ → IOAPIC → 系统总线 → LAPIC(每个 CPU 核心一个)→ CPU

4.1 IOAPIC

IOAPIC 替代了 8259A,负责接收外设的中断并通过系统总线发送出去。它有 24 个 IRQ 引脚,比 8259A 更多,且支持MSI(Message Signaled Interrupt)

IOAPIC 的配置通过 MMIO 访问:

C
// 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)配置的:

Bash
+------+--------+--------+--------+------+------+
| 最高固定优先级 | 最低优先级 | SMI   | NMI  | INIT | ExtINT |
+------+--------+--------+--------+------+------+

Linux 里可以用 cat /proc/interrupts 看到每个 IRQ 被哪个 CPU 核心处理:

Bash
$ 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)用内存写事务代替物理信号线:

Bash
PCIe 设备 → 写内存事务(包含向量信息)→ 系统总线 → LAPIC → CPU

MSI 的优势:

  • 不需要 IRQ 引脚,理论上支持 2048 个 MSI 向量
  • 延迟更低(内存写比硬件中断线更快)
  • 支持多向量MSI(multiple MSI),每个设备可以申请多个中断向量

在 Linux 里查看 PCI 设备的 MSI 能力:

Bash
$ 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  audio

6. Linux 实践:看 /proc/interrupts

实验:观察中断分布

Bash
# 先安装 stress 增加调度压力
sudo apt install stress

# 终端1:观察中断计数
watch -n 1 'cat /proc/interrupts | head -30'

# 终端2:触发大量系统调用(产生软中断)
stress -c 4 &

输出示例:

Bash
            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 设备

Bash
$ 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/ 下):

Cpp
// 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 里登记:

Asm
; 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 把中断处理分为两部分:

Bash
上半部(Top Half):立即响应,做最紧急的事(读取硬件寄存器、清除中断标志)
下半部(Bottom Half):延迟处理,做耗时操作(网络包处理、文件系统写回)

以键盘中断为例:

C
// 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 里实现一个键盘中断处理程序,让按键触发屏幕输出。

  1. 下载 wandos

    Bash
    git clone https://github.com/zhangfuwen/wandos.git
    cd wandos
  2. 找到对应模块:查看 arch/x86/ 目录和 kernel/core/ 下的中断初始化代码

  3. 实现作业

    • 在 IDT 里登记 IRQ1(键盘)的中断处理函数
    • 在处理函数里从 0x60 端口读取扫描码
    • 发送 EOI 到 8259A
    • 把扫描码打印到屏幕或通过串口输出
  4. 提示

    • PS/2 键盘控制器端口:数据 = 0x60,命令 = 0x64
    • 8259A 主片 EOI 端口:0x20,发送 0x60 | (irq_number & 7)
    • 从片 EOI 端口:0xA0
  5. 提交作业: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不着急」,回复”资料”获取内核学习路线图。

仓库https://github.com/golang12306/os-kernel-from-scratch

最后修改: 2024年6月18日

作者

评论

发表评论

您的邮箱地址不会被公开。