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、内存、设备的一组通信通道。
可以传:
- 地址。
- 数据。
- 控制信号。
简化:
总线是共享资源。多个设备同时传输时,需要仲裁。
总线性能关注:
| 指标 | 含义 |
|---|---|
| 带宽 | 单位时间能传多少数据 |
| 延迟 | 发起传输到响应的等待 |
| 仲裁 | 多个设备争用时谁先用 |
| 协议 | 地址、数据、控制信号如何组织 |
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. 学完本章你能解决什么问题
学完这一章,你应该能解决或开始分析这些问题:
- 外设为什么需要设备控制器?
- 总线是什么,为什么它可能成为瓶颈?
- CPU 如何通过设备寄存器控制设备?
- 轮询为什么简单但浪费 CPU?
- 中断如何让设备主动通知 CPU?
- DMA 为什么能减少 CPU 搬运数据的负担?
- IO 慢可能慢在哪些层?
- 为什么缓冲区满会造成阻塞、丢弃或延迟升高?
- 一个网络包从网卡到程序大致经过哪些硬件和系统层?
IO 的本质是数据在 CPU、内存和设备之间移动。理解这条路径,很多网络、文件、系统性能问题都会变得可解释。