0. 阅读指南

本文是关于操作系统中断机制与设备驱动的系统教程。全文以 x86/x86_64 架构和 Linux 内核为主要参照,从 CPU 执行指令的底层视角讲起,逐步过渡到硬件中断控制器、设备驱动模型和实际驱动开发。无论你是刚开始学习操作系统、正在阅读内核源码,还是想写第一个 Linux 驱动,都可以沿着本文的路线建立完整知识框架。

学习本文前,最好具备以下基础:

  • 了解 C 语言的基本语法,能看懂函数、指针和结构体。
  • 知道二进制、十六进制和位运算的基本概念。
  • 对 CPU、内存、总线等计算机组成原理有初步认识。
  • 能够在 Linux 环境下使用 gcc、make 和基础命令行。

如果暂时没有这些基础,也可以先阅读概念部分,再回到代码示例中反复验证。本文约两万字,建议不要一次读完,可以按章节拆分,每读完一章就动手写一段代码、观察一次现象。

1. 从一个按键说起:为什么需要中断

想象一个最普通的场景:你在终端里按下一个按键,字符立刻出现在屏幕上。对 CPU 来说,这个动作并不是它主动安排好的。CPU 正在执行某段代码,可能是在做数学运算、调度进程,也可能在空转等待。按键信号来自键盘控制器,发生的时间完全由用户决定。CPU 必须及时注意到这个外部事件,读取按键扫描码,然后交给操作系统处理。

计算机系统处理外部设备事件,主要有两种思路:一种是 CPU 不断去问设备“你准备好了吗”,另一种是设备在需要服务时主动通知 CPU。前者称为轮询,后者称为中断。

轮询很好理解:CPU 每隔一段时间读取设备状态寄存器,如果发现有输入、输出完成、错误发生等事件,就执行相应处理。它的优点是实现简单、流程可控,但缺点也非常明显:如果设备事件发生频率很低,CPU 会浪费大量时间反复查询;如果设备事件到来很快,CPU 又可能来不及轮询,导致数据丢失。更重要的是,轮询让 CPU 的程序结构和设备状态绑定在一起,系统越复杂,轮询越难以维护。

中断则把主动权交给了设备。设备在需要服务时,通过硬件信号通知中断控制器,中断控制器再通知 CPU。CPU 暂停当前任务,保存必要的上下文,转入中断处理程序,执行完后再恢复被打断的任务。这个过程中,被打断的代码通常完全不知道中断发生过。CPU 的指令流被暂时切走,再被切回来,因此中断从程序视角看是异步发生的。

可以把中断理解为课堂提问。老师正在讲课,学生有问题时举手,老师暂停讲解,处理学生的问题,然后继续讲课。轮询则是老师每隔十秒停下来问所有学生“有没有问题”。显然,举手的方式响应更快,也不会在课堂安静时反复打扰所有人。

中断在操作系统中承担了几类核心任务:

  • 设备事件通知:键盘输入、鼠标移动、硬盘读写完成、网卡收到数据包、定时器到期等。
  • 异常处理:除零、非法指令、缺页、保护错误等 CPU 在执行指令时发现的异常。
  • 系统调用:用户态程序通过专门的指令陷入内核,请求操作系统服务。
  • 核心间通信:在多核系统中,一个 CPU 可以通过处理器间中断通知另一个 CPU 执行特定工作。

因此,中断不仅是设备驱动的基础,也是操作系统实现进程调度、内存管理和系统调用的重要机制。理解中断,就等于理解了操作系统“随时被打断、随时恢复”的核心工作方式。

2. 中断的分类与基本概念

2.1 按来源分类

从来源看,中断可以分为硬件中断和软件中断。硬件中断由处理器外部设备通过中断控制器产生,例如键盘、硬盘、网卡、定时器。软件中断则是由程序主动触发,例如 x86 的 int n 指令、Linux 的系统调用入口等。需要特别说明的是,软件中断中的“中断”更多表示一种陷入内核的机制,不一定由硬件异步事件触发。

从 CPU 内部看,异常也常被归入中断体系。异常是 CPU 在取指、译码或执行过程中检测到的特殊情况。它和外部硬件中断有本质区别:异常通常与当前指令相关,有些异常可以修复后重试,例如缺页异常;有些异常则会导致进程被终止,例如保护错误。

2.2 同步异常与异步中断

操作系统教材通常会强调“同步异常”和“异步中断”的差别。缺页、除零、非法指令等异常是同步的,因为它们的发生位置可以精确定位到某条指令,而且同一个程序用同样的输入执行时,异常会重复发生。外部设备中断是异步的,CPU 不知道它会在哪条指令之后到来,也无法仅凭当前程序状态预测下一次键盘输入的时刻。

这种区分对驱动开发很有意义。异常处理通常需要保存完整的 CPU 上下文,并且可能修改引发异常的指令流。外部设备中断只需要保证被打断的任务能够在处理后正确恢复,因此中断处理程序必须尽量短小、快速,不能随意睡眠,也不能假设自己在某个特定进程上下文中运行。

2.3 可屏蔽中断与不可屏蔽中断

x86 体系中有两条重要的中断相关引脚或信号:可屏蔽中断请求和不可屏蔽中断。可屏蔽中断可以通过清除 EFLAGS 中的 IF 标志来暂时关闭,从而避免在关键代码段被打断。不可屏蔽中断则用于真正严重的事件,例如硬件错误,CPU 无法通过常规手段关闭它。

在 Linux 中,关闭本地 CPU 的中断称为关中断,通常使用 local_irq_disable() 或带中断保护的锁原语实现。关中断可以保护临界区,但会显著增加系统延迟,因此现代内核更多使用更细粒度的锁和底半部机制,而不是长时间关闭硬件中断。

2.4 常用术语

  • IRQ:Interrupt Request,中断请求线或中断号。传统 PC 上,每个设备使用一个或多个 IRQ 线。
  • ISR:Interrupt Service Routine,中断服务程序,也就是中断处理程序。
  • 向量:CPU 用来索引中断描述符表的编号。x86 中向量范围是 0 到 255。
  • IDT:Interrupt Descriptor Table,中断描述符表。保护模式下 CPU 根据中断向量查找处理入口。
  • EOI:End Of Interrupt,中断结束通知。使用传统 8259A 时,处理完中断后必须发送 EOI,否则同级和更低优先级中断会被阻塞。
  • 上下文保存:中断进入时保存寄存器、返回地址等状态,以便处理完成后恢复。

3. x86 保护模式下的中断机制

x86 处理器在不同工作模式下,中断查找方式不同。实模式下,CPU 使用中断向量表,简称 IVT。IVT 固定在内存最低 1KB,即地址 0x00000 到 0x003FF,每个表项 4 字节,保存段地址和偏移地址。这符合早期 8086 的内存模型,但在现代操作系统中已经不直接使用。

进入保护模式后,CPU 使用中断描述符表。IDT 是一张最多 256 项的表,每一项 8 字节,称为门描述符。CPU 收到中断向量 N 后,会读取 IDT 的第 N 项,根据描述符中的段选择子和偏移地址找到处理函数。IDT 的位置由 IDTR 寄存器保存,操作系统需要通过 lidt 指令把 IDT 的基地址和界限加载到 IDTR。

理解保护模式中断,必须结合两个关键概念:段机制和特权级。门描述符中的段选择子指向 GDT 或 LDT 中的一个代码段,偏移地址则指向处理函数在段内的位置。CPU 在跳转到处理函数前会进行特权级检查,确保从低特权级进入高特权级处理程序是合法且受限的。

x86 定义了四类和中断相关的门:

  • 中断门:进入处理程序时自动清除 IF 标志,禁止新的可屏蔽中断,常用于普通中断处理。
  • 陷阱门:进入处理程序时不自动清除 IF 标志,常用于异常或系统调用。
  • 任务门:使用硬件任务切换机制,现代操作系统基本不再使用。
  • 调用门:主要用于特权级之间的受控调用,现在已很少作为中断入口使用。

中断门和陷阱门的核心区别就在 IF 标志处理。中断门适合处理外部硬件中断,因为硬件中断本身不希望被同级别中断随意嵌套;陷阱门适合处理缺页异常和系统调用,因为这类处理可能需要保持中断开启,等待某些事件完成。

在 64 位模式下,中断描述符仍然类似,但表项扩展为 16 字节,并且支持更复杂的堆栈切换机制。x86_64 还提供了 IST 机制,可以让特定中断使用独立的已知良好栈,避免因内核栈损坏导致无法处理异常。

4. 中断描述符表与中断门

下面从代码角度理解 IDT 的结构。在 32 位保护模式下,一个中断门描述符可以定义成如下 C 结构体:

typedef struct {
    uint16_t offset_low;
    uint16_t selector;
    uint8_t zero;
    uint8_t type_attr;
    uint16_t offset_high;
} __attribute__((packed)) idt_entry_t;

typedef struct {
    uint16_t limit;
    uint32_t base;
} __attribute__((packed)) idtr_t;

其中 offset_lowoffset_high 合起来构成 32 位处理函数偏移地址。selector 是代码段选择子,例如内核代码段可能填入 0x08type_attr 中的低 5 位表示类型,0xE 表示 32 位中断门,0xF 表示 32 位陷阱门;DPL 字段表示调用该门所需的最低特权级;P 位表示该表项是否存在。

加载 IDT 的汇编代码可以这样写:

section .text
global load_idt
load_idt:
    mov eax, [esp + 4]
    lidt [eax]
    ret

调用时,先构造好完整 IDT 数组和 IDTR,再把 IDTR 的地址传给 load_idt。这条路径和现代操作系统启动早期设置中断系统的工作是一致的。

64 位模式下的 IDT 表项扩展到 16 字节,偏移量分为三个部分,同时增加 IST 索引字段。Linux 内核对不同异常和中断分别设置门类型、DPL 和 IST,例如机器检查异常、不可屏蔽中断等使用独立栈,以避免普通内核栈已经被破坏时无法进入处理逻辑。

设置 IDT 时最容易犯的错误是权限配置不当。如果普通硬件中断的 DPL 被设成 3,用户态就可能直接通过 int 指令触发该中断,这在安全上是不允许的。通常外部硬件中断使用 DPL 0,只有系统调用门才会把 DPL 设置为 3,以便用户态代码合法陷入内核。

5. 从硬件中断到 CPU 响应:完整流程

一次外部硬件中断从产生到处理完成,大致经历以下过程:

  1. 设备通过硬件信号向中断控制器发出请求。
  2. 中断控制器判断优先级、屏蔽状态和 CPU 是否允许接收中断。
  3. 中断控制器把中断向量号发送给 CPU。
  4. CPU 完成当前指令,进入中断响应周期。
  5. CPU 根据当前特权级决定是否发生栈切换。
  6. CPU 保存被中断代码的返回状态,包括 EFLAGS、CS 和 EIP。
  7. CPU 从 IDT 加载处理程序入口,并跳转执行。
  8. 中断处理程序保存通用寄存器,执行设备相关处理。
  9. 处理程序向中断控制器发送 EOI。
  10. 处理程序恢复寄存器并执行 iret 返回。
  11. CPU 恢复被中断任务继续执行。

在保护模式下,如果中断导致特权级提升,CPU 还会从当前任务的 TSS 中加载新的栈指针。旧栈中的返回地址会被压入新栈,处理程序返回时再恢复。这套机制保证用户态程序不能通过触发中断任意控制内核栈内容。

中断返回与普通函数返回完全不同。普通 ret 只恢复指令指针,而 iret 会恢复代码段和 EFLAGS,必要时还会恢复用户态栈指针。这也是为什么中断处理程序必须使用专门的返回指令,不能直接返回普通函数调用栈。

从软件角度看,中断处理程序要做的事通常包括:读取设备状态、清除中断原因、处理数据、通知后续流程、发送 EOI。每一部分都必须尽量短。因为中断处理期间,当前 CPU 上的许多工作被暂停,如果处理程序执行时间过长,系统响应会明显恶化,甚至丢数据。

6. 8259A PIC 与 APIC 中断控制器

6.1 传统 8259A 可编程中断控制器

早期的 PC 使用两片级联的 8259A 芯片管理 15 个硬件中断。主片使用端口 0x20 和 0x21,从片使用端口 0xA0 和 0xA1。主片的 IRQ2 用于级联从片。每一个 8259A 可以管理 8 条中断请求线,并通过优先级、屏蔽字和中断结束方式控制硬件中断。

传统 IRQ 分配大致如下:

IRQ典型设备说明
0系统定时器产生周期性时钟中断,是进程调度的基础
1键盘按键和释放都会触发中断
2从片级联用于连接第二个 8259A
3串口 2COM2 或调制解调器
4串口 1COM1 或鼠标等设备
5并口或声卡历史上常被声卡占用
6软盘控制器旧式软驱使用
7并口 1打印机等设备
8实时时钟RTC 周期中断和时钟更新
9ACPI 或备用系统中常用于电源管理
10备用或网卡旧式网卡可能使用
11备用或 SCSI取决于具体主板
12PS/2 鼠标鼠标移动和按键事件
13数学协处理器x87 FPU 异常
14IDE 主通道硬盘和光驱
15IDE 从通道第二块 IDE 设备

需要注意的是,操作系统通常会重新映射 IRQ 对应的中断向量。由于 x86 的前 32 个向量默认留给 CPU 异常,8259A 如果保持默认映射,硬件中断会和异常冲突。因此现代系统会把主片 IRQ0 到 IRQ7 映射到较高向量,把从片 IRQ8 到 IRQ15 映射到后面一段。这也解释了为什么 Linux 中时钟中断通常显示在较高的 IRQ 号上。

6.2 EOI 与中断优先级

8259A 在收到中断处理后不会自动复位内部状态。如果处理程序不发送 EOI,该中断请求会被认为仍然在处理中,从而阻塞同级和更低优先级中断。对于级联结构,如果中断来自从片,通常需要同时向从片和主片发送 EOI。现代 Linux 已经把这些细节封装在中断控制器驱动中,驱动开发者一般只需要在处理完成后返回,由内核完成必要的 EOI 操作。

传统 PIC 的主要局限包括中断线数量少、不易扩展、多核环境表达能力弱,以及只能广播到少数 CPU。随着系统复杂度增加,APIC 体系取代了 8259A 的大部分功能。

6.3 APIC、IOAPIC 与 MSI

现代多核系统通常使用高级可编程中断控制器体系,包括本地 APIC 和 IOAPIC。每个 CPU 都有一个本地 APIC,负责接收中断、管理和发送处理器间中断。IOAPIC 位于系统总线和外设之间,收集设备中断,并通过总线把中断投递给目标 CPU。

与传统共享 IRQ 线相比,APIC 体系可以更灵活地指定中断路由,例如把网卡中断固定到某个 CPU,或者分散到多个 CPU 以获得更好的并行性。Linux 用户可以通过 /proc/irq/N/smp_affinity 查看和调整中断亲和性。

PCIe 设备还广泛使用 MSI 和 MSI-X。MSI 不再依赖有限的硬件中断线,而是让设备直接向指定地址写一个值,从而触发中断。MSI-X 进一步支持更多中断向量,允许单个设备内部不同队列使用不同中断。对于高性能网卡、NVMe 固态硬盘等设备,MSI-X 是优化多核性能和降低中断风暴的关键技术。

7. 异常、陷阱与系统调用

异常是 CPU 执行指令时产生的特殊条件。根据发生后能否恢复以及恢复方式,异常可以大致分为故障、陷阱和终止三类。

  • 故障:例如缺页、设备不可用。CPU 把引发故障的指令地址保存为返回地址,修复后可以重新执行同一条指令。
  • 陷阱:例如调试断点、溢出。返回地址通常指向引发陷阱指令的下一条指令,陷阱更像一次受控调用。
  • 终止:例如双重故障、机器检查错误。状态已经不可信,系统通常只能停止当前 CPU 或整个内核。

x86 体系为常见异常保留了向量 0 到 31,例如除零错误使用向量 0,调试异常使用向量 1,不可屏蔽中断使用向量 2,断点使用向量 3,溢出使用向量 4,非法操作码使用向量 6,设备不可用使用向量 7,缺页使用向量 14,x87 浮点异常使用向量 16,机器检查使用向量 18。

系统调用可以理解为用户态主动发起的陷阱。传统的 Linux x86 32 位系统使用 int 0x80 进入内核,通过 EAX 指定系统调用号,EBX、ECX、EDX、ESI、EDI 等寄存器传递参数。后来 x86 引入了 sysentersyscall 指令,减少了传统中断方式带来的压栈、查表和权限检查开销。

系统调用门与普通硬件中断门的关键区别是 DPL。系统调用门必须允许用户态代码进入,因此 DPL 被设为 3。用户态代码不能使用 iret 返回内核态,系统调用处理完成后由内核使用专门路径返回用户态,同时完成特权级切换和栈切换。

理解缺页异常对理解操作系统非常重要。当程序访问一个虚拟地址,但页表无法完成地址转换时,CPU 会触发缺页异常。内核缺页处理程序会判断该地址是否合法、是否属于延迟分配、是否已交换到磁盘,然后决定分配物理页、从交换区读回数据,或者向进程发送段错误信号。正是因为异常处理机制的存在,操作系统才能实现虚拟内存、写时复制、内存映射文件等高级功能。

8. 编写一个最小可运行的中断处理程序

为了把前面的概念落到实处,下面构造一个极简但完整的中断处理示例。这个示例假设我们处于保护模式,已经设置好 GDT、内核栈和代码段,并且已经重新映射 8259A。示例以键盘中断为目标,演示读取按键扫描码并发送 EOI 的过程。

首先编写一段安装 IDT 和重新映射 PIC 的初始化逻辑:

#define PIC1_COMMAND 0x20
#define PIC1_DATA    0x21
#define PIC2_COMMAND 0xA0
#define PIC2_DATA    0xA1

void pic_remap(uint8_t offset1, uint8_t offset2)
{
    outb(PIC1_COMMAND, 0x11);
    outb(PIC2_COMMAND, 0x11);
    outb(PIC1_DATA, offset1);
    outb(PIC2_DATA, offset2);
    outb(PIC2_DATA, 0x04);
    outb(PIC2_DATA, 0x02);
    outb(PIC1_DATA, 0x01);
}

设置中断门描述符时,需要把键盘中断表项指向实际处理函数。假定键盘中断被映射到向量 0x21,处理函数名称为 keyboard_handler,对应的描述符填充逻辑如下:

void set_interrupt_gate(int vector, uint32_t handler)
{
    idt[vector].offset_low = handler & 0xFFFF;
    idt[vector].selector = 0x08;
    idt[vector].zero = 0;
    idt[vector].type_attr = 0x8E;
    idt[vector].offset_high = (handler >> 16) & 0xFFFF;
}

键盘处理程序用汇编封装进入和返回过程,核心 C 逻辑读取键盘数据端口:

void keyboard_handler_main(void)
{
    uint8_t scancode = inb(0x60);

    if ((scancode & 0x80) == 0) {
        put_char('K');
    }

    outb(0x20, 0x20);
}

在真实的键盘中断中,0x60 是数据端口。扫描码最高位为 0 表示按键按下,最高位为 1 表示按键释放。发送 0x200x20 端口是向主片发送 EOI。对于从从片来的中断,还需要向 0xA0 端口发送 EOI。

这段代码虽然简单,但覆盖了中断的核心路径:设置中断控制器、设置 IDT 门描述符、在中断服务程序中读取设备状态、通知中断控制器结束、返回被打断的程序。实际内核还会在这一路径上加入保存完整寄存器、检查返回方式、处理抢占、统计中断次数等大量工作。

实验中,如果发现键盘中断只会触发一次,通常是没有正确发送 EOI。如果系统启动后完全无法响应键盘,优先检查 IDT 描述符的段选择子、门类型和偏移地址。如果处理程序执行时发生崩溃,优先检查栈是否有效、返回指令是否错误,以及处理程序中是否使用了未经保存的寄存器。

9. 设备驱动基础:设备、控制器与总线

设备驱动是操作系统中的一段代码,负责屏蔽硬件差异,向内核提供统一的设备操作接口。一个设备通常由两部分组成:设备和设备控制器。设备是真正执行物理动作的部件,例如磁盘盘片、网络线缆;设备控制器是设备与 CPU 之间的电子接口,例如磁盘控制器、网卡控制器、USB 控制器。

CPU 与设备控制器的交互通过一组寄存器完成。常见寄存器包括:

  • 控制寄存器:CPU 写入命令,让设备开始工作或改变模式。
  • 状态寄存器:CPU 读取设备当前状态,例如忙闲、完成、错误。
  • 数据寄存器:CPU 读写实际数据,例如键盘扫描码、硬盘扇区数据。

设备控制器通过总线接入系统。总线是连接 CPU、内存和设备控制器的通信通道。不同类型的总线有不同的地址空间、数据传输方式和中断机制。例如,早期设备连接在 ISA 总线上,后来出现了 PCI、PCIe、USB、SATA、NVMe 等总线。每种总线都有自己的设备发现、配置和中断模型。

Linux 内核并不把所有设备都当作同一种对象。它把设备划分为字符设备、块设备和网络设备等类别。字符设备按字节流方式访问,没有随机访问缓冲,例如键盘、串口、终端。块设备以块为单位访问,支持随机读写和内核缓冲,例如硬盘、SSD。网络设备不通过文件节点直接读写数据,而是通过套接字接收和发送网络包。

从驱动开发的角度看,写一个设备驱动就是在内核中注册一组操作函数,并把设备的中断、内存映射和端口资源管理起来。驱动不一定是完整的内核模块,它本质上是一组回调函数:系统调用到内核,内核再调用设备相关的函数,设备相关函数再操作硬件寄存器,硬件完成操作后通过中断把状态反馈回来。

10. 端口 I/O 与内存映射 I/O

CPU 访问设备寄存器有两种主要方式:端口 I/O 和内存映射 I/O。

端口 I/O 是 x86 的传统方式。CPU 使用独立的 I/O 地址空间,通过 inout 指令读写设备端口。每个端口有独立编号,例如键盘数据端口 0x60、状态端口 0x64,主片命令端口 0x20。端口 I/O 的好处是与内存访问隔离,但指令和地址空间都相对特殊。

内存映射 I/O 把设备寄存器映射到物理地址空间,CPU 使用普通的内存读写指令访问设备。现代 SoC、PCIe 设备 BAR 空间等大量使用这种方式。它的好处是访问效率高,使用普通指针操作即可,容易利用编译器优化。缺点是设备寄存器不能随意缓存、合并或重排访问顺序,需要使用内存屏障和位宽控制来保证正确性。

在 Linux 中,访问端口 I/O 使用 inboutbinwoutwinloutl 等函数。访问内存映射 I/O 时,驱动通常先通过 request_mem_region 保留资源,再用 ioremap 把物理地址映射到内核虚拟地址,然后使用 readbreadlwritebwritel 等函数读写。

下面是一个 MMIO 寄存器访问的简单示例:

#include <linux/io.h>

void __iomem *reg_base;

static int demo_init(void)
{
    reg_base = ioremap(0xF0000000, 0x1000);
    if (!reg_base)
        return -ENOMEM;

    writel(0x1, reg_base + 0x00);
    writel(0x80000000, reg_base + 0x04);

    return 0;
}

static void demo_exit(void)
{
    if (reg_base)
        iounmap(reg_base);
}

需要特别注意的是,编译器可能为了优化而对相邻内存访问进行重排。设备寄存器访问通常具有副作用,例如写“启动命令”后再读“状态”必须保持顺序。因此内核提供 readlwritel 这类带屏障语义的访问函数,以及 dma_wmbrmbmb 等屏障宏,确保设备看到的操作顺序符合驱动预期。

11. 轮询、中断与 DMA:三种 I/O 方式

设备与 CPU 协同完成数据传输,主要可以分为轮询、中断和 DMA 三种方式。

轮询方式下,CPU 主动读取设备状态,直到设备准备好,然后读写数据。它适合延迟可控、请求频繁或设备响应极快的场景,例如一些高性能网络栈为了降低中断开销,会在短时间内使用轮询。轮询的缺点是忙等会占用 CPU 周期,当设备慢时浪费巨大。

中断方式下,CPU 启动设备操作后就去执行其他任务,设备完成后触发中断,CPU 再回来处理。中断适合设备事件不频繁、CPU 还要同时处理其他任务的场景。对于高吞吐设备,如果每个数据包都触发中断,可能产生中断风暴,反而不如轮询或混合模式高效。

DMA 方式进一步把数据搬运工作从 CPU 转移到 DMA 控制器或设备自身。CPU 只需要配置源地址、目标地址和长度,然后启动传输。DMA 完成后通过中断通知 CPU。现代网卡、磁盘、显卡几乎都支持 DMA,否则 CPU 会浪费大量时间在内存和设备之间搬运数据。

下面的表格对比三种方式:

方式CPU 参与程度优点缺点
轮询高,反复查询简单、延迟可控忙等浪费 CPU
中断中,启动和收尾CPU 利用率高高频事件可能产生中断风暴
DMA低,只配置和响应完成吞吐高、释放 CPU硬件复杂、缓存一致性问题多

实际系统中三种方式不是互斥的。现代网卡驱动经常使用 NAPI 混合方案:先以中断通知 CPU,确认有数据后进入轮询模式,在短时间内批量处理网络包,处理完后再重新开启中断。这样既保持了低延迟,又减少了高频中断带来的上下文切换开销。

DMA 需要认真处理物理地址、虚拟地址和缓存一致性。DMA 设备通常看到的是物理地址,而不是内核虚拟地址。驱动需要通过 dma_alloc_coherentdma_map_single 等接口分配和管理 DMA 缓冲区,必要时执行显式同步,防止 CPU 缓存中的数据和设备看到的内存内容不一致。

12. PCI 与 PCIe 设备及中断路由

PCI 和 PCIe 是当代计算机最常用的设备总线。它们不仅负责数据传输,也提供设备发现、配置和中断分发机制。理解 PCI 设备驱动,需要了解几个核心概念:配置空间、厂商号和设备号、BAR 空间、INTx 中断以及 MSI/MSI-X。

每个 PCI 设备都有一个配置空间,主机可以通过配置读和配置写操作访问。配置空间中保存厂商 ID、设备 ID、类别码、状态、命令、基地址寄存器 BAR、中断引脚和中断线等字段。操作系统启动时遍历 PCI 总线,为每个设备建立信息结构,并为其分配所需资源。

BAR 用于描述设备的内存映射 I/O 或端口 I/O 空间。一个设备可以有多个 BAR,每一个 BAR 都可能映射到一段可访问地址。驱动通过读取 BAR 得到设备寄存器基地址,再调用 ioremap 或端口访问函数操作设备。

传统 PCI 中断称为 INTx,共有 INTA、INTB、INTC、INTD 四根硬件中断线。多个设备可以共享同一根中断线,这也是 Linux 中共享中断请求产生的原因。采用传统 INTx 的设备,系统中会看到多个设备共享同一个 IRQ。共享中断要求驱动在处理函数中判断设备是否真的产生了中断,如果不是本设备产生的中断,应返回未处理状态。

MSI 和 MSI-X 改变了中断投递方式。设备不再通过共享硬件中断线,而是直接向配置好的地址写入特定值,由内存写事务触发处理器中断。这样每个设备甚至每个队列都可以拥有独立的中断号,避免了共享中断的查询开销和竞争问题,同时提高了多核系统的可扩展性。

使用 MSI-X 的驱动通常需要在初始化阶段请求足够数量的中断向量,为每个向量注册单独的中断处理函数,并配置硬件队列与中断向量之间的对应关系。高性能网卡之所以能够把收发队列绑定到不同 CPU,很大程度上依赖 MSI-X 提供的大量独立中断。

13. Linux 设备驱动模型与中断注册

在 Linux 中注册一个中断处理函数,最常用的接口是 request_irq 或更新的 request_threaded_irq。一个典型的中断注册流程如下:

#include <linux/module.h>
#include <linux/interrupt.h>

static irqreturn_t my_handler(int irq, void *dev_id)
{
    pr_info("irq %d handled\n", irq);
    return IRQ_HANDLED;
}

static int __init my_init(void)
{
    int ret;

    ret = request_irq(KEYBOARD_IRQ,
                      my_handler,
                      IRQF_SHARED,
                      "my_keyboard",
                      &my_handler);
    if (ret) {
        pr_err("request_irq failed: %d\n", ret);
        return ret;
    }

    return 0;
}

static void __exit my_exit(void)
{
    free_irq(KEYBOARD_IRQ, &my_handler);
}

module_init(my_init);
module_exit(my_exit);

这里有几个参数需要特别注意。第一个参数是中断号或中断资源。第二个参数是顶半部处理函数。第三个参数是中断标志,例如 IRQF_SHARED 表示允许共享中断。第四个参数是中断名称,会出现在 /proc/interrupts 中。第五个参数是 dev_id,用于共享中断时区分不同设备,也用于在 free_irq 时定位正确的处理函数。

中断处理函数必须返回 IRQ_HANDLEDIRQ_NONE。如果返回 IRQ_NONE,内核会认为该处理函数没有处理这个中断,继续调用同一中断线上的其他共享处理函数。返回 IRQ_HANDLED 表示本处理函数已经处理了中断。

驱动开发者需要理解,中断处理程序运行在一个特殊的上下文中。它不是普通进程上下文,没有独立的进程栈和完整的调度状态。中断上下文中不能调用可能睡眠的函数,例如 msleepcopy_to_user、获取信号量等。中断处理程序还可能在关中断或禁止内核抢占的状态下运行,因此必须限制执行时间。

Linux 设备驱动模型围绕总线、设备、驱动三个核心对象展开。总线负责发现设备并匹配驱动,设备描述具体硬件,驱动提供操作方法和配置逻辑。以 PCI 为例,内核遍历 PCI 设备,根据厂商号和设备号查找匹配的 pci_driver。一旦匹配成功,会调用驱动的 probe 函数,在 probe 中完成内存映射、中断注册和硬件初始化。设备移除时,则调用 remove 函数释放资源并注销中断。

14. 中断下半部:softirq、tasklet、workqueue

中断处理必须快速完成,但很多中断事件背后有大量后续工作。以网卡收到数据包为例,中断处理函数需要确认包到来、通知网络子系统,但完整的协议栈处理、数据拷贝和套接字唤醒如果全部放在中断处理函数中,会阻塞整个 CPU,导致严重后果。

因此 Linux 把中断处理分成两部分:顶半部和底半部。顶半部是在中断上下文中执行的紧急部分,负责应答硬件、保存状态、调度底半部。底半部完成耗时长但可以稍后执行的部分。底半部有多种实现机制,常见的是 softirq、tasklet 和 workqueue。

softirq 是内核底层机制,由固定枚举定义,例如网络收发、定时器、块设备完成等。softirq 运行在中断上下文中,需要在编译时静态定义,并且可能同时在不同 CPU 上运行相同类型的 softirq,因此要考虑并发。普通驱动很少直接新增 softirq,但网络和块设备等核心子系统的性能高度依赖它。

tasklet 构建在 softirq 之上,接口更简单。同一个 tasklet 不会被多个 CPU 同时执行,适合普通驱动。任务通过 tasklet_schedule 调度,在内核方便的时候执行。下面是一个 tasklet 示例:

#include <linux/interrupt.h>

static void my_tasklet_func(unsigned long data)
{
    unsigned long *count = (unsigned long *)data;
    (*count)++;
}

DECLARE_TASKLET(my_tasklet, my_tasklet_func, (unsigned long)&task_data);

static irqreturn_t my_handler(int irq, void *dev_id)
{
    tasklet_schedule(&my_tasklet);
    return IRQ_HANDLED;
}

workqueue 与前面的机制不同,它把工作放到内核线程中执行,因此工作函数运行在进程上下文中,可以睡眠,也可以访问用户态内存。workqueue 适合执行较慢的后端操作,例如磁盘驱动中的复杂错误恢复、异步设备初始化和网络协议处理。它的接口也相对直观:定义 work_struct,使用 INIT_WORK 初始化,再通过 schedule_workqueue_work 调度。

Linux 还提供线程化中断。将 request_threaded_irq 的顶半部设置为快速检查函数,底半部放到专门的内核线程中执行。线程化中断可以降低传统中断上下文对系统的阻塞时间,并减少驱动开发难度。对许多实时性要求不极端的设备,线程化中断是非常合适的选择。

选择底半部机制的一般原则是:能放在顶半部做的小事就不必进入底半部;不能睡眠、时间稍长且需要严格串行时使用 tasklet;核心子系统中已有 softirq 类型时使用 softirq;需要睡眠、访问文件或耗时更长时使用 workqueue 或线程化中断。

15. 并发、竞态与中断安全

驱动开发中最难的部分之一,是处理中断处理程序与进程上下文之间的并发。假设一个字符设备驱动维护一个环形缓冲区,用户进程通过 read 读取缓冲区,设备中断处理程序写入缓冲区。两个执行路径可能同时访问同一数据结构,一个正在读、一个正在写,如果中间被打断或交错执行,就会出现数据损坏。这种情况称为竞态条件。

解决竞态条件的基本思路是使用锁。但中断上下文不能使用普通的互斥锁,因为互斥锁可能导致睡眠,而中断上下文不允许睡眠。常用的做法是使用自旋锁,并结合关中断操作。

如果数据结构只会被进程上下文和设备中断访问,可以使用 spin_lock_irqsavespin_unlock_irqrestore 保护临界区。它们会保存当前中断状态、禁止本地中断并获取自旋锁,离开临界区时恢复中断状态。这样,中断处理程序就无法在和进程上下文竞争时插入到同一临界区中。

static DEFINE_SPINLOCK(my_lock);

static void add_event(struct my_dev *dev, struct event *ev)
{
    unsigned long flags;

    spin_lock_irqsave(&dev->lock, flags);
    add_event_locked(dev, ev);
    spin_unlock_irqrestore(&dev->lock, flags);
}

如果还需要防止其他 CPU 上的中断处理程序并发访问,则要保证所有访问路径都使用同一个自旋锁。中断处理函数在更新共享数据前也要获取该锁。对于只被单个中断处理程序使用的数据,可以避免不必要的锁,但必须在设计上确认不会再被其他上下文访问。

此外,编译器优化和 CPU 乱序执行也可能带来问题。驱动中需要保证设备寄存器访问顺序时,应使用 writelreadl 等接口和内存屏障。对于共享内存中的标志位,如果可能被中断处理程序和进程上下文同时看到,可以使用 READ_ONCEWRITE_ONCE,防止编译器进行不安全的缓存和重排。

中断延迟是另一个重要指标。中断延迟指从中断发生到处理程序真正开始执行的时间,包含硬件传输、中断控制器处理、CPU 屏蔽状态、软件保存上下文、调度底半部等环节。驱动开发中,长时间中断处理、过度使用关中断和自旋锁、不必要的大锁都会增加中断延迟,影响系统实时性。定位中断延迟问题通常需要结合 ftracetrace-cmdpreemptirq 等工具。

16. 综合实例:一个带中断的字符设备驱动

下面通过一个简化的字符设备驱动,把前文的概念串联起来。假设有一个虚拟设备,它会在“有数据到达”时产生中断。驱动需要在初始化阶段注册字符设备和中断处理函数,中断处理函数负责读取数据并唤醒等待读取的进程,进程通过 read 系统调用读取数据。

先定义设备结构体和基础文件操作:

#include <linux/module.h>
#include <linux/fs.h>
#include <linux/cdev.h>
#include <linux/interrupt.h>
#include <linux/wait.h>
#include <linux/uaccess.h>

#define DEV_NAME "irqdemo"
#define IRQ_NUM  20

struct irqdemo_dev {
    struct cdev cdev;
    int irq;
    wait_queue_head_t waitq;
    char buf[256];
    size_t len;
    bool data_ready;
};

这里 wait_queue_head_t 用于实现等待队列:当用户进程读取设备但没有数据时,进程睡眠;当中断到来并写入数据后,驱动唤醒等待进程。下面实现等待队列的初始化、读取和唤醒逻辑:

static irqreturn_t irqdemo_handler(int irq, void *dev_id)
{
    struct irqdemo_dev *dev = dev_id;

    dev->len = snprintf(dev->buf, sizeof(dev->buf), "irq fired\n");
    dev->data_ready = true;
    wake_up_interruptible(&dev->waitq);

    return IRQ_HANDLED;
}

static ssize_t irqdemo_read(struct file *filp, char __user *ubuf,
                            size_t count, loff_t *ppos)
{
    struct irqdemo_dev *dev = filp->private_data;

    if (wait_event_interruptible(dev->waitq, dev->data_ready))
        return -ERESTARTSYS;

    if (count > dev->len)
        count = dev->len;

    if (copy_to_user(ubuf, dev->buf, count))
        return -EFAULT;

    dev->data_ready = false;
    return count;
}

在初始化函数中,需要完成字符设备注册、等待队列初始化和中断注册:

static int __init irqdemo_init(void)
{
    int ret;
    struct irqdemo_dev *dev;

    dev = kzalloc(sizeof(*dev), GFP_KERNEL);
    if (!dev)
        return -ENOMEM;

    init_waitqueue_head(&dev->waitq);
    dev->irq = IRQ_NUM;

    ret = request_irq(dev->irq, irqdemo_handler,
                      IRQF_SHARED, DEV_NAME, dev);
    if (ret)
        goto free_dev;

    pr_info("irqdemo registered\n");
    return 0;

free_dev:
    kfree(dev);
    return ret;
}

真正的驱动还需要注册字符设备、分配主设备号、创建设备节点,并在应用层通过 openreadclose 访问。这里只保留了中断路径和等待队列路径。理解了这条路径,就能理解大多数字符设备驱动的核心骨架。

从设备产生中断到用户进程读到数据,完整链路如下:设备产生中断,中断控制器把中断投递给 CPU,CPU 进入内核中断门处理函数,内核根据 IRQ 找到对应的 irq_desc 和用户注册的处理函数,处理函数读取设备数据、写入缓冲区并唤醒等待队列,等待的进程被调度运行后从内核缓冲区复制数据到用户空间。这一系列环节体现了中断、驱动、进程调度和内存管理之间的协同。

17. 调试与性能优化

开发和调试中断驱动时,最常用的信息来自 /proc/interrupts。该文件列出每个 CPU 的中断计数、中断号、处理函数名称和设备信息。它可以帮助开发者判断中断是否被触发、中断是否集中在某个 CPU 上、是否有中断风暴或中断丢失。

典型的性能分析思路如下:

  1. 运行 cat /proc/interrupts,确认目标设备中断计数是否增长。
  2. 如果计数不增长,检查设备是否初始化、中断是否被屏蔽、共享中断是否被错误处理。
  3. 如果计数增长很快,检查是否产生高频中断,是否需要底半部或轮询优化。
  4. 比较不同 CPU 的中断计数,判断是否需要对中断做 CPU 亲和性调整。
  5. 使用 trace-cmdftracebpftrace 跟踪中断延迟和处理时间。

常见的中断驱动错误包括:

  • 忘记释放中断号,导致模块卸载后设备仍在触发中断。
  • 共享中断处理函数没有判断设备是否产生中断,错误地返回 IRQ_HANDLED
  • 在中断处理函数中睡眠或访问用户态内存。
  • 没有同步中断上下文和进程上下文对共享缓冲区的访问。
  • 读取设备数据太晚,导致设备 FIFO 溢出或数据被覆盖。
  • 没有正确处理设备中断状态位,导致处理完成后硬件反复触发中断。

调试设备寄存器问题时,可以使用 devmem 工具读写物理地址,也可以通过 lspci -v 查看设备资源。对于 MMIO 驱动,使用 readlwritel 时要确保地址已经正确映射,并且基地址符合平台对齐要求。硬件问题与软件代码之间往往只有一层寄存器,保持对寄存器的精确观察能显著缩短调试周期。

性能优化需要避免陷入“中断越大越好”的误区。对于高吞吐设备,应优先考虑批量处理和 DMA。例如网络驱动可以一次读取多个数据包,减少寄存器访问和中断次数。对于延迟敏感设备,应减少顶半部工作,尽快唤醒用户进程,并把非关键操作从关键路径中移出。

18. 总结与学习路线

中断与设备驱动是操作系统中连接软件和硬件的关键桥梁。中断让 CPU 能够及时响应异步事件,异常让 CPU 在执行错误时进入受控处理流程,系统调用则让用户程序安全地请求内核服务。设备驱动通过读写设备寄存器、注册中断处理函数和管理 DMA 缓冲区,把千差万别的硬件抽象成操作系统可以调用的统一接口。

回顾本文,我们依次完成了以下内容:建立中断语义和分类框架,理解 x86 保护模式下的 IDT 与门描述符,分析 CPU 响应中断的完整流程,学习 8259A、APIC 和 MSI/MSI-X 的发展过程,区分异常、陷阱和系统调用,实现最小中断处理程序,理解设备控制器和总线模型,掌握端口 I/O 与 MMIO,比较轮询、中断和 DMA,认识 PCI/PCIe 设备与中断路由,练习 Linux 中断注册和字符设备驱动,最后梳理中断底半部和并发安全。

要继续深入,可以沿着下面的路线学习:

  • 阅读 x86 手册中关于中断、异常、门描述符和 iret 的章节,建立精确的硬件模型。
  • 阅读 Linux 内核 arch/x86/kernel/idt.ctraps.cirq.c,理解真实 IDT 初始化。
  • 使用 QEMU 和 gdb 启动一个极简内核,观察中断入口、栈切换和返回过程。
  • 编写一个带中断的 PCI 或平台设备驱动,并用 /proc/interruptsftrace 验证。
  • 学习块设备驱动和网络设备驱动,比较不同子系统如何处理中断和 DMA。
  • 深入研究 softirq、tasklet、workqueue 和线程化中断的实现,理解内核调度策略。
  • 在实时 Linux 或嵌入式平台上测量并优化中断延迟,理解实时系统对中断机制的要求。

中断机制是操作系统中最具张力的部分:它既要响应敏捷,又要保证安全;既要快速返回,又要完成大量后续工作;既要让设备充分并行,又要避免竞态和拥塞。只有通过不断阅读源码、编写驱动和测量性能,才能真正把这些知识内化为工程能力。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐