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_low和offset_high合起来构成 32 位处理函数偏移地址。selector是代码段选择子,例如内核代码段可能填入0x08。type_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 响应:完整流程
一次外部硬件中断从产生到处理完成,大致经历以下过程:
- 设备通过硬件信号向中断控制器发出请求。
- 中断控制器判断优先级、屏蔽状态和 CPU 是否允许接收中断。
- 中断控制器把中断向量号发送给 CPU。
- CPU 完成当前指令,进入中断响应周期。
- CPU 根据当前特权级决定是否发生栈切换。
- CPU 保存被中断代码的返回状态,包括 EFLAGS、CS 和 EIP。
- CPU 从 IDT 加载处理程序入口,并跳转执行。
- 中断处理程序保存通用寄存器,执行设备相关处理。
- 处理程序向中断控制器发送 EOI。
- 处理程序恢复寄存器并执行
iret返回。 - 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 | 串口 2 | COM2 或调制解调器 |
| 4 | 串口 1 | COM1 或鼠标等设备 |
| 5 | 并口或声卡 | 历史上常被声卡占用 |
| 6 | 软盘控制器 | 旧式软驱使用 |
| 7 | 并口 1 | 打印机等设备 |
| 8 | 实时时钟 | RTC 周期中断和时钟更新 |
| 9 | ACPI 或备用 | 系统中常用于电源管理 |
| 10 | 备用或网卡 | 旧式网卡可能使用 |
| 11 | 备用或 SCSI | 取决于具体主板 |
| 12 | PS/2 鼠标 | 鼠标移动和按键事件 |
| 13 | 数学协处理器 | x87 FPU 异常 |
| 14 | IDE 主通道 | 硬盘和光驱 |
| 15 | IDE 从通道 | 第二块 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 引入了sysenter和syscall指令,减少了传统中断方式带来的压栈、查表和权限检查开销。
系统调用门与普通硬件中断门的关键区别是 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 表示按键释放。发送0x20到0x20端口是向主片发送 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 地址空间,通过in和out指令读写设备端口。每个端口有独立编号,例如键盘数据端口 0x60、状态端口 0x64,主片命令端口 0x20。端口 I/O 的好处是与内存访问隔离,但指令和地址空间都相对特殊。
内存映射 I/O 把设备寄存器映射到物理地址空间,CPU 使用普通的内存读写指令访问设备。现代 SoC、PCIe 设备 BAR 空间等大量使用这种方式。它的好处是访问效率高,使用普通指针操作即可,容易利用编译器优化。缺点是设备寄存器不能随意缓存、合并或重排访问顺序,需要使用内存屏障和位宽控制来保证正确性。
在 Linux 中,访问端口 I/O 使用inb、outb、inw、outw、inl、outl等函数。访问内存映射 I/O 时,驱动通常先通过request_mem_region保留资源,再用ioremap把物理地址映射到内核虚拟地址,然后使用readb、readl、writeb、writel等函数读写。
下面是一个 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); }需要特别注意的是,编译器可能为了优化而对相邻内存访问进行重排。设备寄存器访问通常具有副作用,例如写“启动命令”后再读“状态”必须保持顺序。因此内核提供readl、writel这类带屏障语义的访问函数,以及dma_wmb、rmb、mb等屏障宏,确保设备看到的操作顺序符合驱动预期。
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_coherent、dma_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_HANDLED或IRQ_NONE。如果返回IRQ_NONE,内核会认为该处理函数没有处理这个中断,继续调用同一中断线上的其他共享处理函数。返回IRQ_HANDLED表示本处理函数已经处理了中断。
驱动开发者需要理解,中断处理程序运行在一个特殊的上下文中。它不是普通进程上下文,没有独立的进程栈和完整的调度状态。中断上下文中不能调用可能睡眠的函数,例如msleep、copy_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_work或queue_work调度。
Linux 还提供线程化中断。将request_threaded_irq的顶半部设置为快速检查函数,底半部放到专门的内核线程中执行。线程化中断可以降低传统中断上下文对系统的阻塞时间,并减少驱动开发难度。对许多实时性要求不极端的设备,线程化中断是非常合适的选择。
选择底半部机制的一般原则是:能放在顶半部做的小事就不必进入底半部;不能睡眠、时间稍长且需要严格串行时使用 tasklet;核心子系统中已有 softirq 类型时使用 softirq;需要睡眠、访问文件或耗时更长时使用 workqueue 或线程化中断。
15. 并发、竞态与中断安全
驱动开发中最难的部分之一,是处理中断处理程序与进程上下文之间的并发。假设一个字符设备驱动维护一个环形缓冲区,用户进程通过read读取缓冲区,设备中断处理程序写入缓冲区。两个执行路径可能同时访问同一数据结构,一个正在读、一个正在写,如果中间被打断或交错执行,就会出现数据损坏。这种情况称为竞态条件。
解决竞态条件的基本思路是使用锁。但中断上下文不能使用普通的互斥锁,因为互斥锁可能导致睡眠,而中断上下文不允许睡眠。常用的做法是使用自旋锁,并结合关中断操作。
如果数据结构只会被进程上下文和设备中断访问,可以使用spin_lock_irqsave和spin_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 乱序执行也可能带来问题。驱动中需要保证设备寄存器访问顺序时,应使用writel、readl等接口和内存屏障。对于共享内存中的标志位,如果可能被中断处理程序和进程上下文同时看到,可以使用READ_ONCE和WRITE_ONCE,防止编译器进行不安全的缓存和重排。
中断延迟是另一个重要指标。中断延迟指从中断发生到处理程序真正开始执行的时间,包含硬件传输、中断控制器处理、CPU 屏蔽状态、软件保存上下文、调度底半部等环节。驱动开发中,长时间中断处理、过度使用关中断和自旋锁、不必要的大锁都会增加中断延迟,影响系统实时性。定位中断延迟问题通常需要结合ftrace、trace-cmd和preemptirq等工具。
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; }真正的驱动还需要注册字符设备、分配主设备号、创建设备节点,并在应用层通过open、read、close访问。这里只保留了中断路径和等待队列路径。理解了这条路径,就能理解大多数字符设备驱动的核心骨架。
从设备产生中断到用户进程读到数据,完整链路如下:设备产生中断,中断控制器把中断投递给 CPU,CPU 进入内核中断门处理函数,内核根据 IRQ 找到对应的irq_desc和用户注册的处理函数,处理函数读取设备数据、写入缓冲区并唤醒等待队列,等待的进程被调度运行后从内核缓冲区复制数据到用户空间。这一系列环节体现了中断、驱动、进程调度和内存管理之间的协同。
17. 调试与性能优化
开发和调试中断驱动时,最常用的信息来自/proc/interrupts。该文件列出每个 CPU 的中断计数、中断号、处理函数名称和设备信息。它可以帮助开发者判断中断是否被触发、中断是否集中在某个 CPU 上、是否有中断风暴或中断丢失。
典型的性能分析思路如下:
- 运行
cat /proc/interrupts,确认目标设备中断计数是否增长。 - 如果计数不增长,检查设备是否初始化、中断是否被屏蔽、共享中断是否被错误处理。
- 如果计数增长很快,检查是否产生高频中断,是否需要底半部或轮询优化。
- 比较不同 CPU 的中断计数,判断是否需要对中断做 CPU 亲和性调整。
- 使用
trace-cmd、ftrace或bpftrace跟踪中断延迟和处理时间。
常见的中断驱动错误包括:
- 忘记释放中断号,导致模块卸载后设备仍在触发中断。
- 共享中断处理函数没有判断设备是否产生中断,错误地返回
IRQ_HANDLED。 - 在中断处理函数中睡眠或访问用户态内存。
- 没有同步中断上下文和进程上下文对共享缓冲区的访问。
- 读取设备数据太晚,导致设备 FIFO 溢出或数据被覆盖。
- 没有正确处理设备中断状态位,导致处理完成后硬件反复触发中断。
调试设备寄存器问题时,可以使用devmem工具读写物理地址,也可以通过lspci -v查看设备资源。对于 MMIO 驱动,使用readl和writel时要确保地址已经正确映射,并且基地址符合平台对齐要求。硬件问题与软件代码之间往往只有一层寄存器,保持对寄存器的精确观察能显著缩短调试周期。
性能优化需要避免陷入“中断越大越好”的误区。对于高吞吐设备,应优先考虑批量处理和 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.c、traps.c和irq.c,理解真实 IDT 初始化。 - 使用 QEMU 和 gdb 启动一个极简内核,观察中断入口、栈切换和返回过程。
- 编写一个带中断的 PCI 或平台设备驱动,并用
/proc/interrupts和ftrace验证。 - 学习块设备驱动和网络设备驱动,比较不同子系统如何处理中断和 DMA。
- 深入研究 softirq、tasklet、workqueue 和线程化中断的实现,理解内核调度策略。
- 在实时 Linux 或嵌入式平台上测量并优化中断延迟,理解实时系统对中断机制的要求。
中断机制是操作系统中最具张力的部分:它既要响应敏捷,又要保证安全;既要快速返回,又要完成大量后续工作;既要让设备充分并行,又要避免竞态和拥塞。只有通过不断阅读源码、编写驱动和测量性能,才能真正把这些知识内化为工程能力。