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 近,性能要求低的放得远。把磁盘等慢设备放到外围总线上还有一个好处:可以挂很多设备。
现代系统越来越多地用专用芯片组和更快的点对点互连。下图近似画出了 Intel Z270 芯片组:CPU 离内存最近,同时有一条高性能链路直连显卡;CPU 通过 Intel 专有的 DMI(Direct Media Interface)连到一块 I/O 芯片,其余设备再通过各种互连挂到这块芯片上。
右边,硬盘通过 eSATA 接入——ATA(AT Attachment)→ SATA(Serial ATA)→ eSATA(external SATA)是存储接口几十年来的演进,每一步都在追赶存储设备的速度。下方 USB 用来接键盘、鼠标这种低速设备。左边的 PCIe(Peripheral Component Interconnect Express)接更高性能的设备,比如网卡,以及 NVMe 这类高性能持久存储。
4标准设备与标准协议
为了抓住本质,书中虚构了一个"标准设备"(canonical device,并非真实存在的硬件)。任何设备都有两个重要部分:
- 硬件接口(interface):就像软件一样,硬件也要对外提供接口,让系统软件能控制它。每种设备都有约定好的接口和交互协议。
- 内部结构(internal structure):实现相关,负责实现设备对外呈现的抽象。简单设备几块芯片就够了;复杂设备里有一个简单的 CPU、通用内存和专用芯片。比如现代 RAID 控制器可能跑着几十万行固件(firmware)——也就是运行在硬件里的软件。
这个简化的接口只有三个寄存器:状态寄存器(读它可知设备当前状态)、命令寄存器(告诉设备执行某个任务)、数据寄存器(把数据传给设备或从设备取数据)。OS 读写这些寄存器,就能控制设备的行为。一次典型交互的标准协议如下:
While (STATUS == BUSY)
; // ① 等设备空闲(轮询)
Write data to DATA register // ② 把数据写入数据寄存器(PIO)
Write command to COMMAND register
// ③ 写命令:隐含地通知设备"数据已就位,开始干活"
While (STATUS == BUSY)
; // ④ 等设备完成这次请求(再次轮询)
- ① 轮询(polling):反复读状态寄存器,问设备"你现在怎么样了",直到它准备好接收命令。
- ② 送数据:若是磁盘,传一个 4KB 的块需要多次写数据寄存器。当主 CPU 亲自参与数据搬运时,就叫programmed I/O(PIO)。
- ③ 写命令:这一步隐含地告诉设备:数据已经到了,开始执行吧。
- ④ 再轮询:循环等待设备完成,最后可能读到表示成功或失败的错误码。
这个协议简单、能用,但有明显的低效之处:轮询浪费 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 的进程。
中断的价值在于让计算和 I/O 重叠(overlap)。原书用字符时间线说明("1""2"表示进程 1、2 在运行,"p"表示轮询,"c"表示拷贝数据):
在 ① 中,进程 1 发出磁盘读请求后,没有中断的系统只能自旋,反复轮询直到 I/O 完成(5 个 p)。在 ② 中,OS 在磁盘服务进程 1 的请求时运行进程 2;请求完成时中断到来,OS 唤醒进程 1 再运行它。中间这段时间 CPU 和磁盘都在干活。
中断并不总是更好
中断能让计算和 I/O 重叠,但只对慢设备真正划算。设备很快时,第一次轮询往往就发现它已完成;此时用中断反而拖慢系统:切换到别的进程、处理中断、再切回来,这些都很昂贵。
- 快设备 → 轮询;慢设备 → 中断。粗略地算一笔账:设等待时间为 Tdev,一次中断处理为 Tisr,一次上下文切换为 Tcs。轮询浪费 Tdev 的 CPU;中断浪费 Tisr + 2·Tcs,还会让发起者晚一点醒来。Tdev 明显大于后者时中断才划算。
- 速度未知或时快时慢?用两阶段(two-phased)混合方案:先轮询一小会儿,设备还没好再改用中断,兼得两者之长。
- 网络与活锁(livelock):海量数据包每个都触发中断时,OS 可能只顾处理中断,用户进程永远得不到运行、请求一个也服务不了。比如一个 Web 服务器突然登上 Hacker News 首页,流量暴涨。这时偶尔改用轮询更好,OS 能更好地控制节奏,让服务器先处理一些请求,再回头检查新到的包。
- 中断合并(coalescing):设备要发中断前先等一会儿,期间完成的其他请求就能合并到同一次中断里,降低中断处理开销。代价是请求的延迟增加——等太久就不划算,这是系统中常见的权衡。
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 干预:
要把数据送到设备,OS 只需告诉 DMA 引擎数据在内存的什么位置、拷多少、送到哪个设备,然后 OS 就可以去做别的事了。DMA 完成后,DMA 控制器发出中断,OS 就知道传输结束了。对比时间线 ④:拷贝(c)挪到了 DMA 那一行,CPU 在这段时间里运行进程 2,于是进程 2 得到了更多的 CPU 时间(8 个单位而不是 5 个)。
7模拟器:轮询、中断、PIO 与 DMA
I/O 时间线:CPU 在等设备时都干了什么?
同一组参数下,三种等待方式的对比
模型说明:时间以"格"为单位离散化;设备完成时刻若恰逢 CPU 正在上下文切换,中断要等切换结束才被处理;"两阶段混合"在设备(含 DMA 搬运)完成时间 ≤ 自旋上限时等同于轮询,否则先轮询满自旋上限再转为中断。原书示例假设切换与中断处理开销为 0。
8中断合并与活锁
上面的模拟器里只有一次 I/O。网络设备可不一样:包是源源不断到来的。每个包一次中断,每次中断都有固定开销(保存现场、进入 ISR、应答中断控制器、返回……),外加在中断上下文里做的协议处理。中断的优先级高于任何用户进程,于是到达率一高,CPU 就被中断"吃光"——这就是接收活锁(receive livelock):系统看起来 100% 忙碌,却一个请求也没服务完。
本模拟器的简化模型(到达率 λ 以"千包/秒"计,等价于"包/毫秒";时间单位 µs):
- 每次中断固定开销
h,每包协议处理c(在中断上下文,优先于用户态),用户进程服务每包需要u。 - 逐包中断:中断率 = λ;内核占用 = λ(h + c)/1000;剩给用户的 CPU = 1 − 内核占用;完成率 = min(λ, 剩余 CPU × 1000 / u)。在 λ = 1000/(h+c+u) 达到峰值,在 λ = 1000/(h+c) 降到 0。
- 中断合并(窗口 W):第一个包到达后设备再等 W 才发中断,期间到达的包一起处理。平均每次中断处理 1 + λW/1000 个包,中断率 = λ /(1 + λW/1000);额外延迟 ≈ (W + n·W/2)/(1+n),n = λW/1000。
- 轮询/NAPI 式混合:负载低时逐包中断;负载升高后关中断、由内核按预算轮询,超出处理能力的包直接在网卡丢弃(不进 CPU)。完成率 = min(λ, 1000/(c+u)),不会塌陷。
中断合并(coalescing)与接收活锁(livelock)
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 指令(explicit I/O instructions):最古老的方法(IBM 大型机用了很多年)。x86 上的
in和out指令就是例子:要把数据送给设备,调用者把数据放在某个寄存器里,并指定一个命名该设备的端口(port)。这些指令通常是特权指令——想象一下任何程序都能直接读写磁盘:完全失控,任何用户程序都能借此夺取整台机器。 - 内存映射 I/O(memory-mapped I/O):硬件让设备寄存器看起来就像内存单元。OS 对某个地址发出 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),所有设备交互的细节都封装在里面。
文件系统(更别提上面的应用)完全不关心用的是哪类磁盘,它只向通用块层发出块读写请求,通用块层把请求转给合适的设备驱动,由驱动处理发出具体请求的细节。图中还有一个raw 接口:让文件系统检查器(fsck,后面会讲)、磁盘碎片整理工具等特殊程序绕过文件抽象,直接读写块。大多数系统都提供这类接口来支持底层存储管理程序。
封装也有代价。如果某设备有很多特殊能力,却必须向内核其余部分呈现通用接口,这些能力就用不上了。Linux 中的 SCSI 设备就是一例:SCSI 有非常丰富的错误报告,但因为其他块设备(如 ATA/IDE)的错误处理简单得多,上层软件最终只能收到一个笼统的 EIO(generic I/O error),SCSI 提供的额外细节对文件系统来说全丢了。
因为你可能插进系统的每一种设备都需要驱动,驱动逐渐占据了内核代码的绝大部分:研究表明 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 Port | PIO 读写扇区数据 |
0x1F1 | 错误 Error | 状态中 ERROR=1 时查看 |
0x1F2 | 扇区数 Sector Count | 本次要读写几个扇区 |
0x1F3 | LBA 低字节 | 逻辑块地址(LBA)的各个字节 |
0x1F4 | LBA 中字节 | |
0x1F5 | LBA 高字节 | |
0x1F6 | 1B1D TOP4LBA | B = LBA 模式,D = 驱动器号(主 0x00 / 从 0x10),低 4 位是 LBA 最高 4 位 |
0x1F7 | 命令 / 状态 | 写 = 命令(READ/WRITE),读 = 状态 |
状态寄存器(0x1F7)的 8 个位(7 → 0):
错误寄存器(0x1F1),ERROR = 1 时检查(7 → 0):
BBK 坏块;UNC 不可纠正的数据错误;MC 介质已更换;IDNF 找不到 ID 标记;MCR 请求更换介质;ABRT 命令中止;T0NF 找不到 0 号磁道;AMNF 找不到地址标记。(图 36.5 IDE 接口)
假设设备已初始化,与它交互的基本协议是:
- 等驱动器就绪:读状态寄存器(0x1F7),直到 READY 且不 BUSY。
- 把参数写入命令寄存器:扇区数、要访问扇区的 LBA、驱动器号(IDE 只允许两个驱动器:主 0x00、从 0x10),写到 0x1F2–0x1F6。
- 启动 I/O:把 READ 或 WRITE 命令写入 0x1F7。
- 数据传输(写):等状态为 READY 且 DRQ(驱动器请求数据),把数据写进数据端口。
- 处理中断:最简单时每传一个扇区处理一次中断;复杂些的做法可以批量,整个传输结束才发一次中断。
- 错误处理:每次操作后读状态寄存器;若 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);
}
ide_wait_ready():就是标准协议里的轮询循环。注意驱动在"发命令前"这种很短的等待上仍用轮询——磁盘此时通常马上就绪,用中断反而不划算。ide_start_request():用outb依次写 0x3F6(开中断)、0x1F2(扇区数)、0x1F3–0x1F6(LBA 与驱动器号),最后写 0x1F7 启动。写请求紧接着用outsl以 PIO 方式送出 512 字节。ide_rw():把请求挂到队尾;若队列原本为空就立即发给磁盘;然后调用进程睡眠,直到请求完成(B_VALID 置位且不再 DIRTY)——这就是"中断 + 睡眠"而非轮询等待。ide_intr():中断处理程序。读请求时用insl从数据端口取回数据(仍是 PIO),标记完成,wakeup唤醒等待进程;若队列里还有请求,立刻发出下一个,让磁盘不闲着。
12历史注记
中断是一个非常古老的想法,最早的机器上就有:20 世纪 50 年代初的 UNIVAC 已有某种形式的中断向量。至于谁最先引入 DMA 还有争论:Knuth 等人认为是 DYSEAC(一台"移动"计算机——当时的意思是能装在拖车里拉走),也有人认为是 IBM SAGE。无论如何,到 50 年代中期,已经存在能与内存直接通信、完成后中断 CPU 的 I/O 设备系统。这些想法其实相当"显然":CPU 快、设备慢,让 CPU 在慢 I/O 进行时去做别的事,是那个年代的工程师自然会想到的。
13先预测,再验证
看答案
看答案
看答案
看答案
看答案
看答案
14动手实验:轮询 vs 阻塞等待
用户态程序摸不到真实的设备寄存器,但我们可以用一个"慢设备"来模仿:子进程睡眠一段时间后往管道写 1 个字节(相当于设备完成)。父进程用三种方式等待它:
poll:把管道设成非阻塞,反复read()直到读到数据——就是"读状态寄存器,BUSY 就再读"的轮询;block:阻塞read(),进程睡眠,数据到了由内核唤醒——相当于中断驱动;hybrid:先自旋spin_us微秒,还没好就改为阻塞——两阶段混合。
程序用 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自测题
ide_rw() 发出请求后调用进程如何等待完成?16常见误区与本章小结
- "中断总比轮询好。"不对。中断只对慢设备划算;快设备用中断会因切换和中断处理开销变得更慢;海量中断还可能引发活锁。
- "中断/DMA 让 I/O 本身更快。"它们缩短的是 CPU 的浪费,让别的进程有活干;单个 I/O 的延迟不变甚至略增(多了切换开销)。
- "PIO 就是轮询。"两者是不同维度:轮询/中断说的是怎样得知设备完成,PIO/DMA 说的是谁来搬数据。xv6 驱动就是"中断 + PIO"的组合。
- "内存映射 I/O 意味着设备数据存放在内存里。"不是。只是设备的寄存器占用了一段物理地址,load/store 会被硬件路由到设备。
- "操作系统几百万行代码,大部分是调度和内存管理。"其实七成以上是设备驱动,而且驱动是 bug 和崩溃的主要来源。
本章要点
- 总线按"越快越短越贵"分层;现代系统用芯片组与点对点互连(DMI、PCIe、eSATA、USB)。
- 标准设备 = 接口(状态/命令/数据寄存器)+ 内部实现;标准协议 = 轮询 → 写数据(PIO)→ 写命令 → 轮询。
- 两种提升效率的技术:中断(计算与 I/O 重叠;快设备用轮询,未知时两阶段;合并;警惕活锁)和 DMA(把数据搬运从 CPU 卸载)。
- 两种访问设备寄存器的方法:显式 I/O 指令(特权的 in/out)和内存映射 I/O。
- 设备驱动封装设备细节,使 OS 其余部分设备无关;代价是特殊能力丢失(EIO),而且驱动代码量巨大、bug 多。
承上启下:本章讲的是"OS 怎样和任何设备打交道"。下一章(第 37 章)深入最重要的持久化设备——硬盘:它的接口(扇区数组)、几何结构(盘片、磁道、磁头)、I/O 时间 = 寻道 + 旋转 + 传输,以及磁盘调度。此后的 RAID、文件系统都建立在这两章的模型之上。