本页大纲

Interrupts

什么是中断

中断是一种异步事件通知机制。当特定事件发生时(硬件信号、CPU 异常、软件指令),CPU 会:

  1. 保存当前状态 - 保存程序计数器、寄存器等上下文
  2. 查找处理函数 - 根据中断类型,在中断描述符表(IDT)中找到对应的处理函数
  3. 执行处理逻辑 - 跳转到中断处理函数,处理该事件
  4. 恢复执行 - 恢复之前保存的状态,继续执行被中断的程序

中断的关键特性是异步性:被中断的程序不知道何时会被打断,也不需要主动配合。这让操作系统能够在不修改应用程序的情况下,响应外部事件

中断的类型

x86 架构将中断分为三类:异常(Exception)硬件中断(Hardware Interrupt)软件中断(Software Interrupt)

1. 异常(Exception)

异常由 CPU 内部产生,通常表示程序执行过程中遇到了错误或特殊情况:

  • 除零错误(Divide by Zero) - 除法指令的除数为 0
  • 页错误(Page Fault) - 访问未映射或无权限的内存地址
  • 无效操作码(Invalid Opcode) - CPU 遇到无法识别的指令
  • 断点(Breakpoint) - 调试器设置的断点指令
  • 双重错误(Double Fault) - 处理异常时又发生了异常

异常是同步的:它们在特定指令执行时确定性地发生。例如,执行 mov [0], eax 访问空指针时,必然触发页错误

2. 硬件中断(Hardware Interrupt)

硬件中断由外部设备通过中断控制器发送给 CPU:

  • 定时器中断(Timer Interrupt) - 周期性触发,用于任务调度

    例如:Linux 默认每 10ms 触发一次,操作系统借此实现进程切换

  • 键盘中断(Keyboard Interrupt) - 按键按下或释放时触发

    例如:用户按下 Ctrl+C 时,键盘中断让操作系统能立即响应并终止程序

  • 网卡中断(Network Interrupt) - 收到网络数据包时触发

    例如:收到 TCP 数据包时,网卡通过中断通知操作系统处理

  • 硬盘中断(Disk Interrupt) - 磁盘 I/O 完成时触发

    例如:读取文件完成后,硬盘控制器发送中断,操作系统可以继续处理数据

硬件中断是异步的:它们可以在任意时刻发生,与当前执行的指令无关

3. 软件中断(Software Interrupt)

软件中断由程序通过特殊指令主动触发,常用于系统调用:

  • int 0x80 - Linux 传统系统调用接口
  • syscall - 现代 x86-64 系统调用指令

软件中断是同步的,但它是程序主动请求的,而不是错误

中断控制器

理解了中断的三种类型后,我们来看硬件中断如何到达 CPU。CPU 异常可以直接触发,但外部设备的中断需要通过中断控制器来管理

中断控制器负责:

  1. 接收外部设备的中断信号 - 多个设备可能同时发送中断
  2. 优先级仲裁 - 决定哪个中断应该先被处理
  3. 向 CPU 发送中断向量号 - 告诉 CPU 应该执行哪个中断处理函数

PIC(可编程中断控制器)

PIC

早期 x86 系统使用 8259 PIC(Programmable Interrupt Controller)。一个 PIC 芯片支持 8 个中断源,通过级联两个 PIC 可以支持 15 个中断(主 PIC 的一个引脚连接从 PIC)

PIC 的局限性:

  • 只支持单核 CPU
  • 中断向量号固定,不够灵活
  • 性能较低,不适合现代高速设备

APIC(高级可编程中断控制器)

APIC

现代 x86 系统使用 APIC(Advanced Programmable Interrupt Controller),分为两部分:

  • Local APIC - 每个 CPU 核心内置一个,接收来自 I/O APIC 的中断和核间中断(IPI)
  • I/O APIC - 连接外部设备,将中断路由到指定的 Local APIC

APIC 的优势:

  • 支持多核 CPU,可以将中断分发到不同核心
  • 支持中断优先级和中断屏蔽
  • 支持核间中断(IPI),用于多核同步

操作系统启动时需要初始化 APIC,配置中断路由表,将每个硬件中断映射到合适的中断向量号

中断处理流程

interrupt_flow

当中断发生时,CPU 会执行以下步骤:

1. 中断触发

  • 硬件中断:外部设备通过中断控制器(APIC/PIC)向 CPU 发送中断信号
  • 异常:CPU 执行指令时检测到错误条件
  • 软件中断:程序执行 intsyscall 指令

2. 保存上下文

CPU 自动执行一系列复杂的状态保存操作,确保中断处理函数能在正确的环境中运行:

2.1 对齐栈指针

任何指令都可能触发中断,所以栈指针可能是任何值。部分 CPU 指令(如 SSE 指令)需要栈指针 16 字节边界对齐,因此 CPU 会在中断触发后立刻进行对齐

2.2 切换栈(特权级改变时)

当 CPU 特权等级改变时(如用户态程序触发异常),会发生栈切换:

  • 从任务状态段(TSS)或中断栈表(IST)中获取新的栈指针
  • 切换到内核栈,避免使用可能不可信的用户栈

2.3 压入旧栈指针

在栈指针对齐之前,CPU 将栈指针寄存器(RSP)和栈段寄存器(SS)压入新栈,以便中断返回后恢复

2.4 压入并更新 RFLAGS 寄存器

RFLAGS 寄存器包含各种控制位和状态位。CPU 会:

  • 将当前 RFLAGS 值压入栈
  • 清除某些标志位(如中断使能标志 IF),防止中断嵌套

2.5 压入指令指针

CPU 将指令指针寄存器(RIP)和代码段寄存器(CS)压入栈,记录被中断的位置

2.6 压入错误码(部分异常)

某些异常(如页错误、通用保护错误)会额外压入一个错误码,用于标记错误的具体原因:

  • 页错误:错误码指示是读/写错误、用户/内核模式、页是否存在
  • 通用保护错误:错误码指示违规的段选择子

栈布局示例(特权级切换时):

txt
高地址
+------------------+
|       SS         | ← 旧栈段
+------------------+
|       RSP        | ← 旧栈指针
+------------------+
|     RFLAGS       | ← 旧标志寄存器
+------------------+
|       CS         | ← 旧代码段
+------------------+
|       RIP        | ← 返回地址
+------------------+
|   错误码(可选)    | ← 仅部分异常
+------------------+ ← 当前 RSP
低地址

3. 查找处理函数

CPU 根据中断向量号(0-255)在中断描述符表(IDT)中查找对应的处理函数入口地址

4. 执行中断处理函数

跳转到中断处理函数,执行具体的处理逻辑:

  • 读取设备数据(硬件中断)
  • 终止进程或发送信号(异常)
  • 执行系统调用(软件中断)

5. 中断返回

执行 iret 指令,从栈中恢复之前保存的状态,继续执行被中断的程序

这个流程保证了中断处理的透明性:被中断的程序感觉不到自己被暂停过,就像中断从未发生

中断描述符表(IDT)

中断描述符表(Interrupt Descriptor Table)是一个包含 256 个条目的数组,每个条目对应一个中断向量号(0-255)。CPU 通过 IDT 找到每个中断的处理函数

x86 架构预定义了前 32 个中断向量用于 CPU 异常,剩余的 32-255 可用于硬件中断和软件中断

IDT 结构示例

以下是一个典型的 IDT 结构:

rust
#[repr(C)]
pub struct InterruptDescriptorTable {
    pub divide_by_zero: Entry<HandlerFunc>,
    pub debug: Entry<HandlerFunc>,
    pub non_maskable_interrupt: Entry<HandlerFunc>,
    pub breakpoint: Entry<HandlerFunc>,
    pub overflow: Entry<HandlerFunc>,
    pub bound_range_exceeded: Entry<HandlerFunc>,
    pub invalid_opcode: Entry<HandlerFunc>,
    pub device_not_available: Entry<HandlerFunc>,
    pub double_fault: Entry<HandlerFuncWithErrCode>,
    pub invalid_tss: Entry<HandlerFuncWithErrCode>,
    pub segment_not_present: Entry<HandlerFuncWithErrCode>,
    pub stack_segment_fault: Entry<HandlerFuncWithErrCode>,
    pub general_protection_fault: Entry<HandlerFuncWithErrCode>,
    pub page_fault: Entry<PageFaultHandlerFunc>,
    pub x87_floating_point: Entry<HandlerFunc>,
    pub alignment_check: Entry<HandlerFuncWithErrCode>,
    pub machine_check: Entry<HandlerFunc>,
    pub simd_floating_point: Entry<HandlerFunc>,
    pub virtualization: Entry<HandlerFunc>,
    pub security_exception: Entry<HandlerFuncWithErrCode>,
    // some fields omitted
}

关键字段说明:

  • divide_by_zero (0) - 除零异常,除法指令除数为 0 时触发
  • debug (1) - 调试异常,单步执行或断点触发
  • breakpoint (3) - 断点异常,执行 int 3 指令时触发
  • page_fault (14) - 页错误,访问未映射或无权限的内存时触发
  • double_fault (8) - 双重错误,处理异常时又发生异常
  • general_protection_fault (13) - 通用保护错误,违反段保护或特权级检查

每个条目的类型取决于是否需要错误码:

  • HandlerFunc - 不带错误码的处理函数
  • HandlerFuncWithErrCode - 带错误码的处理函数(如页错误、通用保护错误)
  • PageFaultHandlerFunc - 页错误专用处理函数,包含错误码和触发地址

装载 IDT

操作系统启动时需要初始化 IDT 并通过 lidt 指令告诉 CPU:

rust
// 初始化 IDT
let mut idt = InterruptDescriptorTable::new();
idt.divide_by_zero.set_handler_fn(divide_by_zero_handler);
idt.page_fault.set_handler_fn(page_fault_handler);
// ... 设置其他处理函数

// 装载 IDT
idt.load();

一旦 IDT 装载完成,CPU 就能正确响应各种中断和异常

中断 vs 轮询

为什么中断比轮询更高效?让我们通过一个具体场景对比:

场景:等待键盘输入

轮询方式:

rust
loop {
    if keyboard.has_data() {
        let key = keyboard.read();
        process(key);
    }
    // CPU 一直在循环检查,即使没有按键
}

中断方式:

rust
// 注册中断处理函数
idt.keyboard_interrupt.set_handler_fn(keyboard_handler);

// CPU 继续执行其他任务
do_useful_work();

// 当按键发生时,CPU 自动跳转到这里
fn keyboard_handler() {
    let key = keyboard.read();
    process(key);
}

效率对比

假设用户每秒按键 5 次,CPU 主频 3 GHz:

方式CPU 利用率响应延迟
轮询~100%(大部分时间在空转)取决于轮询频率
中断~0.001%(仅处理中断时占用)微秒级(硬件延迟)

中断让 CPU 从"主动询问"变为"被动通知",释放了 99.999% 的 CPU 时间用于真正的计算任务

何时使用轮询

尽管中断更高效,但某些场景下轮询仍然有用:

  • 高频事件 - 如果事件发生频率极高(如高速网卡每秒数百万包),中断开销可能超过轮询
  • 实时性要求 - 轮询的延迟更可预测,适合硬实时系统
  • 简单设备 - 某些简单设备不支持中断

现代系统常采用混合模式:正常情况下使用中断,高负载时切换到轮询(如 NAPI 网络驱动)

实际应用场景

中断在操作系统中无处不在,以下是几个典型应用:

定时器中断 - 任务调度

操作系统使用定时器中断实现抢占式多任务。定时器每隔固定时间(如 10ms)触发一次中断,操作系统在中断处理函数中:

  1. 保存当前进程的状态
  2. 选择下一个要运行的进程(调度算法)
  3. 恢复新进程的状态并切换执行

这让多个进程能够"同时"运行,即使只有一个 CPU 核心

键盘中断 - 用户输入

当用户按下键盘时,键盘控制器发送中断信号。操作系统在中断处理函数中:

  1. 从键盘控制器读取扫描码
  2. 将扫描码转换为字符
  3. 放入输入缓冲区,供应用程序读取

这让应用程序不需要轮询键盘,可以专注于自己的逻辑

网卡中断 - 网络数据包

当网卡收到数据包时,触发中断通知操作系统。中断处理函数:

  1. 从网卡 DMA 缓冲区读取数据包
  2. 解析协议头(以太网、IP、TCP/UDP)
  3. 将数据包传递给对应的网络协议栈

现代高速网卡使用 NAPI(New API)机制:第一个数据包触发中断,后续数据包使用轮询处理,避免中断风暴

页错误 - 按需分页

当程序访问未映射的内存地址时,CPU 触发页错误异常。操作系统在异常处理函数中:

  1. 检查访问地址是否合法
  2. 如果合法,分配物理页并建立映射
  3. 如果非法,终止进程或发送 SIGSEGV 信号

这是虚拟内存的核心机制,让程序可以使用比物理内存更大的地址空间

总结

中断是操作系统响应异步事件的核心机制:

  • 异步通知 - CPU 不再轮询设备,而是在事件发生时被主动通知
  • 透明处理 - 被中断的程序无感知,操作系统自动保存和恢复状态
  • 高效利用 - 释放 CPU 时间用于真正的计算任务,而不是空转等待

中断机制涉及三个关键组件:

  1. 中断描述符表(IDT) - 存储每个中断的处理函数地址
  2. 中断控制器(APIC/PIC) - 管理硬件中断的优先级和路由
  3. 中断处理函数 - 执行具体的事件处理逻辑

没有中断,现代操作系统将无法高效地处理键盘输入、网络数据包、定时器事件等异步事件。中断让 CPU 从"主动询问"变为"被动响应",是操作系统设计中的关键创新