bus - io

08. 总线、外设与 IO

0. 本章先解决什么问题

CPU 和内存不是计算机的全部。真实程序还要读键盘、写磁盘、收网络包、显示图像、访问 USB 设备。

这些都属于 IO。

本章要解决:

  • 外设如何和 CPU、内存交换数据?
  • 总线是什么,为什么它会成为共享通道?
  • 设备控制器和寄存器有什么作用?
  • 内存映射 IO 和端口 IO 的直觉是什么?
  • 轮询、中断、DMA 各解决什么问题?
  • 为什么 IO 慢常常不是 CPU 算得慢?

总线与设备控制器路径

这张图把 IO 路径展开:CPU 通过总线读写设备控制器的寄存器,设备控制器把真实设备细节适配成系统可操作的接口,大块数据通常经 DMA 在设备和内存之间移动,完成后再用中断通知 CPU。

这张图怎么读

读 IO 图时,先区分控制路径和数据路径:

路径经过什么作用
控制路径CPU -> 总线 -> 设备寄存器发命令、查状态、配置地址和长度
数据路径设备 <-> 控制器/DMA <-> 内存搬运真正的数据
通知路径设备 -> 中断 -> CPU告诉 CPU 某个事件完成或出错

很多 IO 问题就是把这三条路径混在一起:命令发出不代表数据完成,数据完成不代表应用已经读取,设备通知太频繁也会拖慢 CPU。

1. IO 的本质:和外部世界交换数据

输入输出包括:

类型例子
输入键盘、鼠标、传感器、网卡收包
输出显示器、打印机、网卡发包
存储SSD、磁盘、U 盘
通信网卡、蓝牙、串口

IO 的核心是:

设备产生或消费数据
CPU 和内存要与设备交换这些数据

但设备速度、数据格式、控制方式都不同,所以不能让 CPU 直接裸连所有设备。

2. 设备控制器:设备和系统之间的适配器

设备通常通过控制器接入系统。

设备通过总线和控制器接入系统

控制器负责:

  • 暴露控制寄存器。
  • 暴露状态寄存器。
  • 暴露数据缓冲区。
  • 把设备细节转换成系统可操作的接口。
  • 在设备和内存之间搬数据。

CPU 不需要知道磁盘内部如何旋转、网卡如何调制信号。CPU 只需要通过控制器读写寄存器和缓冲区。

3. 总线:共享通信通道

总线是连接 CPU、内存、设备的一组通信通道。

可以传:

  • 地址。
  • 数据。
  • 控制信号。

简化:

总线挂着 CPU、内存和各设备控制器

总线是共享资源。多个设备同时传输时,需要仲裁。

总线性能关注:

指标含义
带宽单位时间能传多少数据
延迟发起传输到响应的等待
仲裁多个设备争用时谁先用
协议地址、数据、控制信号如何组织

3.1 总线争用:不是所有设备都能同时畅通

总线是共享资源时,多方同时使用就会争用。

CPU 访问内存
网卡 DMA 写内存
磁盘 DMA 读内存
显示设备读取帧缓冲

这些活动都可能给内存和互连带来压力。于是 IO 性能不只取决于设备本身,还取决于数据能不能顺利穿过总线/互连到达内存。

现象可能解释
单设备测试很快,多设备同时慢共享带宽被争用
CPU 不忙但 IO 延迟上升请求在控制器或总线上排队
大量 DMA 时计算也变慢内存带宽被设备流量占用

这就是为什么“设备标称带宽”不等于系统实际吞吐。系统里还有共享路径和仲裁成本。

手推:共享总线下,两个快设备也会互相拖慢

假设一条共享路径的有效带宽是:

8 GB/s

现在有两个设备同时搬数据:

设备 A 期望 5 GB/s
设备 B 期望 5 GB/s

从单设备视角看,它们都没有超过 8 GB/s;但从共享路径视角看:

总需求 = 5 + 5 = 10 GB/s
共享路径能力 = 8 GB/s
缺口 = 2 GB/s

缺口不会凭空消失,它会表现为:

排队时间增加
单个请求完成变慢
设备吞吐被压低
CPU 等待 IO 完成

如果再加上 CPU 自己访问内存:

CPU 需要 4 GB/s 内存带宽
设备 A 需要 5 GB/s
设备 B 需要 5 GB/s
总需求 = 14 GB/s

这时即使 CPU 指令本身不复杂,也可能因为内存和互连压力变慢。排查时要把“设备速度”“总线/互连速度”“内存带宽”分开看。

4. 设备寄存器:CPU 如何控制设备

设备控制器通常暴露一些寄存器。

寄存器作用
控制寄存器CPU 写入命令,比如开始读写
状态寄存器CPU 读取设备状态,比如是否完成
数据寄存器读写少量数据
地址/长度寄存器指定内存地址和传输长度

例如一个简化读设备流程:

CPU 写入目标地址
CPU 写入读取长度
CPU 写入开始命令
设备控制器开始工作
CPU 等待完成通知

4.1 设备寄存器不是普通内存

内存映射 IO 看起来像读写地址,但语义和普通内存不同:

普通内存设备寄存器
读写只是保存数据读写可能触发设备动作
多次读取通常结果稳定状态寄存器可能每次都变
写入后只是内存内容变化写控制寄存器可能启动 IO
可做普通缓存优化通常需要特殊顺序和不可缓存语义

例如读取状态寄存器可能清除某个 pending 标志;写入命令寄存器可能立即启动设备。驱动程序访问设备寄存器时必须非常重视顺序、屏障和副作用。

边界条件:优化器和 CPU 不能把设备访问当普通变量重排

普通内存读写在不改变单线程语义时,编译器和 CPU 可能重排、合并或缓存访问。但设备寄存器有副作用,顺序就是语义的一部分。

例如一个设备启动流程:

写 DMA 地址寄存器
写长度寄存器
写命令寄存器: START

如果这些写入被重排成:

写命令寄存器: START
写 DMA 地址寄存器
写长度寄存器

设备可能在地址和长度尚未配置好时启动,结果就是读写错误内存、传输长度异常或设备报错。

设备访问需要特殊约束:

约束目的
不随便缓存设备寄存器每次读取都要看到真实设备状态
不合并有副作用的读写读状态可能清标志,写命令可能启动动作
保持关键写入顺序先配置参数,再启动设备
必要时使用内存屏障确保 CPU、设备、内存看到一致顺序

这就是底层 IO 难的地方:同样是“读写一个地址”,普通内存和设备寄存器的语义完全不同。

反例:posted write 让“写完寄存器”不等于“设备已经看见”

为了提升性能,某些总线或互连可能允许写操作先进入缓冲,再异步送到设备。这类写入可以理解成:

CPU 执行写寄存器指令
写入进入中间缓冲
CPU 继续向下执行
稍后设备才真正看到写入

如果驱动写完命令寄存器后立刻读普通内存里的完成标志,可能会误以为设备没有启动,或者在设备尚未看见完整配置时继续修改缓冲区。

危险流程:

写 DMA 地址
写 DMA 长度
写 START
马上释放或复用 buffer
设备稍后才读取地址和长度

结果可能是设备读到被复用的数据,或者写入已经不属于本次 IO 的内存区域。

因此设备访问常需要顺序约束:

场景要确认的边界
启动 DMA 前描述符和数据 buffer 对设备可见
通知设备后命令写入已经越过 posted buffer
读取完成状态前状态寄存器读取得到的是设备新状态
复用 buffer 前设备已经不再读写这块内存

学习阶段不必记具体平台指令,但要抓住原则:设备、CPU、内存之间的可见顺序本身就是 correctness 的一部分。

5. 内存映射 IO 和端口 IO

CPU 访问设备寄存器有不同方式。

内存映射 IO:

把设备寄存器映射到某些地址
CPU 像读写内存一样读写这些地址

端口 IO:

CPU 使用专门 IO 指令访问设备端口

学习阶段抓住核心:

设备寄存器是 CPU 控制设备的入口。
读写这些寄存器会产生设备行为。

这和普通内存不同。普通内存读写只是存取数据,设备寄存器读写可能触发真实外设动作。

5.1 驱动程序是硬件和 OS 的翻译层

应用程序通常不直接读写设备寄存器。中间有驱动程序:

应用
-> 系统调用
-> 操作系统 IO 子系统
-> 设备驱动
-> 设备寄存器 / DMA / 中断

驱动程序负责把通用操作翻译成设备命令:

通用需求驱动要做什么
读一段数据配置地址、长度、方向,启动设备
写一段数据准备缓冲区,通知设备发送
查询状态读取状态寄存器,解释设备错误
处理完成响应中断,唤醒等待的任务
错误恢复重置设备、重试、上报错误

这就是为什么 IO 问题经常跨越应用、OS、驱动和硬件多层。

6. 轮询:CPU 一直问设备好了没

最简单的方式是轮询:

CPU 发出命令
while 设备没完成:
读取状态寄存器
继续处理

优点:

  • 简单。
  • 控制直接。
  • 适合非常短、非常频繁的小等待。

缺点:

  • CPU 浪费时间。
  • 设备慢时效率很差。
  • 多设备时更不合理。

轮询像一直盯着水壶问“开了吗”。如果等待时间很长,这很浪费。

6.1 轮询不是永远差

轮询的代价取决于等待时间和事件频率。

场景轮询是否合适
事件很快就会完成轮询可能比中断切换更便宜
事件很少或很慢轮询浪费 CPU
极高包量网络纯中断太频繁时,批量轮询可能更稳定
低功耗系统长时间轮询不合适

真实系统常用混合策略:

低负载用中断,避免空转。
高负载切到批量轮询,减少中断风暴。

7. 中断:设备完成后通知 CPU

中断的思想是:

CPU 发出命令后先去做别的事。
设备完成后通知 CPU。
CPU 暂停当前工作,处理设备事件。

流程:

CPU 启动 IO
CPU 执行其他任务
设备完成
设备发出中断
CPU 保存现场
进入中断处理程序
处理中断
恢复现场
继续原任务

中断减少了 CPU 忙等,但也有成本:

  • 保存和恢复现场。
  • 切换执行路径。
  • 中断过多会打扰 CPU 正常执行。

7.1 中断处理要短而快

中断处理程序运行时,会打断普通执行流。它不能做太多慢工作。

常见设计是把处理拆成两段:

快速响应中断:
读取必要状态
确认/清除中断
把后续工作放入队列

稍后批量处理:
解析数据
唤醒等待者
继续协议栈或驱动逻辑

这样能降低中断延迟、及时确认设备状态,也避免普通任务被长时间打断。

8. DMA:大块数据不让 CPU 一个字节一个字节搬

如果磁盘或网卡要传输大块数据,让 CPU 逐字节搬运非常浪费。

DMA 的思想是:

CPU 告诉 DMA 控制器:
从哪里读
写到哪里
搬多少

DMA 控制器负责搬运
完成后用中断通知 CPU

简化:

设备 <-> DMA <-> 内存
|
CPU 只负责配置和收尾

DMA 能降低 CPU 占用,提高大块 IO 效率。

8.1 DMA 需要缓冲区管理

DMA 不是“设备随便写内存”。系统必须准备可用的缓冲区:

分配缓冲区
告诉设备可 DMA 的地址
设置长度和方向
确保 CPU/设备看到一致的数据
完成后回收或交给上层

这里有几个风险:

风险解释
缓冲区太少设备来不及接收,丢包或阻塞
缓冲区太多内存被占用,延迟变高
地址不可 DMA设备不能访问普通虚拟地址
生命周期错误设备还在写,CPU 已经复用缓冲区
Cache 不一致CPU 和设备看到的内容不同

高性能 IO 经常用 buffer ring / descriptor queue 这类结构,让 CPU 和设备通过描述符协作。

9. IO 为什么慢

IO 慢可能来自多个地方:

原因说明
设备本身慢磁盘、网络、外设速度有限
总线带宽有限多设备共享通道
等待延迟请求发出到设备响应需要时间
小 IO 太多每次都有固定开销
数据复制用户态、内核态、设备缓冲区之间复制
中断过多CPU 频繁被打断
队列堆积设备处理不过来

所以 IO 优化常见方向:

  • 批量。
  • 缓冲。
  • 异步。
  • 减少复制。
  • 顺序化。
  • 使用 DMA。
  • 降低中断频率。
  • 使用队列和背压。

9.1 小 IO 的固定成本很贵

一次 IO 往往有固定成本:

系统调用
参数检查
队列提交
设备调度
中断或完成通知
状态更新

如果每次只传很少数据,固定成本会占很大比例。

所以 IO 优化常见地把很多小操作合并:

方法作用
批量读写多份数据共享一次提交成本
缓冲写先积累,再集中写出
顺序化减少随机寻址和调度成本
异步提交提交后不阻塞等待
completion queue批量收完成事件

这和成本模型一致:减少每条数据都重复支付的边界成本。

手推:同样 1 MB,拆成小 IO 会把固定成本放大

假设一次 IO 的固定成本包括系统调用、队列提交、设备调度和完成通知,粗略记为:

fixed cost = 50 us
传输带宽 = 500 MB/s

如果一次读 1 MB,传输时间约为:

1 MB / 500 MB/s = 0.002 s = 2000 us
总时间约 50 + 2000 = 2050 us

固定成本占比很小。

如果拆成 1024 次,每次读 1 KB:

每次传输时间 = 1 KB / 500 MB/s ≈ 2 us
每次总时间约 50 + 2 = 52 us
1024 次总时间约 53248 us

同样读 1 MB,小 IO 可能比大 IO 多花几十倍时间。差距主要来自:

固定成本重复支付了 1024 次

所以看到“吞吐上不去”时,要问:

请求大小是多少?
是否可以合并?
是否在随机位置反复小读写?
是否被上层抽象拆碎了?

IO 优化经常不是让设备更快,而是减少无意义的提交次数、等待次数和切换次数。

10. 设备速度差异和缓冲

CPU、内存、设备速度不同。缓冲区用于吸收速度差。

快速生产者 -> buffer -> 慢速消费者

例如:

  • 网卡接收数据先放入缓冲。
  • 磁盘写入先进入队列。
  • 键盘输入先进入缓冲区。
  • 显示输出使用帧缓冲。

缓冲能平滑短期波动,但如果长期生产速度大于消费速度:

buffer 迟早会满

满了之后就会出现:

  • 阻塞。
  • 丢弃。
  • 延迟升高。
  • 背压。
  • 错误返回。

10.1 缓冲区满是信号,不只是异常

缓冲区满说明:

上游生产速度 > 下游消费速度

处理方式取决于数据语义:

数据类型可选策略
文件写入阻塞、异步等待、返回错误
网络包排队、丢弃、限速、背压
视频帧丢旧帧或降采样
日志批量、降级、丢低优先级
控制命令通常不能随便丢,要限流或拒绝

不要把所有 buffer full 都简单扩大。大缓冲会增加排队延迟,也可能掩盖下游处理能力不足。

11. 联系实际:一个网络包从网卡到程序

简化路径:

网卡收到数据
-> 网卡缓冲区
-> DMA 搬到内存
-> 网卡发中断
-> 操作系统网络栈处理
-> 放入 socket 缓冲区
-> 程序 read/recv 读取

这里涉及:

  • 设备控制器。
  • DMA。
  • 中断。
  • 内核缓冲区。
  • 用户态读取。
  • 数据复制或零拷贝优化。

所以网络慢可能不是“网络线慢”这么简单,也可能是:

  • 中断太多。
  • 内核处理慢。
  • socket buffer 满。
  • 应用读取慢。
  • 内存复制多。
  • CPU 被协议处理占用。

排障卡:一个 IO 慢,先把路径拆开

遇到“磁盘慢”“网络慢”“设备慢”时,不要把设备当成黑盒。沿着路径问:

环节要问什么可能现象
应用发起是否大量小 IO、频繁系统调用吞吐低、CPU 花在边界成本
内核缓冲buffer 是否满、队列是否堆积阻塞、丢弃、延迟上升
总线/控制器多设备是否争用、控制器是否忙带宽受限、响应抖动
设备本身设备服务时间是否高磁盘随机读慢、网卡收包压力
DMA/复制数据是否多次复制内存带宽和 CPU 消耗升高
中断中断是否过多或合并不合理system CPU 高、中断风暴

一个合格结论应该像:

不是“网卡慢”,而是“包量升高后中断和内核协议栈处理占用上升,应用读取速度跟不上,socket buffer 堆积导致尾延迟升高”。

这类结论能直接引导到批量、缓冲、背压、中断合并、减少复制或提高消费速度,而不是盲目换设备。

问题:为什么高负载下 system CPU 很高?

学完本章,可以分析这种现象:

业务代码没有明显变复杂,
但请求量上来后,CPU 大量花在 system/kernel,
用户态业务 CPU 反而不是最高。

可能路径:

假设证据
系统调用太多每个请求做很多小 read/write
中断太多中断计数随包量暴涨
协议栈处理重包量高、内核网络栈耗时高
数据复制多内存带宽高,copy 相关热点明显
socket buffer 堆积应用消费慢,队列增长

改进方向不是“优化业务 if/else”,而是批量、减少系统调用、调整缓冲、开启合适的中断合并、使用更少复制的数据路径,或让应用更快消费。

12. 学完本章你能解决什么问题

学完这一章,你应该能解决或开始分析这些问题:

  1. 外设为什么需要设备控制器?
  2. 总线是什么,为什么它可能成为瓶颈?
  3. CPU 如何通过设备寄存器控制设备?
  4. 轮询为什么简单但浪费 CPU?
  5. 中断如何让设备主动通知 CPU?
  6. DMA 为什么能减少 CPU 搬运数据的负担?
  7. IO 慢可能慢在哪些层?
  8. 为什么缓冲区满会造成阻塞、丢弃或延迟升高?
  9. 一个网络包从网卡到程序大致经过哪些硬件和系统层?

IO 的本质是数据在 CPU、内存和设备之间移动。理解这条路径,很多网络、文件、系统性能问题都会变得可解释。

延伸阅读