很多学《计算机操作系统》的朋友,到了第六章输入输出系统,第一次感觉到“这玩意儿到底在干嘛”。前面几章讲进程、调度、内存,好歹能写出几行代码模拟一下,到了输入输出这里,教材翻来覆去讲控制器、通道、缓冲区、SPOOLing,感觉全是概念,离代码越来越远。
其实恰恰相反,输入输出系统才是你每天写代码时打交道最多的部分。你每敲一个字符,程序每次 read 一个文件,数据库每次落盘,背后全是这一章讲的机制在运转。这一章学好了,你对“程序是怎么跟硬件打交道的”会有真正的体感,而不只是会背几个名词。
这篇文章我按自己讲课和实际排查问题的经验,把这章重新拆一遍。不讲废话,只说人话,把概念落到场景里,能跑的实验给操作步骤,能踩的坑提前标出来。
1. 章节定位:I/O 系统在操作系统里的角色
1.1 I/O 系统到底解决什么问题
先想一个问题:你写了一个fopen("data.txt","r"),到底发生了什么?你的程序在用户态,文件在磁盘上,磁盘由硬件控制器管理,数据要经过总线、控制器、内核缓冲区,最后才能拷到你程序的内存里。
操作系统的 I/O 子系统,就是管这一整条链路的。它要解决的核心问题有三个:
第一,屏蔽硬件差异。磁盘、键盘、网卡、显示器,每种设备的寄存器、时序、数据格式都不一样。不可能让每个应用程序员直接去操作这些寄存器,那写出来的代码就没法跨设备、跨平台了。操作系统在中间提供统一接口,让程序员看到的就是read、write、open这几个系统调用。
第二,提高 CPU 和设备的工作效率。CPU 的速度和 I/O 设备的速度差好几个数量级。机械硬盘一次寻道好几毫秒,CPU 一个时钟才零点几纳秒。如果 CPU 干等设备,那就是巨大的浪费。所以出现了中断、DMA、通道这些机制,核心思路就一句话:让 CPU 启动 I/O 之后赶紧去干别的,等设备完事了再来通知它。
第三,让多个程序安全、高效地共享设备。打印机不能两个进程同时往里塞数据,磁盘不能两个进程在同一个扇区乱写。I/O 子系统还要负责分配、排队、隔离,让共享不出乱子。
这三个问题,其实就是整个第六章的骨架。你后面学的每一种技术,都能对号入座。
1.2 本章知识地图
我习惯把这一章分成四块:
- 硬件基础:设备分类、设备控制器、I/O 端口、DMA、通道。
- 控制方式演进:程序直接控制、中断、DMA、通道,四种方式一层层升级。
- 软件层次:用户层软件、设备独立性层、设备驱动层、中断处理层。
- 核心机制:缓冲区管理、SPOOLing、设备分配。
前两块是“硬件怎么看”,后两块是“软件怎么管”。考试重点和实际应用重点都在后两块,但前两块如果不懂,后两块就是空中楼阁。
我自己教学生的时候,经常强调一句话:不要只背“DMA 是直接存储器访问”这个定义,你要能回答出来“DMA 和中断驱动的本质区别是什么”。答案是:是否经过 CPU 中转数据。中断驱动虽然解决了“CPU 等待”的问题,但数据还是要 CPU 从控制器寄存器搬到内存;DMA 则是控制器直接通过总线把数据写到内存,CPU 只在开始和结束时介入。这才是本质差异。
后面每一节我都按照“是什么、为什么、怎么用、注意什么”这个节奏来讲,尽量让读者看完就能形成自己的逻辑链。
2. 硬件基础:搞懂设备、控制器、端口和 DMA
2.1 设备的两种分类方式
设备分类可以按信息交换的单位分,也可以按共享属性分,考试里两个都考,实际工程里两个都有用。
按交换单位分,就是块设备和字符设备。
块设备以“块”为单位读写,比如磁盘,典型块大小是 512 字节或 4KB。块设备的特点是:可以随机访问,寻址某个块直接读写,不需要顺序来。所以块设备通常支持 mounted 文件系统,可以当存储用。
字符设备以“字符”为单位读写,比如键盘、鼠标、串口。数据是一个字节一个字节来的,通常只能顺序访问,不能 lseek 跳到某个位置。块设备和字符设备的区别,你在 Linux 里能用ls -l /dev直接看到,开头的字母b就是块设备,c就是字符设备。
按共享属性分,就是独占设备和共享设备。打印机是典型的独占设备,一个进程在用,别的进程就得等着。磁盘是可共享设备,多个进程可以同时并发访问,当然底层要靠文件系统来管理并发冲突。还有一种叫虚拟设备,就是用 SPOOLing 技术把独占设备改造成“看起来能共享”的设备,后面专门讲。
我建议你把这两种分类方式分开记,因为它们回答的是不同的问题:按交换单位分类,解决的是“驱动怎么跟设备打交道”;按共享属性分类,解决的是“设备分配策略怎么定”。
2.2 设备控制器:CPU 和设备的“翻译官”
设备控制器本质是一块硬件电路板,插在总线上,一头接 CPU/内存,一头接设备。控制器里面有几个关键组成部分:
- 数据寄存器/数据缓冲区:CPU 和设备之间的数据中转站。比如串口控制器里的接收缓冲寄存器,设备来一个字节先放在这,CPU 来读。
- 控制寄存器:CPU 通过往这里写命令,告诉设备要做什么,比如“启动读”“磁头寻道”。
- 状态寄存器:CPU 从这里读设备当前状态,比如“忙”“就绪”“出错”。
- 地址译码逻辑:CPU 访问控制器时,要知道往哪个地址写是数据寄存器、哪个地址是控制寄存器。
这里有一个关键问题:CPU 怎么访问这些寄存器?有两种方案。
第一种叫独立编址(端口映射 I/O),就是给控制器的每个寄存器分配一个独立的 I/O 端口地址,用专门的 IN/OUT 指令访问。x86 就是这么干的,in、out指令访问的地址空间和内存地址空间是分开的。
第二种叫内存映射 I/O,就是把控制器的寄存器映射到内存地址空间里,CPU 用普通的mov指令就能读写。ARM 架构常用这种方式,你写嵌入式驱动时操作*(volatile uint32_t *)0x40021000这种地址,就是在操作寄存器。
两种方式各有取舍。独立编址需要专用指令,但不会占用内存地址空间;内存映射不需要专用指令,但要注意不能用缓存(所以要 volatile),否则 CPU 可能读不到设备刚更新的状态。
实际工程中,很多系统是两种混用的,x86 的显卡、网卡等高速设备通常走内存映射,老式的串口、并口走独立编址。
2.3 中断和 DMA:两个“不让 CPU 傻等”的硬件帮手
中断这部分,操作系统课程在进程那一章就讲过,但第六章里要换个角度看:中断是 I/O 设备和 CPU 通信的主要手段。设备完成一个操作后,通过中断控制器向 CPU 发一个中断请求,CPU 响应后进入中断处理程序,完成后续工作。
中断有硬中断和软中断之分,硬中断是设备发出的,软中断是程序主动触发的(比如int 0x80系统调用)。在 Linux 里,中断处理还分上半部和下半部:上半部处理紧急的、需要关中断的部分;下半部处理耗时的、可以延后的部分。这是现代操作系统为了“中断处理时间尽量短”做的设计,考试如果考 Linux 的 I/O 实现,会涉及这一块。
DMA(直接存储器存取)是另外一个重要的硬件。没有 DMA 的时候,磁盘读一个扇区到内存,CPU 要先把数据读到自己寄存器里,再写到内存里,一个扇区 512 字节就要搬运 512 次。有了 DMA 控制器,CPU 只要告诉 DMA:“把磁盘地址 X 的数据搬到内存地址 Y,长度 512 字节”,DMA 控制器就自己通过总线搬运完了,搬运完成后发一个中断通知 CPU。CPU 从搬运工变成了包工头,这是质的飞跃。
DMA 控制器需要配置的寄存器有:源地址寄存器、目的地址寄存器、传输长度计数器、控制寄存器(方向、突发模式等)。有的 DMA 控制器还支持链表描述符,可以做散列收集,就是把内存中不连续的好几个缓冲区拼成一个连续的 I/O 操作,Linux 里sg(scatter-gather)就是干这个的。
使用 DMA 要注意缓存一致性问题。CPU 有高速缓存,而 DMA 是直接读写内存的,如果缓存里的数据和内存里的数据不一致,就会出现错误。这也是为什么现代 DMA 引擎都有 cache coherency 处理,或者驱动里用dma_map_single这样的 API 来保证一致性。
3. 四种 I/O 控制方式:从 CPU 苦力到通道管家
这四种方式是本章的核心逻辑线,必须串起来理解,不能孤立着背。
3.1 程序直接控制方式:最原始,CPU 全程苦等
流程很简单:CPU 不断读取设备状态寄存器,看到“就绪”就搬运一个单位的数据,循环往复。
比如要从磁盘读一个字节:CPU 发出读命令,然后不停轮询状态寄存器。设备准备好一个字节后,CPU 读数据寄存器,存到内存,然后继续发下一条命令。
这个方式的致命问题就是忙等。CPU 全程被占住,设备慢,CPU 就空转。对于现代操作系统来说,这是不可接受的。但它有一个优点:实现最简单,适合对性能没要求、或者单片机上的简单场景。
我读大学的时候,用单片机做过一个串口收发实验,就是这种轮询方式。代码大概是:
while ((UART0->FR & (1 << 4)) != 0); // 等待发送FIFO为空 UART0->DR = ch; // 发送一个字符在裸机环境里这么写没问题,但如果你在 Linux 内核驱动里用轮询等设备,会被骂死的。工程师圈子里把这种忙等叫 busy-wait,是性能杀手。
3.2 中断驱动方式:设备完事再叫我
中断驱动的思路是:CPU 发出 I/O 命令后就回去执行其他任务,当设备完成操作时,向 CPU 发出中断请求,CPU 在中断处理程序里接管后续的数据处理。
这里有一个关键区别很多人搞混:程序直接控制和中断驱动,I/O 单位是一个字(或一个字节)。也就是说,中断驱动的数据搬运仍然经过 CPU。比如键盘来一个字符,中断处理程序去读数据寄存器,把字节放到缓冲区,然后返回。每来一个字符,就触发一次中断。
所以中断驱动解决的问题是“CPU 不用干等”,但每传输一个数据单位都要一次中断,中断处理本身要保护现场、跳转、恢复现场,是有开销的。对于键盘这种低速设备无所谓,但要传文件这种大量数据,中断次数就太多了,CPU 要被频繁打扰。
不过中断驱动比轮询先进的地方是确确实实把“等待时间”拿回来了:设备在工作的时候,CPU 可以去跑别的进程。
3.3 DMA 方式:数据搬运不再经过 CPU
DMA 方式下,数据传送的基本单位是块。CPU 只需初始化 DMA 控制器(配置源地址、目的地址、长度),然后就去做别的事情。DMA 控制器自己完成从设备到内存的数据传输,传输完成才发中断通知 CPU。
举个例子,磁盘读一个 4KB 的块。程序直接控制方式下,CPU 读 4096 个字节,每读一个字节判断一次状态。中断驱动方式下,每个字节触发一次中断,如果算上中断的保存/恢复开销,比轮询也好不了太多。DMA 方式则是:CPU 配置好 DMA,然后该干嘛干嘛,DMA 控制器通过总线突发模式一下子把整个 4KB 考到内存,最后发一个中断。CPU 总共也就被麻烦两次:启动时一次、完成时一次。
DMA 和应用层读文件的关系,在后文第 6 节“从应用请求到硬件执行”里会有完整串联,这里先记住结论:DMA 是块设备高性能 I/O 的基石。
DMA 的一个经典坑是 cache coherence 问题,前面提过不再展开,但我要提醒的是在面试和考试里,如果被问“DMA 有什么缺点”,除了 cache 一致性,还可以说:DMA 需要占用总线,在 DMA 传输期间 CPU 访问总线会被仲裁,虽然影响不大,但在高并发大带宽场景下,DMA 也是对系统资源的一种占用。
3.4 通道方式:有一个专门管 I/O 的小 CPU
通道方式可以理解成把 DMA 进一步升级了。DMA 还需要 CPU 去配置各种寄存器,通道则是把 I/O 操作的指令集做成一个专门的处理器,CPU 只需要发起一个“通道程序”,通道自己解析指令,完成一组 I/O 操作,然后中断报告结果。
通道有自己的指令系统,指令包括:读、写、反读、控制、转移等。通道程序就是由这些指令组成的,存放在内存里。CPU 执行专门的外部指令(如“启动 I/O”)来启动通道,通道再去控制设备控制器。
通道的诞生背景是大中型计算机要连接大量外设,如果每个设备都靠 CPU 来管理,CPU 忙不过来。通道相当于一个 I/O 专用处理器,可以把 CPU 从繁琐的 I/O 管理中彻底解放出来。
通道按独立程度分三种:字节多路通道(以字节为单位,共享通道给多个低速设备),数据选择通道(一次只为一个设备服务,适合高速块设备),数组多路通道(以块为单位在设备之间切换,兼具两者优点)。考研操作系统里常考这三个的适用场景,理解它们的原则就能推出来:按数据单位和服务方式是“轮流”还是“独占”去区分。
3.5 四种控制方式的对比速查表
为了好记,我把四种方式的对比整理成一个表。面试前、考试前瞄一眼,能帮你快速找回主线。
| 控制方式 | 数据单位 | 是否经过 CPU 搬运 | CPU 工作量 | 适用场景 |
|---|---|---|---|---|
| 程序直接控制 | 字/字节 | 是 | 最大,全程忙等 | 简单的嵌入式裸机 |
| 中断驱动 | 字/字节 | 是 | 较大,每次传输都中断 | 低速字符设备 |
| DMA | 块 | 否,控制器直接搬 | 小,启动和结束时介入 | 高速块设备 |
| 通道 | 块/组 | 否,通道处理器管理 | 最小,仅发启动指令 | 大型计算机海量外设 |
一条主线串下来就是:CPU 从亲自干活,变成等人叫,变成只管两头,变成彻底甩手。这个演化逻辑记清楚,比背十条定义都管用。
4. I/O 软件层次:设备独立性的实现路径
4.1 四层结构:每层干好自己的活
I/O 软件在逻辑上分为四层,从上到下依次是:
用户层软件:就是你的应用程序里的 read、write、printf 这些库函数和系统调用。这层负责把用户的 I/O 请求转成内核能认的调用,提供友好接口。
设备独立性软件(设备无关层):这一层做的事情很多,包括:给上层提供统一接口;设备命名与映射(把设备名映射到设备驱动);保护设备;为进程分配设备;缓冲管理;差错报告;还有 SPOOLing 的实现也在这里。
设备驱动程序:每个设备类型对应一个驱动,负责把上层的逻辑请求翻译成具体的硬件操作。比如把“读块号 100”翻译成“向磁盘控制器发送寻道命令、读扇区命令,配置 DMA”。
中断处理程序:设备完成操作后发中断,中断处理程序负责读取设备状态、把数据从缓冲区取出、唤醒等待的进程等。
这四层的设计哲学就是分层。每一层只跟相邻层打交道,上层不用关心设备的物理细节,下层不用关心应用的具体意图。
4.2 设备独立性:为什么换设备不用改应用
设备独立性也叫设备无关性,是指应用程序不直接使用物理设备名,而是使用逻辑设备名(比如“打印机”),操作系统在运行时才把逻辑设备映射到物理设备。
这样带来的好处非常明显:如果换了一台打印机,只要新打印机的驱动注册到系统里,应用程序完全不用改,继续用逻辑设备名请求就行。
在 Linux 里,这种设计体现在设备文件上。/dev/sda、/dev/ttyS0这些设备文件就是设备独立性的体现:用户程序通过文件接口访问设备,具体驱动由内核根据主次设备号查找。你写程序用open("/dev/ttyS0", O_RDWR),内核根据这个设备文件的设备号找到对应的串口驱动,然后调用它的操作函数。
我自己写驱动模块的时候,对“设备独立性”的体感特别深:内核里file_operations结构体就是驱动层暴露给上层的统一接口。上层只需要会open、read、write、ioctl,至于底层是 SPI、I2C 还是 PCIe,完全屏蔽。面试问“设备无关性如何实现”,你答出这层意思就够了。
4.3 设备驱动与中断处理:驱动干活,中断善后
设备驱动层和中断处理层的分工是:驱动负责“发起”,中断处理负责“收尾”。
驱动做的事情包括:校验上层传下来的参数;把逻辑请求转成设备命令;写设备控制器的寄存器;配置 DMA;然后返回(阻塞或非阻塞)。
中断处理做的事情包括:保存现场;读取设备状态寄存器确认成功还是失败;如果成功,把数据从控制器缓冲区搬到内核缓冲区;解除阻塞、唤醒等待的进程;恢复现场返回。
这里有一个关键设计问题:驱动通常运行在进程上下文(可以睡眠),中断处理运行在中断上下文(不能睡眠)。所以在 Linux 里,驱动里可以调用wait_event、sleep这类函数,但中断处理程序里绝对不能。在写驱动时,如果中断处理里需要做大量工作,就把工作推到下半部(tasklet 或 workqueue)。
很多教材会把驱动和中断处理都归到“内核层”,但你要意识到,它们在上下文、可调用函数、运行环境上有本质区别,这个在 5.3 的设备分配里还要用到:分配设备时可能要等待设备空闲,这时进程要睡眠。
5. 核心机制:缓冲、SPOOLing 与设备分配
这一节是实操里最常碰到的内容。文件读写为什么会卡?为什么打印机共享不会乱码?多进程同时写磁盘文件系统怎么处理?都是这些机制在起作用。
5.1 缓冲区管理:CPU 和设备的速度差怎么填
缓冲区就是一块内存区域,用于暂存数据,让速度不匹配的双方能“异步”工作。为什么必须有缓冲?我总结三个原因:
第一,匹配 CPU 和设备的速度差。CPU 纳秒级,磁盘毫秒级,中间没缓冲,CPU 就得等设备或者频繁被打断。
第二,减少对 CPU 的中断频率。比如网卡每来一个包就中断一次,如果每次中断只处理一个字节,CPU 就被淹没了。有了缓冲,可以攒一批数据,或者用 DMA 直接批量搬走。
第三,减少数据拷贝次数、提高效率。这个要分清:缓冲本身是拷贝,但合理设计可以减少“应用-内核-设备”之间不必要的来回拷贝。
缓冲区设计里,有几个常见模式:
- 单缓冲和双缓冲:设备先填缓冲区A,CPU 同时处理缓冲区B,交替使用。双缓冲可以让生产和消费并行。
- 环形缓冲:一组固定大小的缓冲区组成环,写指针和生产、读指针和消费。网卡驱动、串口接收都常用这种结构。Linux 里
struct ring_buffer就是这么干的。 - 缓冲池:把多个缓冲区统一管理起来,按需分配,用完后回收,避免频繁申请释放内存。
教材里还会讲“双缓冲为什么能把处理时间和传输时间重叠”。我举一个具体的场景:假设设备传输一块数据时间 T,CPU 处理这块数据时间 C,如果单缓冲,总时间就是 T+C(等设备传完再处理);双缓冲的话,如果 C < T,设备一直在传,CPU 每块数据只要等一段不超过 T 的时间,平均时间接近 max(T, C)。这就是教材里那个计算题的来源,理解了重叠逻辑,公式不用背。
我自己调试网络抓包工具的时候,环形缓冲的知识帮了大忙。理解tail、head、drops这几个计数器,才能看懂netstat -i的丢包统计。如果你做高性能 I/O,这一块是必修。
5.2 SPOOLing:把独占设备变成“虚拟共享设备”
SPOOLing 的全称是 Simultaneous Peripheral Operation On-Line,也就是假脱机操作。它解决的问题是:打印机是独占设备,但一个系统里可能有几十个进程都要打印,怎么办?
最朴素的办法是排队:一个进程打印完,下一个进程再上。但打印是慢速I/O,后面进程阻塞等待,系统效率太低。
SPOOLing 的思路,是在磁盘上划出两块区域:输入井和输出井,同时在内存里维护输入/输出缓冲区。以打印为例,进程要打印时,不是直接把数据扔给打印机,而是先把数据写到磁盘上的输出井中,这个过程很快(磁盘速度比打印机快好几个数量级)。然后由 SPOOLing 系统(后台进程)按照队列顺序慢慢地把输出井中的数据送往打印机。
这样,对进程来说,它感觉自己“独占”了一台打印机,瞬间就完成了打印请求,不用等真正的打印过程。打印机从“独占设备”变成了“看起来可以并发共享的虚拟设备”,这就是虚拟设备的含义。
SPOOLing 技术的应用远不止打印机。在 Linux 里,最典型的就是打印系统的 CUPS 队列。你调用lp命令提交打印任务,实际上是先写到队列里,CUPS 的后台守护进程再逐个打印。而lpr、lpq这些命令就是 SPOOLing 队列的用户接口。
我再举一个不算打印机但本质相同的例子:邮件服务器。你发一封邮件,SMTP 客户端把邮件投递到本地的邮件队列目录里,后台进程再慢慢尝试发送。客户端不用等服务器真的把邮件送达到对方,经历的就是“写入队列”这个过程,这是 SPOOLing 思想在网络服务中的应用。
理解 SPOOLing 的要点就是三个字:缓(磁盘缓冲)、队列(排队调度)、异步(后台执行)。
5.3 设备分配:怎么分才不打架
设备分配涉及两个问题:什么时候分配设备?用什么数据结构记录设备的状态?
教材上会讲设备分配表、控制器分配表、通道分配表,考试喜欢考“分配顺序”:先分配设备,再分配控制器,再分配通道。这张分配顺序的逻辑是:数据从设备上来,要经过控制器、总线/通道,才能到内存,哪一环没分配到,链路都不通。这跟内存管理分配页表、段表一样,整个资源链都得配齐。
我在实际读驱动代码时,对这个过程一般不怎么关注,因为现代操作系统主要用设备文件 + 驱动模型来管理,动态分配和释放都自动化了。但教材里的知识点能帮你理解“为什么系统有时会报告‘设备忙’”:因为某个链路资源被占用了。
设备分配的安全性策略也是常考点。安全分配方式是在设备分配时一次性占有所有需要的设备,然后在 I/O 过程中不再申请新设备;不安全分配方式是边用边申请,可能出现死锁(两个进程各自占了一个设备,还要对方占的设备)。这其实是资源分配的一般性原则,跟读者进程同步那一章的死锁知识是通的。
对了,还有一个概念容易混淆:设备分配中的“逻辑设备名”。教材上说,用户用逻辑设备名请求设备,系统把它映射到物理设备名。映射过程就是查逻辑设备表,表中记录了逻辑设备名对应的物理设备名和驱动程序入口地址。这就是设备独立性的落地——上层用逻辑名,底层转到物理设备。
5.4 与设备独立性相关的完整链条
把 4.2 和 5.3 合起来看,设备独立性的完整链条就是:
用户程序 → 逻辑设备名 → 逻辑设备表 → 物理设备名 → 设备驱动程序入口 → 设备控制器 → 物理设备
这一条链路就是第 4 节“I/O 软件层次”和第 5 节“设备分配”的桥梁。考试如果考综合题,叫你画出“一次打印请求从应用到打印机的完整流程”,本质上就是让你把这条链路用 SPOOLing 串起来。
我来画一条完整链路(文字版):
- 应用程序调用
printf,C 库把它封装成write系统调用。 - 内核根据文件描述符找到对应的文件对象(这个 fd 是
/dev/lp0打印机设备文件)。 - 文件对象指向设备独立层的打印逻辑设备,调用设备驱动里的
write函数。 - 驱动把这个打印任务写入 SPOOLing 输出井(磁盘上的队列)。
- 应用返回,打印任务排队等待。
- CUPS 后台进程从输出井取出任务,调用真正的打印机驱动。
- 打印机驱动通过设备控制器把数据送往打印机的数据缓冲区。
- 打印机控制器控制打印头/激光单元,完成物理打印。
注意第 4 步是关键:应用在写入输出井后就返回了,后续打印是异步的。这就是为什么你用lp提交任务,命令行立刻返回,但打印机要过一会儿才动。
6. 一次完整串联:从 read 系统调用到磁盘硬件
这一节我把前面所有知识串成一个完整的例子。场景是:一个进程执行read(fd, buf, 4096),从磁盘文件读 4KB 数据。
我把全过程分成八个阶段,你看看每一步对应第六章的哪个知识点:
用户态调用
read,触发系统调用,陷入内核。这一层属于用户层软件和系统调用接口。内核虚拟文件系统层根据 fd,找到对应文件的内核态表示(
struct file),获取文件当前的偏移量。根据文件所在文件系统,定位到磁盘上的逻辑块号,然后调用块设备驱动层的接口。
块设备驱动根据逻辑块号,计算柱面、磁头、扇区(如果是机械盘),或者直接换算成 NAND 的物理页地址(如果是 SSD)。这里用到设备驱动层。
驱动判断是否可以直接用 DMA 读,如果可以,就初始化 DMA 控制器,配置源地址(设备控制器里的数据缓冲区)、目的地址(内存页)、长度,然后启动。
DMA 控制器控制设备执行数据传送,完成后发中断。这个中断的处理程序把 DMA 完成状态记录下来,唤醒等待这个 I/O 的进程。注意,数据到了这里还只是在内核的页缓存(page cache)里,不在用户态的
buf里。内核把数据从页缓存拷贝到用户空间的
buf,返回给用户态。如果文件在页缓存里已经有数据(之前读过),第 4~6 步可以省掉很多,这就是页面缓存的作用。系统调用返回,用户程序继续执行。
这个完整流程,几乎把前面所有章节的知识都串起来了:系统调用、内核态用户态切换、页缓存、块设备层、设备驱动、中断、DMA。学操作系统要想形成整体观,一定要自己走一遍这样的“贯穿流程”。
我在 Linux 里用一个简单的strace命令就能看到真实的过程。比如:
strace -f -e trace=read,openat cat /etc/hostname你会看到 openat、read 这些系统调用。如果想深入观察 I/O 在内核里的行为,可以用blktrace:
blktrace -d /dev/sda -o - | blkparse -i -这个能看到每次 I/O 请求从进程到块设备的完整轨迹。我实习的时候靠这个排查过虚拟机磁盘性能问题,它能清楚地显示请求在哪一层排队、在哪一层合并。
7. 常见问题与易错点:考试和实战中的那些坑
7.1 概念辨析题:最容易混淆的几个点
第一个坑:DMA 和中断驱动,很多人觉得 DMA 也用中断啊,两者区别到底是什么。区别在于数据传送主体。中断驱动是 CPU 在中断处理程序里逐字搬运;DMA 是控制器直接搬运,中断只是“搬家完成”的通知。
第二个坑:SPOOLing 是不是就是缓冲区?不是。缓冲区在内存,SPOOLing 是在磁盘上的缓冲井 + 后台进程调度。缓冲区解决速度匹配,SPOOLing 解决独占设备共享,二者解决的问题不同。
第三个坑:块设备和字符设备是不是像文件和目录那样互斥?不是。有的设备既支持块读写也支持字符模式访问,比如/dev/sda是块设备,但/dev/sda1如果直接读块也行,而/dev/ttyS0是字符设备。分类标准是访问方式,不是设备类型本身。
第四个坑:通道和 DMA 的关系。DMA 是通道的一种工作方式吗?不能简单这么说。通道拥有自己的指令系统,能执行通道程序,管理多个设备;DMA 只是单一的数据搬运动作,不能“编程”。通道比 DMA 更高一层,通道程序里可以包含多次 DMA 操作。
这四个坑,考试时几乎年年有人掉进去。我当助教改卷子的时候,见过太多次“反正都用了中断所以认为 DMA 和中断驱动一样”的答案,只能说,抓住“数据经不经过 CPU”这个判据,所有问题迎刃而解。
7.2 计算题:中断次数、DMA 时间、缓冲重合时间
这一章的计算题主要集中在两块:中断/ DMA 传输时间,和缓冲区空闲/重合计算。
先说中断次数。比如一个波特率 9600 的串口,每秒传 9600 个字符,每个字符 8 位。如果每收到一个字符触发一次中断,那每秒中断 9600 次。CPU 每次中断处理假设需要 50 微秒,那 CPU 在中断上就要花 480 毫秒,接近一半 CPU 时间。这就是为什么高速设备必须用 DMA:中断开销根本扛不住。
DMA 时间的计算:假设 DMA 传输 4KB 数据,总线时钟 100MHz,每次突发传输 32 位(4 字节),那么需要 4096 / 4 = 1024 个总线周期,每个周期 10 纳秒,总时间约 10.24 微秒。这个时间远小于 CPU 中断准备 + 数据搬运的时间。所以高性能存储设备全部走 DMA。
缓冲重合时间的计算:题目通常给一个设备传输时间 T,CPU 处理时间 C,问单缓冲和双缓冲的块处理时间。我直接给经验公式:
- 单缓冲:每块总耗时 = max(T, C) + 很小的调度开销。要注意如果 C < T,CPU 会等待设备;如果 C > T,设备会等待 CPU,所以两项取大。
- 双缓冲:每块总耗时 = max(T, C)。因为两块交替,流水线重叠,理论上吞吐量只受最慢一环限制。
这个公式看起来很简单,但好多同学会忘掉“取大”这一步,直接 T+C,那就错了。你再回头想想我前面说的“双缓冲把时间重叠”,其实就用这个公式理解最直白。
7.3 实际工程中的排查思路
实战里,I/O 相关问题的排查比书本上的概念题复杂很多,但思路是相通的。我把自己踩过的坑整理成几个排查步骤:
第一,看是不是中断风暴。用cat /proc/interrupts看某个设备的中断计数是不是在疯狂增长。如果中断次数每秒上千万次,那可能某些老设备还在用中断驱动方式搬运大数据,严重拖累 CPU。这时候优化方向就是启用 DMA 或调整中断合并。
第二,看是不是缓冲配得不对。用vmstat看bi、bo、wa三个字段。wa偏高说明 CPU 在等待 I/O,很可能是缓冲区不足或设备太慢。我之前调一个 Redis 持久化性能,wa一直 30% 以上,后来发现是内核的dirty_ratio设置不合理,导致大量回写,加缓冲参数之后就降下来了。
第三,看是不是设备分配排队太长。Linux 里的 block layer 有 I/O 调度算法,比如mq-deadline、kyber、bfq。如果发现 I/O 延迟很大,但设备本身的吞吐率还有余量,可能是调度器没有按应用需求来,切换调度器有时立竿见影。这个过程需要在「设备队列深度、批大小、目标延迟」之间做取舍,没有放之四海而皆准的配置。
第四,看是不是文件系统层导致的额外 I/O。iostat -x里有avgqu-sz、await、svctm等指标。如果await远大于svctm,说明请求在队列里等的时间长,可能是设备饱和或调度器排队。如果两者接近,说明设备本身处理慢,可能是磁盘坏了或者硬件性能不够。
这一套排查思路,其实对应的是第六章各个软件层次在真实系统中的表现。教材上的每一个概念,在proc、sysfs、iostat、blktrace这些工具里都有落脚点。我觉得学这一章,最好的方式就是一边读教材一边在 Linux 上敲命令验证,而不是只盯着教科书上的概念图看。
8. 学习与复习建议:怎么把这一章真正吃透
如果你是在校学生,正在准备考研或者期末考试,我给三点建议:
第一,先把四种 I/O 控制方式的演进逻辑自己讲一遍。不要拿着书念,合上书,从“CPU 忙等”讲到“中断”讲到“DMA”讲到“通道”。能讲出来,说明逻辑链通了。
第二,做几道缓冲重合计算的题。这类题是固定套路,公式搞清楚之后很好拿分。但注意审题,看清楚是单缓冲还是双缓冲,是“传输+处理”还是“读入+读出”。
第三,画一次完整的打印请求流程图,跟同学互问。SPOOLing 是本章最常考的案例,也是实际系统用的最多的技术,值得多花时间。
如果你是在职工程师,读这一章是为了补操作系统基础,我更推荐一种方法:结合 Linux 源码看。重点看这五个文件:
block/blk-core.c:块设备 I/O 请求的入口和核心流程。drivers/char/mem.c:字符设备/dev/mem、/dev/null的简单实现,非常适合入门。drivers/block/virtio_blk.c:一个真实的块驱动,看它怎么用 DMA 和中断。kernel/irq/manage.c:中断管理核心,看 request_irq 和中断线程化。fs/buffer.c:缓冲区管理和页缓存交互。
不要一开始就啃最复杂的驱动,从简单驱动入手。我自己是这样一路走过来,发现教材里的概念在源码里都能对上。比如virtio_blk里virtqueue就是教材“环形缓冲”的工业实现。
最后一句话送给读者:输入输出系统这章,最忌讳的就是“只见树木不见森林”。如果你发现自己背了很多名词但连“一次 read 系统调用数据到底是怎么到磁盘的”都说不清楚,那说明还停留在碎片化记忆阶段。逼自己把那条链路完整串一遍,才是真的学会了。