编程语言研究书库

中断与队列 — 让硬件来敲门,以及为什么中断里不能干重活

中断与队列 — 让硬件来敲门,以及为什么中断里不能干重活

这一章讲什么: 上一章用轮询问鼠标「动了没」,本章换成中断——鼠标动时硬件主动通知 CPU。 为此要把 x86-64 的中断机制整套立起来:中断向量编号、IDT 注册表、MSI 配置。 立起来之后立刻撞上第二个问题:中断处理程序里干重活,会丢别的事件—— 于是「处理程序只推一条消息进队列、主循环慢慢处理」的经典结构诞生, 顺带引出关中断(cli)与数据冲突的第一次正面交锋。

1. 先看现象:从「你动了吗」到「它自己来报告」

主线例子,鼠标移动一次,系统里发生了什么:

  轮询版(第 6 章):主循环反复问 xHC「有事件没?」——问一万次,中一次
  中断版(本章):
    ① 开机时:把 IntHandlerXHCI 的地址填进 IDT[0x40],lidt 告诉 CPU 表在哪
    ② 配置 MSI:告诉 xHC「有事件时,向这个内存地址写这个数」——
       写的动作本身就是中断,向量号 = 0x40
    ③ 鼠标一动:xHC 往约定地址一写 → CPU 被打断,跳到 IDT[0x40] = IntHandlerXHCI
    ④ 处理程序收尾时往 0xfee000b0(EOI 寄存器)写一个值:「我完事了」
       ——不写信,下次同样中断就不会再来了

  图说:从「人盯梢」变「门铃」。但门铃一响,处理必须快——下一节就说为什么。

2. 顶层全景

  事前准备:处理程序(__attribute__((interrupt)))→ IDT(256 项数组)→ lidt 注册
  事发生时:设备经 MSI「写内存」报案 → CPU 按向量号查 IDT → 跳转执行 → EOI 销案
  事后优化:处理程序瘦身为「Push 一条消息」→ 主循环事件驱动地 Pop 处理
       (队列为空时 sti+hlt 睡死等中断;操作队列时 cli 关中断防数据冲突)

  图说:前三段是「让中断跑起来」,第四段是「让中断跑得好」——
  后者(消息队列 + 事件循环)将是此后全书的主循环结构。

3. 核心原理

3.1 中断的两个约定:向量和表

中断的类型五花八门(鼠标、定时器、除零……),所以每种中断分一个编号,叫中断向量 (x86-64 是 0–255):除零固定是 0,USB 中断的编号由 OS 作者自己定1。 CPU 需要一张「向量号 → 处理程序地址」的表,这就是 IDT(中断描述符表): 放在主内存里的 256 项数组,每项是一个 16 字节的中断描述符2。

描述符的关键字段:处理程序地址(拆成三段,烦琐但机械)、代码段选择器 (64 位模式里分段已被简化,第 8 章再谈)、属性 type(14=中断门/15=陷阱门)和 DPL(权限,一般 0)、present 标志3。 填表用 SetIDTEntry(),填完用 lidt 指令把「表的大小和地址」注册进 CPU—— lidt 吃的不是两个参数,而是一个 10 字节内存区(2 字节大小 + 8 字节地址),所以 LoadIDT 得用汇编写, 参数经 RDI/RSI 传进来4。xHCI 的向量被定义为 0x405。

两个 C++ 细节顺便认识:__attribute__((packed)) 禁止编译器在结构体字段间塞填充, 因为这张表的长度和布局是硬件规范定的,一个字节都不能多6; 位域(类型:位宽)让「一个整数里的几比特」能按名字读写,编译器用 AND/OR 生成读写指令7。

3.2 处理程序的仪式:EOI 与 volatile

__attribute__((interrupt)) 告诉编译器「这是中断处理程序」,编译器自动插入上下文保存/恢复(把当时的寄存器值先存起来,办完再还原) (第 13 章的主题)的前处理后处理8。处理程序本体很短:调 NotifyEndOfInterrupt()。 它干的事是往 0xfee000b0 写个值——那个地址不是主内存,是 CPU 的「中断结束寄存器」, 写的动作本身就是通知;不写,下次同号中断就被忽略,鼠标就僵死9。

这引出一个 C++ 陷阱:这种「写了没人读」的变量,编译器优化时会认为写操作无用而删掉。 volatile 修饰符的用途正在于此:声明这个变量易变,读写它不许优化省略10。 (OS 代码里凡是要真的碰到硬件的地址,都要 volatile——MMIO 全靠它保命。)

3.3 MSI:设备用「写内存」报案

老办法给每个 PCI 设备拉一根中断线,可 PCI 只定义了 INT#A~D 四根线,设备们得共享—— 共享就要猜「刚才是谁响的」,苦不堪言。MSI(Message Signaled Interrupt)换了个思路: 不对 CPU 拉线,而是往内存总线上写一个特定的值,写这个值的动作就是中断11。

配置 MSI 就是填两个字段:Message Address(写往哪个地址——其中 Destination ID 填 要通知的 CPU 内核编号 Local APIC ID,读 0xfee00020 的 31:24 位可得)和 Message Data(写什么值——其中 Vector 填中断向量,也就是 0x40)。 于是「xHC 往这里写这个 → CPU 收到 0x40 号中断 → 查 IDT → IntHandlerXHCI」这条链就闭合成环12。 本书只启动一个 CPU 内核(BSP,Bootstrap Processor),其余内核被禁用,所以中断都落在 BSP 上13。

3.4 中断为什么不能干重活:173×2 次内存写

现在的中断处理程序里有 ProcessEvent(),鼠标一动就在里面擦旧光标、画新光标—— 光标不透明部分 173 个像素,每次移动要往显存(帧缓冲区)写 173×2 次内存,从 CPU 看很耗时14。 更要命的是:处理中断时不接收其他中断——正在擦光标时定时器中断来了,错过; 错过网络数据包就很麻烦15。

加速策略一句话:中断处理程序只记录「中断来过了」,重活稍后到普通上下文里慢慢干16。 记录的工具是队列。

3.5 FIFO、环形数组与 ArrayQueue

FIFO(先进先出)= 队列;FILO(先进后出)= 栈——同一对结构的两个名字17。 C++ 标准库有现成的,但它们要动态内存管理,现在的 OS 还没有,所以自己写一个18。

ArrayQueue 用固定长度数组 + 两个游标(跟着读写位置移动的指针)实现:read_pos_ 指队头(下一个该弹的), write_pos_ 指队尾(下一个该写的);Push/Pop 各自递增,越过数组末尾就重置为 0—— 末尾和开头缝合成一个环,这就是环形缓冲区19。 Push 满了返回 kFull,Pop 空返回 kEmpty;Pop 只挪游标,取值用 Front() 单独做20。 这是个模板类,template <typename T> 一行让同一份代码装任何消息类型—— 本章用它装 Message 结构体(目前只有 xHCI 一种类型,位预留着)21。

3.6 事件循环与数据冲突:cli/sti/hlt 三兄弟

改造后的系统分两半:

  中断处理程序(要快要快):
      msg.type = kInterruptXHCI; msg_queue.Push(msg);  ——完事
  主循环(随便慢):
      if (队列空) { sti; hlt; }        // 开中断,睡死,等下一个门铃
      else { cli; msg = Front(); Pop(); sti; 处理 msg; }

  图说:队列是两套执行环境(中断处理程序 vs 主循环)之间的邮筒。

为什么取消息前后要 cli(关中断)/sti(开中断)?因为队列是两边共享的数据, 操作一半时来了中断,Push 和 Pop 会把 count_ 之类的状态改乱。书里拿 Pop 演示了教科书级的 数据冲突(race condition):Pop 把 count_ 读进 RAX、减 1、写回——中间插入的 Push 递增了 count_, 返回主流程后旧值盖回去,一次递增就丢了22。

两个死亡提醒:取完消息立刻 sti,否则中断永远不来、鼠标永不移动; hlt 之前必须 sti——中断关着时 hlt 是睡死,再也醒不来23。 书里也很诚实:cli 只挡得住中断这一种并发源,面对编译器优化和多核,它并不完备; 完整的并发防护超出了本书范围24。

4. 作者的判断与证据

判断:中断处理程序的理想形态是「空」的。 「只生成 Message 推入队列——这是一个理想的中断处理程序」25。 这个判断的证据是反事实的:不重活会错过中断,错过网络包「很麻烦」——工程后果摆在那。

判断:轮询的价值在于铺垫。 第 6 章明知轮询笨还要用它,是为了让第 7 章的中断「可被理解」: 先亲手感到「问一万次中一次」的浪费,再看门铃机制才知好在哪。

坦白: 7.7 节原注:「原本应该测量处理所需的时间,以了解修改的程度,但由于目前还没有这样做的方法, 所以这里没有进行测量」26——想测而没有工具,这个欠账第 11 章(定时器)来还。

5. 边界与局限

  • cli/sti 是最原始的并发原语:只挡中断、不挡多核(本书单核假设下够用),不是锁的替代品。
  • 队列定长、满则丢弃错误返回:没有背压、没有优先级,第 14 章的任务管理会接管更复杂的调度。
  • MSI/MSI-X 的格式是 x86-64 + Local APIC 特有的;别的架构有自己的报案方式。
  • BSP 单核是硬假设:多核启动(AP 的唤醒)本书不做,中断全部落在唯一的核上。

6. 可带走的

本章主线例子,一行写完: 鼠标动 → xHC 经 MSI 写内存报案(向量 0x40)→ CPU 查 IDT 跳 IntHandlerXHCI → 推一条 Message 进队列 → 主循环 Pop 处理 → EOI 销案 → 空了就 sti+hlt 睡等下一个。

  1. 中断 = 硬件主动通知:事前注册处理程序进 IDT,事中 CPU 按向量号查表跳转,事后 EOI 销案;
  2. IDT 是内存里的 256 项数组,lidt 用 10 字节内存区(大小+地址)注册;硬件结构的结构体要 packed;
  3. MMIO 写动作本身有意义(EOI 寄存器),这种变量必须 volatile,否则被优化掉;
  4. MSI = 设备用「写内存」报案,Address 里填目标 CPU 核(Local APIC ID),Data 里填向量号;
  5. 中断里干重活会丢别的事件;正解是处理程序只推消息、主循环消化——此后全书的主循环;
  6. 环形队列 = 数组 + 两个游标,越界即归零,首尾缝成环;
  7. 共享数据操作要 cli 关中断,操作完 sti;hlt 前必须先 sti,睡死就醒不来;
  8. 数据冲突的本质是「读-改-写」中途被插队;单核 + 关中断能挡住一半,完整方案是锁(本书不展开)。

7. 原文地图

主题原书章原文位置
中断 vs 轮询、值守机器人7.1 中断(osbook_day07a)text/50-ch07-01-7-1-osbook-day07a.txt:4(搜「硬件通知」) · text/50-ch07-01-7-1-osbook-day07a.txt:7(搜「可靠的机器人」)
中断实现的事前/事中两步7.1 中断(osbook_day07a)text/50-ch07-01-7-1-osbook-day07a.txt:24(搜「事前准备」)
attribute((interrupt)) 与 EOI7.2 中断处理程序text/51-ch07-02-7-2.txt:22(搜「attribute」) · text/51-ch07-02-7-2.txt:29(搜「0xfee000b0」)
volatile 的必要性7.3 中断向量text/52-ch07-03-7-3.txt:10(搜「volatile修饰符」)
中断向量与 IDT7.3 中断向量text/52-ch07-03-7-3.txt:18(搜「中断有多种类型」) · text/52-ch07-03-7-3.txt:32(搜「中断描述符表」)
描述符字段、位域、packed7.3 中断向量text/52-ch07-03-7-3.txt:66(搜「位域」) · text/53-ch07-04-7-4.txt:1(搜「插入间隙」)
分段在 64 位模式的简化7.3 中断向量text/52-ch07-03-7-3.txt:77(搜「代码段」)
SetIDTEntry、向量 0x40、lidt7.4 设置中断描述符text/53-ch07-04-7-4.txt:21(搜「SetIDTEntry」) · text/53-ch07-04-7-4.txt:29(搜「0x40」) · text/53-ch07-04-7-4.txt:36(搜「lidt」)
MSI 机制与 Message 两字段7.5 MSI机制text/54-ch07-05-7-5-msi.txt:5(搜「信号线」) · text/54-ch07-05-7-5-msi.txt:15(搜「Message Address」)
Vector 字段、Local APIC ID、BSP7.5 MSI机制text/55-ch07-06-7-6.txt:3(搜「IntHandlerXHCI」) · text/55-ch07-06-7-6.txt:12(搜「Local APIC ID」) · text/55-ch07-06-7-6.txt:16(搜「BSP」)
中断总结三要素7.6 中断总结text/55-ch07-06-7-6.txt:28(搜「中断处理程序、中断描述符和中断源」)
173×2 次写显存的代价7.7 加速中断处理程序(osbook_day07b)text/57-ch07-08-7-8-fifo-filo.txt:7(搜「173个像素」)
处理中断时不能接收中断7.7 加速中断处理程序(osbook_day07b)text/57-ch07-08-7-8-fifo-filo.txt:10(搜「错过新的中断」)
只记录事实的基本策略7.7 加速中断处理程序(osbook_day07b)text/57-ch07-08-7-8-fifo-filo.txt:13(搜「只记录中断已到来的事实」)
FIFO/FILO 概念与别名7.8 FIFO和FILOtext/57-ch07-08-7-8-fifo-filo.txt:17(搜「先进先出」) · text/58-ch07-09-7-9.txt:8(搜「队列(queue)」)
自造队列的理由7.8 FIFO和FILOtext/58-ch07-09-7-9.txt:12(搜「动态内存管理」)
ArrayQueue 成员与环形7.9 实现队列text/58-ch07-09-7-9.txt:34(搜「template」) · text/58-ch07-09-7-9.txt:51(搜「data_」) · text/58-ch07-09-7-9.txt:93(搜「重置为0」)
Push 满/Pop 空/Front7.9 实现队列text/58-ch07-09-7-9.txt:78(搜「kFull」) · text/58-ch07-09-7-9.txt:92(搜「kEmpty」) · text/58-ch07-09-7-9.txt:97(搜「Front()」)
消息结构与理想处理程序7.10 使用队列加速中断text/59-ch07-10.txt:14(搜「Message结构体」) · text/59-ch07-10.txt:19(搜「这是一个理想的」)
cli 与 count_ 数据冲突示例7.10 使用队列加速中断text/59-ch07-10.txt:36(搜「cli」) · text/59-ch07-10.txt:49(搜「读入RAX」)
sti/hlt 顺序的死亡提醒7.10 使用队列加速中断text/59-ch07-10.txt:68(搜「sti指令」) · text/59-ch07-10.txt:72(搜「省电模式」)
没来得及测量的坦白7.7 加速中断处理程序(osbook_day07b)text/59-ch07-10.txt:27(搜「测量」)

Footnotes

  1. 出处:「7.1 中断(osbook_day07a)」(text/50-ch07-01-7-1-osbook-day07a.txt:4(搜「硬件通知」); text/50-ch07-01-7-1-osbook-day07a.txt:7(搜「可靠的机器人」))。 ↩

  2. 出处:「7.1 中断(osbook_day07a)」(text/50-ch07-01-7-1-osbook-day07a.txt:24(搜「事前准备」))。 ↩

  3. 出处:「3.1 QEMU监视器」(text/28-ch03-01-3-1-qemu.txt:42,搜「info registers」;:69,搜「x /fmt addr」;:86,搜「绕来绕去」)。 ↩

  4. 出处:「3.2 寄存器」(text/29-ch03-02-3-2.txt:9,搜「16GB」;:10,搜「0.5ns」;:12,搜「R8~R15」)。 ↩

  5. 出处:「3.2 寄存器」与「3.3 第一个内核」(text/29-ch03-02-3-2.txt:33,搜「RIP」;text/30-ch03-03-3-3-osbook-day03a.txt:3,搜「保护模式」)。 ↩

  6. 出处:「7.2 中断处理程序」(text/51-ch07-02-7-2.txt:22(搜「attribute」); text/51-ch07-02-7-2.txt:29(搜「0xfee000b0」))。 ↩

  7. 出处:「7.3 中断向量」(text/52-ch07-03-7-3.txt:10(搜「volatile修饰符」))。 ↩

  8. 出处:「7.3 中断向量」(text/52-ch07-03-7-3.txt:18(搜「中断有多种类型」); text/52-ch07-03-7-3.txt:32(搜「中断描述符表」))。 ↩

  9. 出处:「7.3 中断向量」(text/52-ch07-03-7-3.txt:66(搜「位域」); text/53-ch07-04-7-4.txt:1(搜「插入间隙」))。 ↩

  10. 出处:「7.3 中断向量」(text/52-ch07-03-7-3.txt:77(搜「代码段」))。 ↩

  11. 出处:「7.4 设置中断描述符」(text/53-ch07-04-7-4.txt:21(搜「SetIDTEntry」); text/53-ch07-04-7-4.txt:29(搜「0x40」); text/53-ch07-04-7-4.txt:36(搜「lidt」))。 ↩

  12. 出处:「7.5 MSI机制」(text/54-ch07-05-7-5-msi.txt:5(搜「信号线」); text/54-ch07-05-7-5-msi.txt:15(搜「Message Address」))。 ↩

  13. 出处:「7.5 MSI机制」(text/55-ch07-06-7-6.txt:3(搜「IntHandlerXHCI」); text/55-ch07-06-7-6.txt:12(搜「Local APIC ID」); text/55-ch07-06-7-6.txt:16(搜「BSP」))。 ↩

  14. 出处:「7.6 中断总结」(text/55-ch07-06-7-6.txt:28(搜「中断处理程序、中断描述符和中断源」))。 ↩

  15. 出处:「7.7 加速中断处理程序(osbook_day07b)」(text/57-ch07-08-7-8-fifo-filo.txt:7(搜「173个像素」))。 ↩

  16. 出处:「7.7 加速中断处理程序(osbook_day07b)」(text/57-ch07-08-7-8-fifo-filo.txt:10(搜「错过新的中断」))。 ↩

  17. 出处:「7.7 加速中断处理程序(osbook_day07b)」(text/57-ch07-08-7-8-fifo-filo.txt:13(搜「只记录中断已到来的事实」))。 ↩

  18. 出处:「7.8 FIFO和FILO」(text/57-ch07-08-7-8-fifo-filo.txt:17(搜「先进先出」); text/58-ch07-09-7-9.txt:8(搜「队列(queue)」))。 ↩

  19. 出处:「7.8 FIFO和FILO」(text/58-ch07-09-7-9.txt:12(搜「动态内存管理」))。 ↩

  20. 出处:「7.9 实现队列」(text/58-ch07-09-7-9.txt:34(搜「template」); text/58-ch07-09-7-9.txt:51(搜「data_」); text/58-ch07-09-7-9.txt:93(搜「重置为0」))。 ↩

  21. 出处:「7.9 实现队列」(text/58-ch07-09-7-9.txt:78(搜「kFull」); text/58-ch07-09-7-9.txt:92(搜「kEmpty」); text/58-ch07-09-7-9.txt:97(搜「Front()」))。 ↩

  22. 出处:「7.10 使用队列加速中断」(text/59-ch07-10.txt:14(搜「Message结构体」); text/59-ch07-10.txt:19(搜「这是一个理想的」))。 ↩

  23. 出处:「7.10 使用队列加速中断」(text/59-ch07-10.txt:36(搜「cli」); text/59-ch07-10.txt:49(搜「读入RAX」))。 ↩

  24. 出处:「7.10 使用队列加速中断」(text/59-ch07-10.txt:68(搜「sti指令」); text/59-ch07-10.txt:72(搜「省电模式」))。 ↩

  25. 出处:「7.10 使用队列加速中断」(text/59-ch07-10.txt:14(搜「Message结构体」); text/59-ch07-10.txt:19(搜「这是一个理想的」))。 ↩

  26. 出处:「7.7 加速中断处理程序(osbook_day07b)」(text/59-ch07-10.txt:27(搜「测量」))。 ↩

On this page