从0开始的操作系统教程:中断与设备驱动

发布时间:2026/9/8 3:57:39
从0开始的操作系统教程:中断与设备驱动 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 常用术语IRQInterrupt Request中断请求线或中断号。传统 PC 上每个设备使用一个或多个 IRQ 线。ISRInterrupt Service Routine中断服务程序也就是中断处理程序。向量CPU 用来索引中断描述符表的编号。x86 中向量范围是 0 到 255。IDTInterrupt Descriptor Table中断描述符表。保护模式下 CPU 根据中断向量查找处理入口。EOIEnd 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从片级联用于连接第二个 8259A3串口 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缺页使用向量 14x87 浮点异常使用向量 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/OCPU 访问设备寄存器有两种主要方式端口 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访问。这里只保留了中断路径和等待队列路径。理解了这条路径就能理解大多数字符设备驱动的核心骨架。从设备产生中断到用户进程读到数据完整链路如下设备产生中断中断控制器把中断投递给 CPUCPU 进入内核中断门处理函数内核根据 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 或嵌入式平台上测量并优化中断延迟理解实时系统对中断机制的要求。中断机制是操作系统中最具张力的部分它既要响应敏捷又要保证安全既要快速返回又要完成大量后续工作既要让设备充分并行又要避免竞态和拥塞。只有通过不断阅读源码、编写驱动和测量性能才能真正把这些知识内化为工程能力。