1本章要解决的问题

如何把 I/O 集成进系统?通用的机制是什么?怎样让它们高效?

How should I/O be integrated into systems? What are the general mechanisms? How can we make them efficient?

没有输入的程序每次都输出同样的结果,没有输出的程序跑了也白跑,所以计算机离不开输入/输出(I/O)设备。前两部分我们虚拟化了 CPU 和内存,从这一章起进入全书第三部分持久化(persistence)。开篇之前,要先弄清一个更底层的问题:操作系统到底怎样和一块硬件"说话"?

本章给出后面所有章节都要用到的基础:设备的硬件接口长什么样;OS 如何用轮询、中断、DMA 和设备打交道,以及各自的代价;OS 怎样用设备驱动把千奇百怪的设备藏在统一接口后面。下一章我们会把目光放到一种具体设备——硬盘上。

2核心概念速览

分层总线(bus hierarchy)

越快的总线越短、越贵:内存总线最快,I/O 总线次之,外围总线最慢但能挂很多设备。

标准设备(canonical device)

对外是接口(状态、命令、数据寄存器),对内是内部结构(微控制器、内存、固件)。

轮询(polling)

OS 反复读状态寄存器直到设备不忙。简单,但慢设备面前 CPU 白白空转。

中断(interrupt)

发出请求后让进程睡眠、切去跑别的;设备完成时发中断,CPU 进入中断处理程序(ISR)。让计算与 I/O 重叠。

PIO 与 DMA

PIO(programmed I/O)由 CPU 亲自搬数据;DMA(直接内存访问)引擎替 CPU 搬,完成后再中断。

I/O 指令 vs 内存映射 I/O

访问设备寄存器的两种办法:x86 的特权指令 in/out,或把寄存器映射到内存地址用 load/store 访问。

设备驱动(device driver)

OS 中唯一知道设备细节的那一层;上层通过通用块接口访问,保持"设备无关"。

活锁与中断合并

中断太多时 OS 只顾处理中断,进程毫无进展(livelock);合并(coalescing)把多个中断攒成一个。

3系统架构:为什么总线要分层

先看一张"经典"的系统结构图。CPU 通过内存总线(通常是厂商专有的)连到主内存;一部分设备挂在通用 I/O 总线上(现代系统中多为 PCI 及其变种),显卡等高性能设备在这里;再往下是一条或多条外围总线(peripheral bus),例如 SCSI、SATA、USB,磁盘、鼠标、键盘这些慢设备连在上面。

CPU 内存 Memory 内存总线(专有) Memory Bus 桥 通用 I/O 总线(如 PCI) 显卡 Graphics 高速网卡 桥 外围 I/O 总线(SCSI、SATA、USB) 磁盘 磁盘 键盘 鼠标 U 盘 … 更快·更短·更贵 更慢·可以挂很多设备
图 36.1 原型系统架构(Prototypical System Architecture):离 CPU 越近,总线越快

为什么要分层?一句话:物理和成本。总线越快就必须越短,高性能的内存总线上根本没多少地方插设备;而且把一条总线做快本身就很贵。于是设计者把要求高性能的部件(如显卡)放得离 CPU 近,性能要求低的放得远。把磁盘等慢设备放到外围总线上还有一个好处:可以挂很多设备。

现代系统越来越多地用专用芯片组和更快的点对点互连。下图近似画出了 Intel Z270 芯片组:CPU 离内存最近,同时有一条高性能链路直连显卡;CPU 通过 Intel 专有的 DMI(Direct Media Interface)连到一块 I/O 芯片,其余设备再通过各种互连挂到这块芯片上。

CPU 内存 Memory 显卡 Graphics 内存互连 PCIe(显卡) DMI I/O 芯片 eSATA 磁盘 磁盘 磁盘 磁盘 USB 键盘 鼠标 PCIe 网卡 Network NVMe 固态盘
图 36.2 现代系统架构(近似 Intel Z270 芯片组):高速设备走 PCIe,磁盘走 eSATA,键鼠走 USB

右边,硬盘通过 eSATA 接入——ATA(AT Attachment)→ SATA(Serial ATA)→ eSATA(external SATA)是存储接口几十年来的演进,每一步都在追赶存储设备的速度。下方 USB 用来接键盘、鼠标这种低速设备。左边的 PCIe(Peripheral Component Interconnect Express)接更高性能的设备,比如网卡,以及 NVMe 这类高性能持久存储。

4标准设备与标准协议

为了抓住本质,书中虚构了一个"标准设备"(canonical device,并非真实存在的硬件)。任何设备都有两个重要部分:

接口Interface 状态Status 命令Command 数据Data 内部Internals 微控制器(CPU) Micro-controller 内存(DRAM / SRAM) Memory 其他专用芯片 Other Hardware-specific Chips
图 36.3 标准设备(A Canonical Device):OS 只能看到上面三个寄存器,下面的一切对 OS 透明

这个简化的接口只有三个寄存器:状态寄存器(读它可知设备当前状态)、命令寄存器(告诉设备执行某个任务)、数据寄存器(把数据传给设备或从设备取数据)。OS 读写这些寄存器,就能控制设备的行为。一次典型交互的标准协议如下:

While (STATUS == BUSY)
    ;                          // ① 等设备空闲(轮询)
Write data to DATA register    // ② 把数据写入数据寄存器(PIO)
Write command to COMMAND register
    // ③ 写命令:隐含地通知设备"数据已就位,开始干活"
While (STATUS == BUSY)
    ;                          // ④ 等设备完成这次请求(再次轮询)
OS(CPU) 设备 ① 读 STATUS …… 直到不再 BUSY(轮询) READY ② 写 DATA(CPU 亲自搬数据 = PIO,可能要写很多次) ③ 写 COMMAND:启动设备 设备工作中 ④ 读 STATUS …… 直到完成(再次轮询) 完成(可能附带错误码)
标准协议四步:两次轮询夹着一次数据传输和一次命令

这个协议简单、能用,但有明显的低效之处:轮询浪费 CPU——设备可能很慢,CPU 却一直空转等待,而不是切换去运行别的就绪进程。第 ② 步的 PIO 也让 CPU 干着"搬砖"的粗活。下面两节分别解决这两个问题。

5用中断降低 CPU 开销

如何避免轮询的代价?OS 怎样在不频繁轮询的情况下获知设备状态,从而降低管理设备的 CPU 开销?

How can the OS check device status without frequent polling, and thus lower the CPU overhead required to manage the device?

工程师们多年前就找到了办法——我们早就见过的中断(interrupt)。OS 发出请求后,让调用进程睡眠,上下文切换到别的任务;设备完成操作时发出一个硬件中断,CPU 跳到 OS 预先设定好的中断服务例程(ISR,interrupt service routine),也叫中断处理程序(interrupt handler)。这段 OS 代码完成请求的收尾(例如读出数据和错误码),并唤醒等待 I/O 的进程。

进程 1OS 内核进程 2设备 运行 ① 系统调用:发命令给设备 ② 睡眠+切换 ③ 运行(与 I/O 重叠) 设备处理请求 ④ 硬件中断 ⚡ ⑤ ISR:取结果唤醒进程 1 ⑥ 调度 继续
中断驱动 I/O 的一个完整回合:② 和 ⑤⑥ 就是"用中断的代价"——设备越快,这些固定开销越显得不划算

中断的价值在于让计算和 I/O 重叠(overlap)。原书用字符时间线说明("1""2"表示进程 1、2 在运行,"p"表示轮询,"c"表示拷贝数据):

原书四张时间线(由下方模拟器的同一套算法绘制,零开销假设):① 轮询 ② 中断 ③ PIO 写 ④ DMA 写

在 ① 中,进程 1 发出磁盘读请求后,没有中断的系统只能自旋,反复轮询直到 I/O 完成(5 个 p)。在 ② 中,OS 在磁盘服务进程 1 的请求时运行进程 2;请求完成时中断到来,OS 唤醒进程 1 再运行它。中间这段时间 CPU 和磁盘都在干活。

中断并不总是更好

中断能让计算和 I/O 重叠,但只对慢设备真正划算。设备很快时,第一次轮询往往就发现它已完成;此时用中断反而拖慢系统:切换到别的进程、处理中断、再切回来,这些都很昂贵。

6用 DMA 更高效地搬数据

如何降低 PIO 的开销?使用 PIO 时,CPU 花太多时间手工在内存和设备之间搬数据。怎样把这项工作卸载出去,让 CPU 得到更有效的利用?

With PIO, the CPU spends too much time moving data to and from devices by hand. How can we offload this work and thus allow the CPU to be more effectively utilized?

回到时间线 ③:进程 1 要写一些数据到磁盘,发起 I/O 时必须由 CPU 把数据一个字一个字地从内存拷到设备(c)。拷完了磁盘才开始工作,CPU 才能去做别的事。一件琐碎的活占用了宝贵的 CPU。

解决办法是直接内存访问(DMA,Direct Memory Access)。DMA 引擎是系统中一个非常专门的设备,能在设备和主内存之间编排数据传输,几乎不需要 CPU 干预:

内存进程 1 的缓冲区 设备(磁盘)数据寄存器/缓存 DMA 引擎 CPU ② 数据直接搬运(CPU 不参与) ① 编程 DMA:地址、长度、目标设备 ③ 传完了:DMA 控制器发中断
DMA:OS 只负责"下订单"(①),搬运(②)由 DMA 引擎完成,结束时用中断(③)通知 OS

要把数据送到设备,OS 只需告诉 DMA 引擎数据在内存的什么位置、拷多少、送到哪个设备,然后 OS 就可以去做别的事了。DMA 完成后,DMA 控制器发出中断,OS 就知道传输结束了。对比时间线 ④:拷贝(c)挪到了 DMA 那一行,CPU 在这段时间里运行进程 2,于是进程 2 得到了更多的 CPU 时间(8 个单位而不是 5 个)。

7模拟器:轮询、中断、PIO 与 DMA

模拟器

I/O 时间线:CPU 在等设备时都干了什么?

先点"原书示例"复现四张时间线,再调设备时间和切换开销。重点观察:快设备时中断反而让进程 1 更晚完成;两阶段混合怎样两头兼顾;DMA 把 c 从 CPU 行挪走后进程 2 多得了多少 CPU。
1 进程 12 进程 2 p 轮询空转c 拷贝数据 s 上下文切换i 中断处理 · CPU 空闲⚡ 设备/DMA 完成(发中断)

同一组参数下,三种等待方式的对比

模型说明:时间以"格"为单位离散化;设备完成时刻若恰逢 CPU 正在上下文切换,中断要等切换结束才被处理;"两阶段混合"在设备(含 DMA 搬运)完成时间 ≤ 自旋上限时等同于轮询,否则先轮询满自旋上限再转为中断。原书示例假设切换与中断处理开销为 0。

8中断合并与活锁

上面的模拟器里只有一次 I/O。网络设备可不一样:包是源源不断到来的。每个包一次中断,每次中断都有固定开销(保存现场、进入 ISR、应答中断控制器、返回……),外加在中断上下文里做的协议处理。中断的优先级高于任何用户进程,于是到达率一高,CPU 就被中断"吃光"——这就是接收活锁(receive livelock):系统看起来 100% 忙碌,却一个请求也没服务完。

本模拟器的简化模型(到达率 λ 以"千包/秒"计,等价于"包/毫秒";时间单位 µs):

模拟器

中断合并(coalescing)与接收活锁(livelock)

拖动到达率 λ,看三条"完成率-到达率"曲线:逐包中断在过载后急剧塌陷到 0;合并推迟了塌陷但付出延迟;轮询式混合保持在上限。下方时间条展示 400µs 内 CPU 被怎样切分。

9与设备通信的两种方法

如何与设备通信?硬件应该怎样与设备通信?需要专门的指令吗?还是有别的办法?

How should the hardware communicate with a device? Should there be explicit instructions? Or are there other ways to do it?

到目前为止我们还没说 OS 究竟怎样读写设备寄存器。历史上形成了两种主要方法:

显式 I/O 指令(x86 in / out) CPUoutb 0x1F7 内存地址空间 RAM I/O 端口空间 0x1F0–0x1F7 IDE 0x3F6 • 独立的"端口"地址空间,专用指令访问 • 指令是特权的:只有 OS 能直接碰设备 内存映射 I/O(memory-mapped I/O) CPUstore → 地址 X 统一的物理地址空间 RAM 设备寄存器地址 X … RAM • 普通 load/store,硬件把该地址路由到设备 • 不需要新指令;保护靠地址映射/权限
两种访问设备寄存器的方式,今天都还在使用

两者没有谁有压倒性优势。内存映射的好处是不需要新指令,但两种方法今天都在用。

10融入 OS:设备驱动

如何构建设备无关的 OS?怎样让 OS 的大部分保持设备中立,把设备交互的细节从主要子系统中隐藏起来?

How can we keep most of the OS device-neutral, thus hiding the details of device interactions from major OS subsystems?

我们希望文件系统在 SCSI 盘、IDE 盘、USB 闪存盘上都能工作,而且对"怎样给不同类型的盘发读写请求"一无所知。解决办法是古老的抽象:在最底层,OS 中有一块软件必须详细了解设备如何工作,我们叫它设备驱动(device driver),所有设备交互的细节都封装在里面。

应用程序 Application POSIX API [open, read, write, close …] 用户态 user内核态 kernel mode 文件系统 File System Raw 接口 通用块接口 [block read/write] 通用块层 Generic Block Layer 特定块接口 [protocol-specific read/write] 设备驱动 Device Driver [SCSI, ATA …] SCSI 盘 SATA 盘 USB 盘 ← 不知道底下是哪类盘   只发"读/写第 n 块" ← 唯一懂设备细节的地方
图 36.4 文件系统栈(The File System Stack,Linux 的粗略示意)

文件系统(更别提上面的应用)完全不关心用的是哪类磁盘,它只向通用块层发出块读写请求,通用块层把请求转给合适的设备驱动,由驱动处理发出具体请求的细节。图中还有一个raw 接口:让文件系统检查器(fsck,后面会讲)、磁盘碎片整理工具等特殊程序绕过文件抽象,直接读写块。大多数系统都提供这类接口来支持底层存储管理程序。

封装也有代价。如果某设备有很多特殊能力,却必须向内核其余部分呈现通用接口,这些能力就用不上了。Linux 中的 SCSI 设备就是一例:SCSI 有非常丰富的错误报告,但因为其他块设备(如 ATA/IDE)的错误处理简单得多,上层软件最终只能收到一个笼统的 EIO(generic I/O error),SCSI 提供的额外细节对文件系统来说全丢了。

设备驱动 > 70% 其余内核代码 对 Linux 内核的研究:超过 70% 的 OS 代码在设备驱动里 [C01]
"操作系统有几百万行代码",其实大多是驱动代码

因为你可能插进系统的每一种设备都需要驱动,驱动逐渐占据了内核代码的绝大部分:研究表明 Linux 内核超过 70% 的代码在设备驱动里,Windows 大概也差不多。当然,对某一台具体机器,大部分驱动代码并不活跃(同时连着的设备只有几个)。更让人沮丧的是,驱动常常由"业余者"而非全职内核开发者编写,bug 多得多(一项研究发现驱动的 bug 率约是内核主线代码的 7 倍),是内核崩溃的主要来源。

11案例:一个简单的 IDE 磁盘驱动

来看一个真实设备:IDE 磁盘。它的接口由四类寄存器组成:控制、命令块、状态、错误。在 x86 上用 in/out 指令读写特定"I/O 地址"(如 0x3F6)来访问它们。

端口地址寄存器用途
0x3F6控制寄存器 Control= 0x08(0000 1RE0):R = reset;E = 0 表示允许中断
0x1F0数据端口 Data PortPIO 读写扇区数据
0x1F1错误 Error状态中 ERROR=1 时查看
0x1F2扇区数 Sector Count本次要读写几个扇区
0x1F3LBA 低字节逻辑块地址(LBA)的各个字节
0x1F4LBA 中字节
0x1F5LBA 高字节
0x1F61B1D TOP4LBAB = LBA 模式,D = 驱动器号(主 0x00 / 从 0x10),低 4 位是 LBA 最高 4 位
0x1F7命令 / 状态写 = 命令(READ/WRITE),读 = 状态

状态寄存器(0x1F7)的 8 个位(7 → 0):

BUSY
READY
FAULT
SEEK
DRQ
CORR
IDDEX
ERROR

错误寄存器(0x1F1),ERROR = 1 时检查(7 → 0):

BBK
UNC
MC
IDNF
MCR
ABRT
T0NF
AMNF

BBK 坏块;UNC 不可纠正的数据错误;MC 介质已更换;IDNF 找不到 ID 标记;MCR 请求更换介质;ABRT 命令中止;T0NF 找不到 0 号磁道;AMNF 找不到地址标记。(图 36.5 IDE 接口)

假设设备已初始化,与它交互的基本协议是:

  1. 等驱动器就绪:读状态寄存器(0x1F7),直到 READY 且不 BUSY。
  2. 把参数写入命令寄存器:扇区数、要访问扇区的 LBA、驱动器号(IDE 只允许两个驱动器:主 0x00、从 0x10),写到 0x1F2–0x1F6。
  3. 启动 I/O:把 READ 或 WRITE 命令写入 0x1F7。
  4. 数据传输(写):等状态为 READY 且 DRQ(驱动器请求数据),把数据写进数据端口。
  5. 处理中断:最简单时每传一个扇区处理一次中断;复杂些的做法可以批量,整个传输结束才发一次中断。
  6. 错误处理:每次操作后读状态寄存器;若 ERROR 位为 1,读错误寄存器了解详情。

xv6 的 IDE 驱动(简化版,图 36.6)几乎完整体现了这个协议:

static int ide_wait_ready() {
  while (((int r = inb(0x1f7)) & IDE_BSY) || !(r & IDE_DRDY))
    ;                                  // 轮询:直到不 BUSY 且 READY
}
static void ide_start_request(struct buf *b) {
  ide_wait_ready();
  outb(0x3f6, 0);                      // 允许中断(E=0)
  outb(0x1f2, 1);                      // 几个扇区?
  outb(0x1f3, b->sector & 0xff);       // LBA 低字节
  outb(0x1f4, (b->sector >> 8) & 0xff); // LBA 中字节
  outb(0x1f5, (b->sector >> 16) & 0xff);// LBA 高字节
  outb(0x1f6, 0xe0 | ((b->dev&1)<<4) | ((b->sector>>24)&0x0f));
  if (b->flags & B_DIRTY) {
    outb(0x1f7, IDE_CMD_WRITE);        // 写命令
    outsl(0x1f0, b->data, 512/4);      // 写:顺便用 PIO 把数据送过去
  } else {
    outb(0x1f7, IDE_CMD_READ);         // 读命令(此时不传数据)
  }
}
void ide_rw(struct buf *b) {
  acquire(&ide_lock);
  for (struct buf **pp = &ide_queue; *pp; pp = &(*pp)->qnext)
    ;                                  // 走到队尾
  *pp = b;                             // 请求入队
  if (ide_queue == b)                  // 队列原本为空?
    ide_start_request(b);              // 直接发给磁盘
  while ((b->flags & (B_VALID|B_DIRTY)) != B_VALID)
    sleep(b, &ide_lock);               // 睡眠,等中断唤醒
  release(&ide_lock);
}
void ide_intr() {
  acquire(&ide_lock);
  struct buf *b = ide_queue;
  if (!(b->flags & B_DIRTY) && ide_wait_ready() >= 0)
    insl(0x1f0, b->data, 512/4);       // 读:在中断处理中取数据(PIO)
  b->flags |= B_VALID;
  b->flags &= ~B_DIRTY;
  wakeup(b);                           // 唤醒等待的进程
  if ((ide_queue = b->qnext) != 0)     // 还有请求?
    ide_start_request(ide_queue);      // 发出下一个
  release(&ide_lock);
}

12历史注记

中断是一个非常古老的想法,最早的机器上就有:20 世纪 50 年代初的 UNIVAC 已有某种形式的中断向量。至于谁最先引入 DMA 还有争论:Knuth 等人认为是 DYSEAC(一台"移动"计算机——当时的意思是能装在拖车里拉走),也有人认为是 IBM SAGE。无论如何,到 50 年代中期,已经存在能与内存直接通信、完成后中断 CPU 的 I/O 设备系统。这些想法其实相当"显然":CPU 快、设备慢,让 CPU 在慢 I/O 进行时去做别的事,是那个年代的工程师自然会想到的。

13先预测,再验证

载入"原书①轮询"和"原书②中断"。两者进程 1 的完成时间分别是多少?CPU 有用工作时间(1 和 2 的格数)各是多少?先写下预测再运行。
看答案
进程 1 都在第 15 格完成——在零开销的理想情况下,中断并不会让进程 1 更快。区别在于 CPU 的用途:轮询时 5 格被 p 浪费,有用工作 10 格;中断时这 5 格给了进程 2,有用工作 15 格(利用率 100%)。中断提升的是整体利用率/吞吐,而不是单个 I/O 的延迟。
设备服务时间 1,上下文切换 s = 2,中断处理 i = 1,读、无拷贝。分别用轮询和中断,进程 1 何时完成?
看答案
轮询:1×5、p、1×5,第 11 格完成。中断:1×5 后先花 2 格切换(设备在第 6 格就完成了,中断只能等切换做完),再 1 格中断处理、2 格切回,最后 1×5,第 15 格完成,而且进程 2 一格也没跑上。这就是"快设备用中断反而更慢"(点"快设备:中断更慢"预设验证)。
保持 s = 2、i = 1,把设备服务时间改成 12("慢设备 + 真实开销")。三种方式中哪种让进程 1 最早完成?哪种让进程 2 得到最多 CPU?
看答案
轮询:进程 1 在 22 完成,进程 2 得 0 格,浪费 12 格 p。中断:进程 1 在 25 完成(多了 2+1+2 的开销),但进程 2 得到 10 格。两阶段(自旋上限 2):同样 25 完成,进程 2 得 8 格(前 2 格轮询白转)。结论:对单个 I/O 的延迟,轮询始终最低;中断的价值是把等待时间"还"给其他进程。慢设备时这笔账划算,快设备时不划算。
写方向、拷贝量 c = 10、设备时间 5、中断、零开销、I/O 后运行 2。PIO 和 DMA 下进程 1 的完成时间与进程 2 得到的 CPU 各是多少?
看答案
两者进程 1 都在第 22 格完成(拷贝 10 + 设备 5 的串行路径不变),但 PIO 时 CPU 花 10 格做 c,进程 2 只得 5 格;DMA 时拷贝交给 DMA 引擎,进程 2 得到 15 格。DMA 同样不缩短这次 I/O,本身,它省下的是 CPU。
两阶段混合:设备时间 3,s = 2,i = 1。自旋上限分别设为 2 和 4,进程 1 各在何时完成?自旋上限该怎么选?
看答案
上限 4:设备 3 格内完成,等同轮询,第 13 格完成。上限 2:白转 2 格后仍转中断,付出 2+1+2 的开销,第 17 格完成,比纯轮询和纯中断都差。经验法则:自旋上限大约取"一次中断 + 两次切换"的代价——等待不超过这个量,轮询更省;超过了,再切换也不亏太多(类似第 28/32 章讨论的"先自旋后睡眠"锁)。
在活锁模拟器中取默认 h = 10、c = 5、u = 20 µs。先算:逐包中断的完成率在 λ 为多少时达到峰值?λ 为多少时降为 0?轮询式混合的上限是多少?再拖动滑块验证。
看答案
峰值 λ = 1000/(10+5+20) ≈ 28.6 千包/秒;降为 0 于 λ = 1000/(10+5) ≈ 66.7(此后 CPU 100% 在处理中断:活锁)。轮询式混合的上限 = 1000/(5+20) = 40 千包/秒,之后保持平稳,多余的包在网卡处被丢弃。合并窗口 W = 50 时,λ = 60 下完成率约 27.5,比逐包中断的 5 好得多,但每个包平均多等约 31 µs。

14动手实验:轮询 vs 阻塞等待

用户态程序摸不到真实的设备寄存器,但我们可以用一个"慢设备"来模仿:子进程睡眠一段时间后往管道写 1 个字节(相当于设备完成)。父进程用三种方式等待它:

程序用 getrusage() 统计父进程等待期间消耗的 CPU 时间(用户态 + 内核态),用 clock_gettime() 统计墙钟时间。

cd labs/36-io-devices && make
./wait-io poll 500      # 设备延迟 500ms,轮询
./wait-io block 500     # 阻塞等待
./wait-io hybrid 500 100# 先自旋 100µs 再阻塞
make run                # 一次跑完,含 1ms 快设备的对比

示例输出(WSL2 + gcc 14 实测;你的机器上数字会不同):

mode=poll   device=500ms  wall=  502.4 ms  cpu(user+sys)=  466.2 ms  ( 92.8% of wall)  polls=811443
mode=block  device=500ms  wall=  500.7 ms  cpu(user+sys)=    0.1 ms  (  0.0% of wall)  polls=0
mode=hybrid device=500ms  wall=  502.4 ms  cpu(user+sys)=    0.1 ms  (  0.0% of wall)  polls=180
--- 快设备(1ms)---
mode=poll   device=1ms  wall=    7.3 ms  cpu(user+sys)=    3.3 ms  ( 45.4% of wall)  polls=6229
mode=block  device=1ms  wall=    3.1 ms  cpu(user+sys)=    0.1 ms  (  3.0% of wall)  polls=0

该观察到什么:三种方式的墙钟时间几乎一样(都要等"设备" 500ms),但轮询把约 93% 的时间花在 CPU 上、做了八十多万次无用的 read();阻塞等待几乎不耗 CPU——这段 CPU 可以给别的进程用。混合方式只在开头自旋了 180 次,之后睡眠,CPU 消耗和阻塞一样低。

为什么快设备那组没显示"轮询更快"?在用户态,"设备"本身是个要被调度、要唤醒的进程,fork 和定时器的误差(毫秒级)远大于一次中断/切换的开销(微秒级),所以这个实验只能清楚展示"轮询浪费 CPU"这一面。"快设备时轮询延迟更低"要在内核里才明显——Linux 的 NVMe 驱动为此提供了 io_poll(高优先级 I/O 走轮询),网卡驱动的 NAPI 也是在高负载时从中断切到轮询。

15自测题

为什么系统要把总线分成内存总线、通用 I/O 总线、外围总线几层?
原书的答案是"物理和成本":高速总线短、贵、插槽少;分层让高性能部件(如显卡)靠近 CPU,慢设备(磁盘、键鼠)挂在外围总线上,而且可以挂很多。
一个设备几乎总在第一次轮询时就已完成。对它使用中断会怎样?
对快设备,中断带来的上下文切换和中断处理开销比设备本身还贵。书中的建议:快设备轮询,慢设备中断,不确定时两阶段混合。
关于 DMA,下列哪项说法正确?
DMA 卸下的是 CPU 的搬运工作(PIO 的 c),CPU 可以运行别的进程;完成时靠中断通知。单次 I/O 的串行路径(拷贝 + 设备服务)并不因此变短。
"接收活锁(receive livelock)"指的是:
活锁时系统很"忙"却没有进展。中断优先级高于用户进程,到达率足够高时 CPU 全部花在中断上。对策:高负载时改为轮询(控制节奏),或中断合并。
中断合并(interrupt coalescing)的代价是什么?
合并用"等一等"换来"少中断几次",典型的延迟 vs 开销权衡;等太久延迟就不可接受。
关于内存映射 I/O,正确的是:
内存映射 I/O 的好处是不需要新指令;原书强调两种方法没有谁有压倒性优势,今天都在用。访问权限仍由 OS 通过地址映射控制。
Linux 中 SCSI 设备丰富的错误信息为何到文件系统那里只剩一个 EIO?
这是抽象/封装的代价:为了设备无关,上层只能看到所有设备的"最大公约数"。
在 xv6 IDE 驱动中,ide_rw() 发出请求后调用进程如何等待完成?
ide_rw() 把请求入队(必要时立即发出),然后 sleep;磁盘完成后产生中断,ide_intr() 取数据(读时)、标记完成、wakeup 等待者并发出队列中的下一个请求。ide_wait_ready() 的轮询只用于发命令前的短暂等待。

16常见误区与本章小结

本章要点

承上启下:本章讲的是"OS 怎样和任何设备打交道"。下一章(第 37 章)深入最重要的持久化设备——硬盘:它的接口(扇区数组)、几何结构(盘片、磁道、磁头)、I/O 时间 = 寻道 + 旋转 + 传输,以及磁盘调度。此后的 RAID、文件系统都建立在这两章的模型之上。