计算机输入输出设备
# 05.计算机输入输出设备
# 目录介绍
- 01.工作案例引入
- 02.I/O设备概述
- 03.I/O设备接口
- 04.I/O控制方式
- 05.中断系统设计
- 06.DMA深入原理
- 07.常见I/O设备原理
- 08.I/O设备与编程
- 09.综合案例Nginx文件
- 10.思考题与作业
# 01.工作案例引入
# 1.1 CPU闲传不动文件
场景:小赵维护一个内部文件下载服务,用的是 Java FileInputStream + OutputStream 手写的传输逻辑。某天业务反馈:下载 500MB 的大文件平均要 40 秒,机器明明是千兆网(理论上 5 秒就够)。
小赵上线观察:
%CPU : 12% ← 很闲
%MEM : 30% ← 够用
网络出口: 120Mb/s ← 居然没跑满!千兆网应该能 900Mb+
磁盘读 : 180MB/s ← SSD 没问题
2
3
4
最诡异的是:所有组件都不是瓶颈,但速度就是上不去。代码看着也没毛病:
byte[] buf = new byte[4096];
while ((n = fis.read(buf)) > 0) {
sos.write(buf, 0, n); // 标准的"读一段写一段"
}
2
3
4
资深同事看了一眼说:换成 FileChannel.transferTo() 试试。小赵改了一行,下载时间从 40 秒降到 5 秒。CPU 占用率从 12% 降到 3%。
# 1.2 追问清单
小赵这次踩的坑,涉及本章所有知识:
- "为什么 CPU 不忙但传输慢?" → 因为瓶颈在 I/O 的上下文切换和数据拷贝,不在计算
- "
read + write到底慢在哪里?" → 数据要在"磁盘→内核→用户→内核→网卡"跳 4 次拷贝 + 4 次上下文切换 - "
transferTo为什么快?" → 底层是 sendfile 系统调用,走的是"磁盘→内核→网卡"的 2 次 DMA 拷贝,CPU 零参与 - "为什么 CPU 越少参与反而越快?" → 因为 DMA 是硬件级别的并行,CPU 在此期间可以干别的
- "这跟中断、DMA、零拷贝这些原理有什么关系?" → 这是三者配合的经典演出
本章就是带你把这条"磁盘到网卡"的路径一层一层拆开:I/O 接口的作用、CPU 和设备怎么沟通、中断和 DMA 的分工、零拷贝凭什么快。读完后你会理解为什么 Kafka 号称"磁盘比内存还快",Nginx 为什么能轻松顶几万 QPS——都是这条路径上的不同优化姿势。
# 02.I/O设备概述
# 2.1 什么是I/O设备
I/O 是 Input/Output 的缩写,即输入/输出。I/O设备是计算机与外界交互的桥梁——没有I/O设备,计算机就是一个"孤岛",算再多也没有意义。
疑惑:键盘鼠标显示器我理解,但硬盘、网卡也算I/O设备吗?
答疑:是的。在计算机体系结构中,除了 CPU 和内存之外的所有设备都统称为 I/O 设备(外部设备/外设)。
CPU 和 内存 = 计算机的"大脑"
I/O设备 = 计算机的"五官和四肢"
输入设备:获取外界信息(眼睛、耳朵)
输出设备:向外界表达结果(嘴巴、手)
存储设备:长期记忆(笔记本)
网络设备:远程通信(电话)
2
3
4
5
6
7
# 2.2 I/O设备分类
| 分类方式 | 类型 | 示例 |
|---|---|---|
| 按方向 | 输入设备 | 键盘、鼠标、摄像头、麦克风、扫描仪 |
| 输出设备 | 显示器、打印机、扬声器 | |
| 输入输出设备 | 触摸屏、网卡、硬盘 | |
| 按速度 | 低速设备 | 键盘(~10字节/秒)、鼠标 |
| 中速设备 | 打印机(~数KB/秒) | |
| 高速设备 | 硬盘(~GB/秒)、网卡、显卡 | |
| 按信息组织 | 块设备 | 硬盘、SSD(以数据块为单位传输) |
| 字符设备 | 键盘、鼠标(以字符/字节为单位传输) |
# 2.3 为什么I/O很重要
疑惑:I/O不就是接个设备读写数据吗,为什么要专门学?
答疑:I/O是计算机系统的性能瓶颈所在。现代计算机的CPU速度已经非常快,大多数程序的性能瓶颈不在于"算不过来",而在于"数据搬运不过来"。
一个Web服务器的典型时间分配:
CPU计算: 5% 的时间
等待I/O: 95% 的时间(等待网络数据、等待磁盘读写)
这就是为什么:
- Node.js 用异步I/O 就能处理高并发
- Redis 用内存代替磁盘就能快100倍
- 选SSD换HDD,编译速度能翻倍
2
3
4
5
6
7
8
# 03.I/O设备接口
# 3.1 什么是设备接口
I/O设备不能直接连到CPU上,它们之间需要一个"翻译官"——这就是I/O接口(I/O Interface),也叫I/O控制器(I/O Controller)或适配器(Adapter)。
疑惑:为什么I/O设备不能直接和CPU通信?
答疑:因为两者之间存在巨大的差异:
| 差异点 | CPU侧 | I/O设备侧 |
|---|---|---|
| 速度 | 纳秒级 | 毫秒级(差百万倍) |
| 数据格式 | 并行的数字信号 | 各种模拟/数字信号 |
| 数据宽度 | 32/64位 | 各不相同 |
| 时序 | 与CPU时钟同步 | 各自有自己的时序 |
I/O接口的作用就是解决这四个差异:速度匹配、格式转换、宽度适配、时序协调。
# 3.2 接口的核心组成
CPU总线
│
┌───────┴───────┐
│ I/O 接口 │
│ │
│ ┌───────────┐ │
│ │ 数据寄存器 │ │ ← 暂存传输的数据(速度匹配)
│ │(Data Reg) │ │
│ └───────────┘ │
│ ┌───────────┐ │
│ │ 状态寄存器 │ │ ← 记录设备状态(忙/空闲/错误)
│ │(Status Reg)│ │
│ └───────────┘ │
│ ┌───────────┐ │
│ │ 控制寄存器 │ │ ← 接收CPU的控制命令
│ │(Ctrl Reg) │ │
│ └───────────┘ │
│ ┌───────────┐ │
│ │ 数据转换 │ │ ← 串并转换、模数转换等
│ │ 逻辑电路 │ │
│ └───────────┘ │
└───────┬───────┘
│
I/O 设备
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 3.3 I/O端口编址方式
CPU如何找到特定I/O设备的寄存器?有两种编址方式:
独立编址(I/O映射I/O):
内存地址空间: 0x0000_0000 ~ 0xFFFF_FFFF (用MOV指令访问)
I/O地址空间: 0x0000 ~ 0xFFFF (用专门的IN/OUT指令访问)
x86架构采用此方式
IN AL, 0x60 ; 从端口0x60(键盘)读数据
OUT 0x61, AL ; 向端口0x61写数据
2
3
4
5
6
统一编址(内存映射I/O):
整个地址空间: 0x0000_0000 ~ 0xFFFF_FFFF
其中:
0x0000_0000 ~ 0xBFFF_FFFF → 真正的内存
0xC000_0000 ~ 0xFFFF_FFFF → 映射到I/O设备寄存器
ARM架构采用此方式
LDR R0, [0xC000_1000] ; 读I/O设备寄存器(和读内存一样的指令)
STR R1, [0xC000_1000] ; 写I/O设备寄存器
2
3
4
5
6
7
8
# 3.4 主流接口标准演进
接口标准演变:
串口(RS-232) → 并口(LPT) → USB 1.0 → USB 2.0 → USB 3.0 → USB4/Thunderbolt
IDE → SATA → NVMe(PCIe)
PCI → PCIe
USB 速度演进:
USB 1.0: 1.5 Mbps (1996)
USB 2.0: 480 Mbps (2000)
USB 3.0: 5 Gbps (2008)
USB 3.2: 20 Gbps (2017)
USB4: 40 Gbps (2019)
2
3
4
5
6
7
8
9
10
11
# 04.I/O控制方式
# 4.1 程序查询方式
最原始的I/O方式:CPU主动不断查询I/O设备的状态,直到设备准备好。
CPU 的行为:
while (设备状态寄存器 != 就绪) {
// 什么也不做,一直循环等待
// CPU 被完全占用!
}
读取数据寄存器;
2
3
4
5
6
时间线:
CPU: [查询][查询][查询][查询]...[就绪!][读数据]
设备: [准备中.....................][就绪]
CPU在等待期间完全浪费了!
2
3
4
5
优点:实现最简单 缺点:CPU利用率极低,CPU必须一直等待,无法做其他事情
# 4.2 中断驱动方式
改进思路:让设备准备好之后主动通知CPU,CPU在等待期间可以做其他事情。
CPU 的行为:
启动I/O设备;
去做其他工作(); // CPU可以执行其他任务
...
// 设备准备好后发出中断信号
[收到中断] → 暂停当前工作 → 处理I/O数据 → 恢复之前的工作
2
3
4
5
6
时间线:
CPU: [启动设备][做其他事A][做其他事B][中断!处理数据][继续做事C]
设备: [准备中.................][就绪→发中断]
CPU在等待期间可以做其他事情!大幅提高利用率
2
3
4
5
优点:CPU利用率大幅提高 缺点:每传输一个字(或字节),CPU都要中断一次,频繁中断开销大
# 4.3 DMA方式
疑惑:中断方式已经很好了,为什么还需要DMA?
答疑:当传输大量数据时(比如从硬盘读取一个文件),每个字节都中断一次CPU,中断频率太高了。DMA(Direct Memory Access,直接内存访问)的思路是:让数据传输完全绕过CPU。
CPU 的行为:
告诉DMA控制器:从硬盘地址X读取N个字节到内存地址Y;
去做其他工作();
...
// DMA传输完成后才发一次中断
[收到中断] → 数据已经在内存中了
2
3
4
5
6
时间线:
CPU: [配置DMA][做其他事A][做其他事B][做其他事C][DMA完成中断]
DMA: [搬运数据 从硬盘→内存.....................][完成]
整个数据传输过程中,CPU完全自由!
只在开始(配置)和结束(中断)时参与
2
3
4
5
6
# 4.4 通道方式
通道是比DMA更高级的I/O处理器,它有自己的指令系统,可以执行通道程序来控制I/O操作。
CPU → 启动通道 → 通道独立执行I/O通道程序 → 完成后通知CPU
通道相当于一个"简化版CPU",专门处理I/O操作
大型机和服务器中常见
2
3
4
# 4.5 四种方式对比
| 控制方式 | CPU参与度 | 数据传输单位 | 适用场景 | 类比 |
|---|---|---|---|---|
| 程序查询 | 100% | 字/字节 | 简单低速设备 | 你亲自到门口等快递 |
| 中断驱动 | 每次传输参与 | 字/字节 | 中速设备 | 快递到了按门铃通知你 |
| DMA | 开始和结束参与 | 数据块 | 高速块设备 | 快递员直接放柜子里,放好通知你 |
| 通道 | 只在开始参与 | 一组数据块 | 大型系统 | 雇了个助理帮你处理所有快递 |
技术演变过程:
程序查询 →(CPU太浪费了)→ 中断驱动 →(中断太频繁了)→ DMA →(还想更自动化)→ 通道
│ │ │ │
CPU占用率: 100% CPU占用率: ~30% CPU占用率: ~5% CPU占用率: ~1%
2
3
flowchart LR
A[程序查询<br/>CPU轮询] -->|开销大| B[中断驱动<br/>设备唤醒CPU]
B -->|中断太多| C[DMA<br/>设备直接搬运内存]
C -->|批量托管| D[通道<br/>独立I/O处理器]
style A fill:#fdd
style B fill:#fed
style C fill:#dfd
style D fill:#ddf
2
3
4
5
6
7
8
# 05.中断系统设计
# 5.1 为什么需要中断
疑惑:没有中断的计算机会怎样?
答疑:没有中断,计算机就只能一件一件事情顺序做,无法响应任何突发事件。
没有中断的世界:
CPU: 执行任务A...执行任务A...执行任务A...
键盘: [用户按了一个键] → 没人理
网卡: [收到一个数据包] → 没人理
定时器: [该切换进程了] → 没人理
有了中断:
CPU: 执行任务A...[键盘中断!]→处理按键→继续任务A...[时钟中断!]→切换到任务B
2
3
4
5
6
7
8
结论:中断机制使得计算机可以及时响应外部事件和实现多任务并发。没有中断,就没有现代操作系统。
# 5.2 中断的基本概念
中断(Interrupt)是指CPU在执行程序过程中,遇到急需处理的事件时,暂停当前程序,转去处理该事件,处理完成后再返回原程序继续执行。
核心概念:
中断源: 引发中断的设备或事件
中断请求: 设备向CPU发出的信号
中断响应: CPU暂停当前任务,转去处理中断
中断服务程序(ISR):处理中断事件的代码
中断返回: 处理完成,恢复原来的程序
2
3
4
5
6
# 5.3 中断处理流程
完整的中断处理流程:
1. 中断请求
设备 →[中断请求信号]→ 中断控制器 →[选择优先级最高的]→ CPU
2. 中断响应(CPU硬件自动完成)
① 执行完当前指令
② 保存现场:将PC、PSW等压入栈
③ 关中断(防止处理过程中被打断)
④ 根据中断向量找到ISR入口地址
⑤ 跳转到ISR
3. 中断服务(软件执行)
① 保存额外的寄存器
② 执行具体的中断处理逻辑
③ 恢复寄存器
4. 中断返回
① 开中断
② 恢复PC和PSW
③ 继续执行被中断的程序
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
sequenceDiagram
participant 用户程序
participant CPU
participant 中断控制器
participant ISR as 中断服务程序
用户程序->>CPU: 顺序执行
中断控制器->>CPU: INTR(中断请求)
CPU->>CPU: 保存PC/PSW、关中断
CPU->>ISR: 跳转(查中断向量)
ISR-->>ISR: 处理事件
ISR->>CPU: iret 返回
CPU->>CPU: 恢复现场、开中断
CPU->>用户程序: 继续执行
2
3
4
5
6
7
8
9
10
11
12
13
# 5.4 中断优先级
当多个设备同时发出中断请求时,需要一个优先级机制来决定先处理谁。
典型的中断优先级(从高到低):
1. 机器校验中断(硬件故障) — 不处理机器可能损坏
2. 时钟中断 — 维持系统时间和进程调度
3. 磁盘中断 — 数据传输完成需要及时处理
4. 网络中断 — 网络数据需要及时接收
5. 终端中断(键盘/鼠标) — 用户输入
6. 软件中断(系统调用) — 应用程序请求
2
3
4
5
6
7
# 5.5 软件中断硬件中断
| 类型 | 触发方式 | 来源 | 示例 |
|---|---|---|---|
| 硬件中断(外中断) | 由外部设备信号触发 | I/O设备 | 键盘按键、网卡收到数据、时钟滴答 |
| 软件中断(内中断/异常) | 由指令执行触发 | CPU内部 | 除零错误、缺页异常、系统调用(INT/SVC) |
// 系统调用本质上就是软件中断
// Linux x86 系统调用示例
// 用户程序想要写文件:
// syscall号 = 1 (write)
// 参数: 文件描述符, 缓冲区地址, 长度
mov eax, 1 ; 系统调用号
mov edi, 1 ; 文件描述符(stdout)
mov rsi, buffer ; 缓冲区地址
mov rdx, length ; 长度
syscall ; 触发软件中断,切换到内核态
2
3
4
5
6
7
8
9
10
11
# 06.DMA深入原理
# 6.1 DMA的设计背景
疑惑:为什么要设计DMA这么复杂的机制?中断不够用吗?
答疑:考虑从硬盘读取一个4KB的文件。
中断方式(每次传输1字节):
需要中断次数:4096次
每次中断开销:保存现场 + ISR + 恢复现场 ≈ 500个时钟周期
总中断开销:4096 × 500 = 2,048,000 个时钟周期
CPU 几乎被中断"淹没"了!
DMA方式:
CPU配置DMA:~100个时钟周期
DMA传输4KB:CPU完全不参与
传输完成中断:~500个时钟周期
总开销:~600个时钟周期
节省了 3000 多倍的 CPU 开销!
2
3
4
5
6
7
8
9
10
11
12
# 6.2 DMA工作原理
DMA控制器是一个专门的硬件模块,它可以在不需要CPU参与的情况下,直接在I/O设备和内存之间传输数据。
DMA控制器内部结构:
┌──────────────────────┐
│ DMA 控制器 │
│ ┌──────────────┐ │
│ │ 内存地址寄存器 │ │ ← 数据要写入/读取的内存起始地址
│ │ (MAR) │ │
│ └──────────────┘ │
│ ┌──────────────┐ │
│ │ 数据计数器 │ │ ← 还剩多少数据要传输
│ │ (Count) │ │
│ └──────────────┘ │
│ ┌──────────────┐ │
│ │ 控制/状态 │ │ ← 传输方向、设备号、状态
│ │ 寄存器 │ │
│ └──────────────┘ │
└──────────────────────┘
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 6.3 DMA传输流程
步骤1:CPU 初始化 DMA
CPU → DMA: "从硬盘读4KB数据到内存地址0x1000"
设置MAR = 0x1000, Count = 4096, 方向 = 设备→内存
步骤2:DMA 接管总线,开始传输
DMA 通过总线仲裁获得总线控制权
设备 → DMA → 内存 (每次传一个字)
MAR++, Count--
DMA 释放总线
(CPU可以在DMA不占用总线时使用总线)
步骤3:传输完成
Count == 0
DMA → CPU: 发出中断信号
CPU: "好的,数据已经在0x1000了,我可以使用了"
2
3
4
5
6
7
8
9
10
11
12
13
14
15
sequenceDiagram
participant CPU
participant DMA as DMA控制器
participant DEV as I/O设备
participant MEM as 内存
CPU->>DMA: 配置(源/目的/长度)
CPU-->>CPU: 继续执行其他任务
DMA->>DEV: 启动传输
loop 直到Count=0
DEV->>DMA: 1字(数据)
DMA->>MEM: 写入(MAR++)
end
DMA->>CPU: 完成中断
CPU->>MEM: 读取结果
2
3
4
5
6
7
8
9
10
11
12
13
14
# 6.4 DMA与中断的配合
实际场景:读取磁盘文件
应用程序: read(fd, buf, 4096)
↓ 系统调用(软件中断)
操作系统: 配置DMA控制器
↓
DMA控制器: 从磁盘读数据到内核缓冲区
CPU: 可以调度其他进程执行
↓ DMA完成(硬件中断)
操作系统: 将数据从内核缓冲区拷贝到用户缓冲区
↓
应用程序: read()返回,数据在buf中
2
3
4
5
6
7
8
9
10
11
12
# 07.常见I/O设备原理
# 7.1 键盘的工作原理
按下一个键的完整过程:
1. 物理层:按下按键,接通电路
2. 扫描码:键盘控制器检测到按键,生成扫描码(每个键有唯一编码)
3. 数据传输:扫描码通过USB发送到计算机
4. 中断:键盘控制器触发硬件中断(IRQ 1)
5. ISR:操作系统的键盘中断服务程序读取扫描码
6. 转换:将扫描码转换为ASCII码/Unicode
7. 传递:将字符传递给当前活跃的应用程序
整个过程耗时 < 1ms,但涉及了硬件中断、I/O端口读写、缓冲区等多个概念
2
3
4
5
6
7
8
9
10
11
# 7.2 显示器的工作原理
从程序到屏幕画面:
1. 应用程序调用图形API(如OpenGL、DirectX)
2. GPU接收绘制命令,执行顶点/片元着色器
3. 渲染结果写入帧缓冲区(Frame Buffer)
帧缓冲区存储每个像素的颜色值
分辨率 1920×1080,每像素4字节 = 约8MB
4. 显示控制器按照刷新率(如60Hz)读取帧缓冲区
5. 通过HDMI/DP信号线传输到显示器
6. 显示器逐行扫描,点亮每个像素
刷新率 60Hz = 每秒刷新60次 = 每16.67ms一帧
2
3
4
5
6
7
8
9
10
11
12
# 7.3 磁盘的工作原理
机械硬盘读取数据的过程:
盘片 磁头
┌─────────────┐ ├──── 磁头臂
│ ○ 磁道(Track)│ │
│ ○○○○○○○○ │ ←──移动──→ ▼
│ ○○○○○○ │
│ 扇区(Sector)
└─────────────┘
盘片旋转(7200RPM)
读取过程:
1. 寻道:磁头臂移动到目标磁道(~5-10ms)
2. 旋转延迟:等待目标扇区转到磁头下方(~4ms @7200RPM)
3. 传输:数据从盘片传输到控制器缓存(~0.01ms/扇区)
平均访问时间 ≈ 寻道时间 + 旋转延迟 + 传输时间 ≈ 10ms
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 7.4 网卡的工作原理
接收一个网络数据包的过程:
1. 物理层:电/光信号到达网卡
2. 网卡接收:将信号转换为数字数据
3. 校验:验证数据完整性(CRC校验)
4. DMA传输:网卡通过DMA将数据写入内存中的接收缓冲区
5. 中断:网卡发出硬件中断
6. 驱动程序:ISR将数据包交给操作系统网络协议栈
7. 协议栈处理:TCP/IP解包,将数据交给应用程序
2
3
4
5
6
7
8
9
# 08.I/O设备与编程
# 8.1 设备驱动程序
疑惑:应用程序是怎么操作I/O设备的?需要直接操作硬件寄存器吗?
答疑:不需要。操作系统通过分层抽象,让应用程序可以用统一的接口操作所有设备。
应用程序
↓ read()/write() 统一的API
操作系统(虚拟文件系统VFS)
↓ 统一的接口
设备驱动程序
↓ 操作硬件寄存器 每种设备不同
具体I/O设备
2
3
4
5
6
7
Unix/Linux 的设计哲学:一切皆文件。
# 读键盘输入 → 就是读文件
cat /dev/input/event0
# 写显示器 → 就是写文件
echo "Hello" > /dev/tty
# 读硬盘 → 就是读文件
cat /dev/sda1
# 从程序员的角度,操作任何设备都是 open/read/write/close
2
3
4
5
6
7
8
9
10
# 8.2 IO对软件性能影响
# Python 示例:演示I/O对性能的影响
import time
# 逐行写入(每次write都是一次系统调用+I/O操作)
start = time.time()
with open("test1.txt", "w") as f:
for i in range(100000):
f.write(str(i) + "\n")
print(f"逐行写入: {time.time()-start:.2f}s")
# 批量写入(减少I/O次数)
start = time.time()
lines = []
for i in range(100000):
lines.append(str(i) + "\n")
with open("test2.txt", "w") as f:
f.writelines(lines)
print(f"批量写入: {time.time()-start:.2f}s")
# 批量写入通常快 3-10 倍
# 原因:减少了系统调用次数,减少了用户态/内核态切换
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 8.3 零拷贝技术
疑惑:为什么网络服务器要用"零拷贝"技术?
传统的文件发送流程涉及多次数据拷贝:
传统方式(4次拷贝 + 4次上下文切换):
磁盘 →[DMA]→ 内核缓冲区 →[CPU]→ 用户缓冲区 →[CPU]→ Socket缓冲区 →[DMA]→ 网卡
零拷贝方式 sendfile()(2次拷贝 + 2次上下文切换):
磁盘 →[DMA]→ 内核缓冲区 →[DMA]→ 网卡
数据全程不经过用户空间!
2
3
4
5
6
7
// C语言 零拷贝示例
#include <sys/sendfile.h>
int file_fd = open("large_file.dat", O_RDONLY);
// sendfile: 直接在内核空间完成文件到网络的传输
sendfile(socket_fd, file_fd, NULL, file_size);
// 数据不需要拷贝到用户空间
2
3
4
5
6
7
结论:Nginx、Kafka等高性能系统大量使用零拷贝技术,这就是它们能处理超高吞吐量的关键原因之一。
# 09.综合案例Nginx文件
用 Nginx 发一个 500MB 的 mp4 给客户端这个极普通的场景,把本章所有知识(I/O 接口、中断、DMA、零拷贝、设备驱动)串起来。
# 9.1 场景背景
请求:GET /video.mp4 HTTP/1.1,服务器要把磁盘上的 /var/www/video.mp4 读出来,通过 TCP 传给客户端。两种写法的性能差异会是数倍。
# 9.2 readwrite慢版本
// 朴素版本:每次读 4KB,写 4KB
char buf[4096];
while ((n = read(file_fd, buf, sizeof(buf))) > 0) {
write(socket_fd, buf, n);
}
2
3
4
5
这段 500MB 文件大约需要 128,000 次循环。每一圈里发生了什么?
一次循环(读 4KB + 写 4KB):
1. 用户态 → 内核态(read 系统调用,软件中断)
2. 内核检查 Page Cache:
- 未命中 → 发 DMA 指令给磁盘控制器
- DMA 把 4KB 从磁盘搬到 Page Cache
- 磁盘控制器发中断通知 CPU "搞定了"
3. CPU 把 4KB 从 Page Cache 拷到用户空间 buf
4. 内核态 → 用户态(read 返回)
↓ 跳过 return,小赵没做任何处理
5. 用户态 → 内核态(write 系统调用,软件中断)
6. CPU 把 4KB 从用户 buf 拷到 Socket 缓冲区
7. DMA 把 4KB 从 Socket 缓冲区搬到网卡
8. 网卡发中断通知 "发完了"
9. 内核态 → 用户态(write 返回)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
一次循环的代价:
| 次数 | 动作 | 类型 |
|---|---|---|
| 4 | 上下文切换(用户/内核态) | 软中断 |
| 2 | DMA 搬运(磁盘→Cache / SockBuf→网卡) | 硬件 |
| 2 | CPU 拷贝(Cache→用户 / 用户→SockBuf) | CPU 工作 |
| 2 | 硬件中断(DMA 完成通知) | 硬中断 |
500MB × 128,000 次,CPU 花在"搬运"上的时间 >> 花在"计算"上的时间。这就是为什么小赵看到 CPU 12% 却传不快——CPU 的 12% 不是在做业务,是在拷贝数据!
# 9.3 sendfile快版本
// 零拷贝版本:一个系统调用搞定
sendfile(socket_fd, file_fd, &offset, count);
2
一次 sendfile 调用(Linux 2.4+,SG-DMA 硬件支持):
1. 用户态 → 内核态(sendfile 系统调用)
2. 内核检查 Page Cache:
- 未命中 → DMA 从磁盘搬到 Page Cache
3. 内核直接把 Page Cache 的描述符(地址+长度)交给 Socket
4. DMA 从 Page Cache 直接搬到网卡(SG-DMA)
5. 内核态 → 用户态(sendfile 返回)
2
3
4
5
6
7
8
一次调用的代价:
| 次数 | 动作 |
|---|---|
| 2 | 上下文切换 |
| 2 | DMA 搬运 |
| 0 | CPU 拷贝 |
| 1 | 硬件中断 |
CPU 完全从数据通路里解放出来。这就是为什么小赵改一行代码,CPU 占用率从 12% 降到 3%,速度反而快了 8 倍。
# 9.4 串起本章所有知识
这个案例刚好覆盖本章每一节:
| 章节 | 知识点 | 在这个案例里的角色 |
|---|---|---|
| 2.2 I/O 接口寄存器 | 状态寄存器 | 磁盘控制器、网卡控制器的状态查询 |
| 3.1 程序查询 | 原始方式 | 如果去掉中断和 DMA,就是小赵的噩梦 |
| 3.2 中断驱动 | 响应机制 | DMA 完成后通知 CPU 的方式 |
| 3.3 DMA | 批量传输 | 磁盘→Cache、Cache→网卡两次都是 DMA |
| 5.x DMA 原理 | 脱离 CPU | 让 CPU 去干别的业务 |
| 6.3 磁盘原理 | Page Cache | 第二次读同一文件时跳过磁盘 |
| 6.4 网卡原理 | DMA + 中断 | 网卡把帧数据 DMA 出去后触发中断 |
| 7.3 零拷贝 | sendfile | 本案例的主角,直接跳过用户空间 |
一句话:看起来只是"换个 API",实际上是你"重新认识了一次 I/O 子系统"。真正懂 I/O 原理的人,写 sendfile 那一行的底气,是能画出整条数据通路的。
# 10.思考题与作业
# 10.1 基础思考题
四种 I/O 控制方式对号入座:下面的场景分别适合哪种?
- 读键盘输入 / 打印机打印 / 硬盘读 10MB 文件 / 显卡刷新 60fps 画面 / 网卡持续收包
为什么要有 I/O 接口(控制器)? 不能让 CPU 直接连接设备吗?请从"速度差异、格式差异、时序差异、宽度差异"四个角度分别说明。
中断 vs 轮询:在什么情况下"轮询"反而比"中断"更好?提示:考虑高速网卡下的 napi 混合模型。
DMA 结束时为什么还要发中断? 既然 DMA 能绕过 CPU,完成后为什么还要惊动它?
零拷贝的几种实现:mmap、sendfile、splice 各自的适用场景和局限是什么?
# 10.2 进阶思考题
0.1 节故事的完整复盘:用本章 3.x、5.x、7.3 三节知识,画出小赵"
read+write40 秒"版本的完整数据路径,并在图上标出"CPU 在哪几步参与了数据搬运"。为什么 Kafka 号称"磁盘比内存还快"? 请从"顺序写 + sendfile + Page Cache"三个角度论证。
DMA 和 Cache 的冲突:如果 DMA 直接修改了内存,CPU Cache 里的对应数据还是旧的,怎么办?现代硬件用什么机制解决?(提示:DMA 一致性、Cache Flush)
I/O 多路复用和 I/O 控制方式的关系:select/poll/epoll 是不是"第五种 I/O 控制方式"?还是建立在前面四种之上?
设备驱动的分层:Linux 把驱动分成字符设备、块设备、网络设备三大类,这种分类的本质依据是什么?为什么键盘是字符设备而磁盘是块设备?
# 10.3 动手作业
作业一(必做):复现 sendfile vs read+write 的性能差。
- 用你熟悉的语言写一个小型文件服务器,实现两个版本:
- 版本 A:
read + write循环 - 版本 B:
sendfile/transferTo/os.sendfile
- 版本 A:
- 传一个 500MB 文件,对比:传输时间、CPU 占用率、网卡利用率
- 用
strace -c或perf看看两个版本各自产生了多少次系统调用
作业二(选做):写一个简化的 DMA 模拟器。
- 用多线程模拟 DMA:一个线程负责"搬运"(sleep 模拟传输时间),另一个线程是"CPU"在干业务
- 让 CPU 线程用两种方式等待:① 忙等(轮询状态变量)② 阻塞(信号量)
- 观察两种方式下 CPU 的利用率差异,体会"中断/DMA 为什么能释放 CPU"
作业三(拓展):给你的线上服务做一次 I/O 体检。
选一个业务接口,用 iostat、vmstat、perf top、pidstat -d 做这样的体检:
- CPU 的
%wa(I/O wait)占多少? - 磁盘 IOPS 和吞吐是否到达瓶颈?
- 是不是有哪个系统调用特别频繁?(
strace -c -p PID) - 能不能用批量写、mmap、sendfile、零拷贝优化?
写一份小型优化报告,你会发现 I/O 优化通常是"四两拨千斤"——花一点时间能带来数量级的性能提升。