Skip to content

第七章:设备 I/O

CPU、网络设备和存储设备的服务时间、并行度与到达模式差异很大。若 CPU 在每次请求提交后同步等待设备完成,等待期间无法执行其他可运行任务。

I/O 子系统因此将请求提交、设备执行和完成处理分离:CPU 构造请求,设备异步执行,双方通过共享队列和完成通知协调。

xv6 设备路径

xv6 运行在 QEMU virt 机器上,已经有一条完整异步设备路径。以磁盘读取为例:

text
bread() 缓存未命中
  → virtio_disk_rw()
  → 填 descriptor chain
  → 写 available ring
  → MMIO notify
  → sleep(buffer)
  → virtio device 执行 I/O
  → PLIC 外部中断
  → devintr()
  → virtio_disk_intr()
  → 标记完成并 wakeup(buffer)

主线代码只有 virtio_disk.cplic.ctrap.c 几个文件。CPU 提交后睡眠,设备独立工作,中断再唤醒原进程;“异步设备、同步 read”在这条路径中同时成立。

MMIO

现代设备通常向 CPU 暴露寄存器和内存中的队列。驱动通过 MMIO(memory-mapped I/O)访问控制寄存器:地址看似普通内存,实际读写会到达设备。

在 xv6 中,VIRTIO0 是 QEMU virt machine 规定的 MMIO 基址。virtio_disk.cR(offset) 宏把 VIRTIO0 + offset 转成 volatile uint32 *,初始化代码直接读取 magic、version、device id 和 feature registers。提交请求后,驱动写 VIRTIO_MMIO_QUEUE_NOTIFY 通知设备。

PCIe 扩展

以 PCIe 设备为例,固件和内核枚举 BAR(base address register),把某段物理地址窗口分配给设备。Linux 驱动用 pci_iomap()ioremap() 得到内核虚拟地址,再通过 readl()/writel() 等 accessor 访问。普通 C 指针解引用不能表达设备访问所需的宽度、字节序与顺序保证。

MMIO 不能随意被编译器删除、缓存或重排。驱动必须使用体系结构提供的访问原语和内存屏障,保证“先填描述符,再敲 doorbell”的顺序。普通并发已经需要内存模型,和设备并发还多了总线、IOMMU 与设备自身缓存。

只有内核或被严格授权的进程能映射设备寄存器,否则一个程序就能重置设备、窃取其他请求或让 DMA 越界。

数据传输

早期或简单设备可由 CPU 逐字节搬数据,称为 programmed I/O。大数据传输通常使用 DMA:驱动准备内存缓冲区和描述符,告诉设备地址与长度,设备直接在内存和硬件之间搬运。

text
CPU:准备 buffer 与 descriptor
  │  写 doorbell

设备:读取 descriptor ──DMA──> 读写内存

  └─ 写 completion / 发中断

DMA 不提供固有的内存隔离。若设备可以使用任意物理地址,错误驱动或受攻击设备可能覆盖内核内存。IOMMU 为设备实施地址翻译和访问权限,只允许其访问已授权页面;相应成本包括映射维护、IOTLB 和失效操作。

DMA 还要求相关页面在操作期间不能被回收或移动,并需要处理 CPU cache 与设备之间的一致性。设备直接访问用户内存可以减少复制,但会扩大页面生命周期协议的参与范围。

IOMMU 扩展

驱动也不应把 CPU 看到的物理地址直接塞给设备。Linux 的 DMA API 通过 dma_alloc_coherent()dma_map_*() 返回设备可使用的 dma_addr_t;底层可能建立 IOMMU 映射,也可能执行 cache 同步。dma_wmb() 保证 descriptor 内容先于 ownership bit 或 doorbell 对设备可见,解决的是 CPU—设备顺序,不等同于线程间的 smp_wmb()

完成通知

低事件率时,中断允许 CPU 在设备工作期间执行其他线程或进入空闲状态。高事件率时,每个数据包触发一次中断会产生大量进入/退出和 cache 扰动,此时轮询与批处理通常具有更低的单位事件成本。

xv6 的 PLIC 路径采用每次完成中断。devintr()plic_claim() 取得 IRQ,调用 virtio_disk_intr()uartintr(),最后 plic_complete(irq) 允许同一设备再次中断。驱动在 completion ring 中找到已完成 descriptor,清除 buffer 的 disk 标记并 wakeup()

NAPI 扩展

现代高速网络常采用混合策略:收到中断后暂时屏蔽同队列中断,内核在给定预算内轮询多个包;流量下降后重新启用中断。该策略在低负载下保持较低通知延迟,在高负载下通过批处理降低每包固定成本。

Linux 将这套机制明确实现为 NAPI。网卡中断 handler 通常只屏蔽或确认中断并调用 napi_schedule();随后网络软中断或 NAPI 线程调用驱动的 poll(napi, budget)。若一次处理少于 budget,说明队列已排空,驱动执行 napi_complete_done() 并重新启用中断。这里没有含糊的“内核选择轮询”:选择就落在这些函数和 budget 返回值上。

中断合并也让设备积累多个完成再通知,减少开销但增加单项等待。几乎所有 I/O 优化都在吞吐和尾延迟间做交换。

设备驱动

上层希望看到稳定接口,例如网络包或块请求;设备却有厂商寄存器、队列格式、复位流程和固件 bug。驱动负责翻译并管理:

  • 探测和初始化设备;
  • 分配 DMA 队列与中断;
  • 提交、取消和完成请求;
  • 处理超时、热拔插、复位和电源状态;
  • 向上层报告能力与错误。

驱动通常具有较高权限,同时需要解析不完全可信的设备输入,因此构成重要的内核攻击面。将驱动移至用户态、使用 IOMMU 隔离或采用内存安全语言,可以缩小故障范围,但会增加 IPC、性能和兼容性成本。

Virtio

完整模拟真实网卡能够兼容既有 guest 驱动,但也需要实现设备的历史接口和复杂状态机。virtio 采用半虚拟化接口:guest 使用专用驱动,双方通过标准共享队列交换描述符。

在 xv6 中,一次磁盘请求使用三个 descriptor:request header、data buffer、status byte。driver 把 descriptor head 放入 available ring,device 完成后把 head 放入 used ring。info[head].b 保存等待的 buffer,因而中断处理程序能从完成项找到应唤醒的进程。阅读 virtio_disk_rw()virtio_disk_intr() 就能看到 ring 的两端。

这能减少陷入 hypervisor 的次数,并允许批处理和 zero-copy。virtio 的思想不限于 VM:只要两侧处于不同保护域,共享 ring 加通知都是常见的高性能接口。

共享 ring 仍需定义保护边界:生产者和消费者索引需要正确的内存顺序,描述符必须经过验证,页面所有权必须明确,通知丢失时仍需保证系统能够继续推进。

Virtio 规范把 split virtqueue 具体分成 descriptor table、available ring 和 used ring。driver 填 descriptor,把 head index 写入 avail ring 后通知 device;device 完成后把结果写入 used ring。两端借助 index 判断新条目,并用 suppress notification/notification data 减少通知。可直接对照 Virtio 1.3 specification 的 virtqueue 章节。

Linux I/O 编程模型

阻塞式 read() 看似同步,底层设备仍是异步的。内核提交请求后让线程睡眠,完成中断再唤醒。同步只是调用者的编程模型,不是设备的工作方式。

这正是 xv6 bread() 的语义:调用者阻塞在 buffer 上,但 virtio device 并未同步执行在该进程的 CPU 指令流中。

io_uring

非阻塞 FD 在暂时无法推进时返回 EAGAINselectpollepoll 允许线程等待多个 FD 的 readiness。readiness 表示相应操作当前可能取得进展,不表示指定操作已经完成;多个消费者并发操作时,条件还可能在调用前发生变化。

completion 模型直接报告已提交操作的结果。io_uring 通过用户态与内核共享的提交环和完成环批量交换请求,减少系统调用和部分复制,并可表达操作依赖。其接口同时增加了固定页面、凭证传播、取消和状态机验证的复杂度。

这里的“共享环”也有具体对象:用户写 SQE,用 SQ tail 发布;内核消费后推进 SQ head;完成时内核写 CQE,再推进 CQ tail。io_uring_enter() 负责显式提交或等待,注册固定文件和缓冲区则通过 io_uring_register()。Linux 实现在 io_uring/,用户可见布局定义在 include/uapi/linux/io_uring.h

Linux 网络路径

当前 xv6 没有网络协议栈。下面从已经建立的“DMA queue—中断—唤醒”设备模型扩展到 Linux 网络路径。

curl 接收数据时,一条简化路径是:

text
网卡收到 frame
  → DMA 到接收队列 buffer
  → 中断提示 / 内核轮询
  → 驱动构造 packet 元数据
  → Ethernet / IP / TCP 处理
  → 放入 socket 接收队列
  → 唤醒等待 socket 的线程
  → read() 复制或引用数据到用户空间

链路层处理本地帧,IP 提供跨网络寻址,TCP 实现重组、重传和字节流语义,socket 将协议状态关联到进程持有的 FD。

高速路径使用 checksum、GSO、GRO 等卸载与聚合机制降低每包固定成本。XDP/eBPF 可在网络栈早期处理或丢弃数据包,避免无效流量继续经过完整协议栈。

背压

若应用产生请求的速率长期高于设备完成速率,无界队列会持续增加内存占用和等待时间。系统必须在某一层拒绝、阻塞、丢弃请求或降低生产速率。

TCP 接收窗口、socket buffer、块设备队列深度和 io_uring ring 大小都表示有界容量。较深队列可以提高设备利用率,但也会增加排队时间。根据 Little's Law,在吞吐率固定时,系统中的平均在途请求数与平均停留时间成正比。

背压需要跨层传播。若请求仅在底层持续排队,上层无法及时观察容量不足,最终会同时增加内存占用和尾延迟。

请求取消

异步操作提交后,调用者可能超时或退出。业务层不再需要结果,并不表示设备已经停止执行或 DMA。取消协议必须区分:

  • 请求尚未提交,可以直接移除;
  • 设备已接收,可能支持撤销;
  • 无法撤销,只能忽略最终结果;
  • 完成与取消并发,需要唯一决定谁释放资源。

缓冲区的生命周期必须持续到设备确定不再访问为止。将业务超时错误地视为物理操作终止,会导致 use-after-free 或重复完成。

实验

先在 xv6 中给 virtio_disk_rw()sleep()devintr()virtio_disk_intr()wakeup() 设置断点,完成一次未命中 buffer cache 的 bread()。记录 descriptor 状态、avail/used index 和进程状态。下面的 Linux 工具用于观察 PCIe、网络栈和多队列设备。

bash
# 查看中断如何分布在 CPU 上
cat /proc/interrupts

# 查看网卡队列与统计(接口名按机器调整)
ip -s link
ethtool -l eth0

# 观察网络系统调用和等待
strace -f -e trace=network,epoll_wait,poll curl -o /dev/null https://example.com

可使用 ss -tin 观察 TCP 窗口、拥塞状态和队列,从而将应用的 read() 与端到端流量控制状态对应起来。

练习

  1. 从 xv6 bread() 到进程被唤醒,CPU、virtio 设备和 PLIC 各完成了哪些步骤?
  2. xv6 的一次块请求为什么使用三个 virtqueue descriptor?
  3. MMIO 写寄存器、填写 DMA 队列和处理中断分别需要怎样的顺序约束?
  4. 高速网络为何从逐包中断转向 NAPI?这改变了完成通知的哪一部分?
  5. 请求超时后,为什么必须确认设备不再 DMA 才能释放缓冲区?

上一章:共享内存并发 · 下一章:存储数据路径 →