处理器的世界里有个很好玩的矛盾:它自己每秒能执行几十亿条指令,可一旦要跟外部设备交换数据,速度立刻被打回原形。这里的关键就落在设备管理里的数据传送控制方式上——从最初的程序直接控制,到中断驱动、DMA、再到通道控制,这四种方式向上承接驱动程序,向下对接设备控制器,本质上回答的是同一个问题:“数据到底怎么从设备搬进内存,以及CPU要不要一直盯着不放。”
这篇笔记适合几类人:正在复习操作系统、马上要考进程管理和设备管理的同学;刚接触驱动开发、发现网上资料把四种方式讲得支离破碎的初学者;还有做嵌入式或存储调优的朋友,想搞清楚“为什么明明用了DMA,CPU占用率还是高”这类实际现象。我会按一条主线展开:每种控制方式解决了什么痛点、又留下了什么新问题,再用表格和计算把边界讲清楚。
1. 为什么要把“数据搬运”这件事单独拿出来讲
先别急着背四种方式的定义。你真正要理解的是:CPU和外设之间的速度差异大到什么程度,以及“搬运”这件事在整个I/O流程里占多大的分量。
1.1 CPU和外设之间那道速度鸿沟
CPU内部的寄存器到缓存走的是纳秒级路径,但外设呢?键盘敲一下是毫秒级,机械硬盘寻道是毫秒级,哪怕现在最快的NVMe固态盘,一次命令提交到完成也要几十微秒。这两种速度差了三到六个数量级,相当于你坐高铁从北京到上海只需要四个半小时,但要从车站排队进站可能要等一整天——CPU就是那个高铁,外设就是那个闸机口。
既然速度差距摆在这,I/O操作就必须有个“谁等谁”的问题。早期的做法是CPU主动去查设备状态,查一次没准备好,再查一次,直到设备说“我好了”。这就是程序直接控制方式,也叫轮询方式。这种方式逻辑上没有任何问题,但CPU的全部时间都被一次I/O给占了。设备转一圈要多久,CPU就得陪多久。
打个具体的比方:如果让CPU用轮询方式去读一个每秒产生1000次数据的老式串口设备,每次轮询加搬运大约消耗2000个时钟周期,那么光应付这一个设备,每秒就要烧掉200万个时钟周期。如果处理器主频是1GHz,这已经占用了千分之二的能力。但这只是一台低速设备。换成磁盘,一次读完几十KB,中间轮询次数更多,CPU几乎就废了。
1.2 控制方式的本质:CPU介入程度的递减
所以,数据传送控制方式的演进,本质上就是一条“CPU越来越少亲自干活”的路线:
- 程序直接控制:CPU全程盯守,读一个字节、等一个字节、搬运一个字节。
- 中断驱动:CPU可以干别的活,设备每准备好一个字节就叫一次中断,CPU被叫回来搬数据。
- DMA:CPU只在开始和结束的时候出现,中间由DMA控制器直接在外设和内存之间搬运成块数据。
- 通道:比DMA更高级,能执行自己的I/O指令程序,CPU只需要发起一个I/O请求,剩下整批数据的传输、格式转换、校验都由通道自己搞定。
这四种方式的排序,正好对应CPU按字节、按字、按块、按一批数据来介入的递进关系。你也可以从另一个角度理解:这是把“I/O过程的管理权”一点点从CPU让渡给专用硬件的过程。谁的管理权更大,谁就能把CPU解放得更彻底,但相应的,硬件也越复杂,价格也越贵。
| 控制方式 | 数据单位 | CPU介入程度 | 数据流向 | 主要适用设备 |
|---|---|---|---|---|
| 程序直接控制 | 字节/字 | 完全介入,全程等待 | 设备→CPU→内存 | 低速简单设备 |
| 中断驱动 | 字节/字 | 每次传输都中断CPU | 设备→CPU→内存 | 键盘、串口等 |
| DMA | 连续数据块 | 仅在开始和结束介入 | 设备↔内存直传 | 磁盘、网卡 |
| 通道 | 一批数据/多组数据块 | 仅发起时介入 | 设备↔内存直传,可管理多设备 | 大型主机I/O系统 |
表格里这几行,建议你反复看。很多时候面试题或考试题考察的不是你能不能背出名字,而是给你一个设备、一个数据量,让你判断该用哪种方式,甚至算一算中断次数。后面几节我会把每种的细节拆开讲。
2. 程序直接控制与中断驱动:从死等到被通知
2.1 轮询方式的流程与致命伤
程序直接控制的核心思想是一句大白话:“CPU反复问设备,你好了没有?”具体流程是这样的:CPU向设备控制器发起读请求,然后进入一个循环,不停读取设备状态寄存器里的“忙/闲”标志位;一旦发现设备数据寄存器已经就绪,就执行一条输入指令把数据取走,存入内存指定的位置;接着再检查下一次数据是否就绪,直到整个数据块读完。
用伪代码表示就是:
// 轮询读取设备数据 while (count < required_bytes) { while ((status_reg & BUSY) != 0) { // 空转等待,CPU什么正事都干不了 } buffer[count] = data_reg; // 从设备数据寄存器取走一个字节 count++; }这段代码看起来简单,但有个非常直观的问题:里面的第二个while循环就是“死等”。等设备转完一圈,CPU什么都没做。问题还不只是浪费时间,更麻烦的是它会“阻塞”。如果某个进程发起了读操作,在数据没读完之前,整个处理器都没法切换到别的进程。你可能会想,那让别的进程来执行不就行了?问题是轮询是循环检测指令,不是请求中断,它不会主动让出处理器。就算操作系统的调度器想切换进程,也得等当前循环走到能被抢占的点,而轮询循环通常不会主动触发调度。
在实际开发里,轮询也并非一无是处。我在调试某些嵌入式裸机程序时,反而会故意用轮询,因为它行为确定、没有异步回调,代码执行路径非常清晰。比如读取一个温度传感器,温度值变化本身就很慢,CPU轮询一次可能只占几十个周期,用轮询完全没问题。真正扛不住的是高速设备或者大数据量传输。
这里还有一个容易被忽略的点:轮询浪费的不只是CPU时间,还浪费功耗。每一次读取状态寄存器、比较标志位,都会产生指令执行和总线访问。手机或者物联网设备如果傻傻轮询,电量掉得飞快。这也是为什么现代操作系统几乎不会用纯轮询来应付普通设备,最多只在系统启动阶段或者无中断可用的时候用一下。
2.2 中断方式:让CPU从“盯梢”里解放出来
中断驱动方式改善了“死等”的问题。设备控制器在数据就绪之后,主动向CPU发送一个中断请求信号。CPU正在执行的指令流被打断,进入中断处理程序,把数据从设备数据寄存器取走,然后再恢复之前的执行现场。这样CPU在等待设备期间,完全可以去运行其他进程,表面上“并行”起来了。
这个改进是直觉性的,就像你去餐厅吃饭,不用一直站在厨房门口等菜,服务员做好了会喊你。但现实里有个坑:服务员每上一道菜就喊你一次,如果一顿饭有100道菜,你就得从座位上站起来100次。中断对CPU的打扰也一样,关键看设备一次能准备好多少数据。
对于键盘这种单字符设备,中断是完美适配的:你按一下键,它中断一次,CPU读走一个扫描码,完事。可对于磁盘、网卡这类设备,问题来了。假设磁盘每扇区512字节,如果按字节中断,读完1MB数据就要触发大约200万次中断。每次中断,CPU至少要压栈、跳转、识别中断源、读数据、恢复现场,保守估计几百到上千个时钟周期就没了。如果你在中端系统里用atop或者perf观察,会发现中断开销占CPU的比例高得吓人。
不过,中断驱动还是有它的历史地位。它第一次让“CPU计算”和“I/O等待”有了重叠的可能性,不过程度很有限:虽然等待期间CPU能跑别的进程,但数据传输本身仍然是“设备→CPU寄存器→内存”两段式搬运,CPU依然是数据流的必经之路。也就是说,中断虽然解决了“等待”问题,没有解决“搬运”问题。数据还是得从设备寄存器先进CPU,再由CPU放到内存,每一个字节都要经过CPU,这个瓶颈一直都在。
3. DMA方式与通道控制方式:把搬运外包给硬件
3.1 DMA:外部设备与内存之间的直通车道
DMA(Direct Memory Access,直接内存访问)的出现,就是为了打掉上面说的“CPU必须经手”这个瓶颈。它的核心是增加一个专门的DMA控制器,这个控制器自带地址寄存器、字节计数器和控制逻辑,能够直接在外设与内存之间搬运数据,不需要CPU一条条指令地搬。
我来描述一次典型的DMA读磁盘过程。CPU先向DMA控制器发出指令,告诉它源设备地址、内存起始地址、要搬运多少字节,然后DMA控制器接管总线,开始干活。此时CPU可以继续执行自己的程序,只要不和DMA同时抢总线就行。DMA控制器每搬完一个数据块,占用一次总线,把数据从磁盘控制器直接写到内存,然后再释放总线。整块搬完之后,DMA控制器向CPU发一个中断,告诉它“搞定了”,CPU再进入中断处理程序完成后续操作。
这里有个术语需要弄清楚:DMA访问内存的方式有两种,一种是块传输方式,DMA霸占总线,把整块数据一口气搬完,期间CPU完全无法使用总线,这叫“停止CPU访问内存”,适合单次传输大批量数据;另一种是周期挪用方式,DMA控制器在每个内存周期结束时“偷”一个总线周期来传输数据,CPU几乎无感知,适合传输速率不太高、但CPU不能长时间被停下访问内存的场景。你如果去读某款DMA控制器的数据手册,通常能看到这两种模式的寄存器配置项。
DMA和中断的结合是如今绝大多数高速设备的标配:DMA负责把数据成块搬到内存,中断负责跟CPU报告“搬运完成”。这样,CPU既不用字节级介入,也不用在搬运过程中被打断,只在开头和结尾露两次面,节省的时间是数量级级别的。
用之前的菜馆比方说:DMA不是服务员每上一道菜喊你一次,而是等所有菜都上齐了,再喊你去吃饭。代价当然也有:DMA控制器本身是个硬件,价格不便宜,而且它只能做“搬运”这一件事,如果你希望设备在做搬运的同时还能做格式转换、差错校验、数据打包这些事,DMA就力不从心了。这就引出了通道。
3.2 通道:更高级的I/O处理器
通道控制方式可以看作DMA的“完全体”。通道本质上是一台专门执行I/O指令的小型处理器,它有自己的指令系统,能够读懂I/O程序。CPU只需要在内存里写好一段“通道程序”,然后启动通道,剩下的事全交给通道:它自己去设备控制器读取状态、控制数据流向、判断什么时候该结束、处理出错重试,甚至能同时管理多台同类或不同类的设备。
通道和DMA最大的区别在于,DMA是一个“定制的搬运工”,只会按CPU设定好的参数把数据从一个地址搬到另一个地址;通道则是一个“现场指挥”,能根据I/O程序里的不同指令,动态决定下一步做什么。如果你学过汇编,可以把DMA理解成一个固定的memcpy调用,通道则是一个能根据情况跳转、循环、判断的小程序。
通道有三种常见类型,这个知识点考试和面试都爱考:
| 通道类型 | 特点 | 适用场景 |
|---|---|---|
| 字节多路通道 | 按字节交叉方式轮换服务多台设备,每台设备传一个字节就切换 | 大量低速设备,如终端、打印机 |
| 数组选择通道 | 一次只为一台高速设备服务,连续传送整块数据 | 高速磁盘、磁带 |
| 数组多路通道 | 高速设备之间按数据块交叉传送,兼顾多路和高速 | 多台磁盘同时工作 |
为什么需要这么多类型?本质原因是硬件资源的平衡:字节多路通道慢但能同时服务很多设备,数组选择通道快但一次只能服务一台,数组多路通道是折衷,它把一个大数据块切成多个小块,每传送一小块就去服务另一台设备,这样多台高速设备看起来都在同时工作。
到这里你可以看出一个趋势:CPU的职责越来越“宏观”。从最开始的字节级搬运,到最后只需要提供一个I/O请求描述和一段通道程序,连数据怎么组织、设备怎么调度都不管了。这也是大型主机里常见的设计,本质上是用专用处理器换CPU的处理时间。你可能接触不到这种级别的机器,但这个思路理解透了,对理解现代网卡的RSS队列、多队列磁盘控制器都有帮助。
4. 数据传送控制方式在I/O体系中的位置与影响范围
单独背四种方式只是第一步,更值钱的是搞明白它们在完整I/O系统里站在什么位置,以及它们跟缓冲管理、驱动程序、中断处理这些“邻居”是怎么配合的。
4.1 I/O软件层次:控制方式落在哪一层
操作系统的I/O软件通常分成四个层次,从用户态到内核态分别是:用户层I/O软件、设备独立性软件、设备驱动程序、中断处理程序。数据传送控制方式主要落在设备驱动程序和中断处理程序这两层。
用户层I/O软件负责把人可读的请求(比如fread读取文件)转成抽象的I/O命令;设备独立性软件解释命令、计算缓冲区位置、格式化数据;真正下发控制指令的是设备驱动程序,它知道设备的寄存器地址、控制协议细节,能向设备控制器发出“开始DMA传输”之类的指令;而中断处理程序是数据传送过程结束后的收尾者,它响应设备发来的中断,唤醒等待I/O的进程,或者启动下一轮传输。
把这套层次跟控制方式联系起来,你会发现一个有趣的现象:控制方式的升级并不改变上层接口,fread还是那个fread,open还是那个open,变的只是底层驱动程序和中断处理程序的实现细节。对应用开发者来说,这层抽象把底层翻天覆地的变化完全屏蔽了,这是你有底气在用户态写出跨设备代码的根本原因。
4.2 缓冲管理:为什么DMA不代表什么都不管
DMA把数据直接搬进内存,听起来很美,但有一个问题:内存里必须有一块地方,而且这块地方得是连续的、物理上可用的。如果CPU在读数据之前没有提前准备好缓冲区,DMA就算想搬也没地方放。于是缓冲管理就跟数据传送控制方式牢牢绑在一起。
最常见的缓冲方式有四种。单缓冲:一块缓冲区,设备先把数据填进去,CPU再从里面取走,两个动作不能重叠,效率最低。双缓冲:两块缓冲区交替使用,设备填一块、CPU读另一块,双方不必互相等,能一边生产一边消费。循环缓冲:把多个缓冲区排成环形队列,适合连续的数据流,比如音频播放或者视频采集。缓冲池:由系统维护一批大小不等的缓冲区,供多台设备动态申请和释放,这是通用操作系统里的常见方案。
缓冲区数量多少,直接影响DMA传输的效率。我做过一个抓包工具的性能优化,发现网卡DMA到达的速率远高于应用消费速度,应用层偶尔来不及取走缓冲区,结果把环形缓冲填满了,网卡只好丢包。后来我把环形缓冲区从64个增加到512个,丢包率立刻降了一个数量级。数据传送控制方式决定数据怎么从设备进内存,缓冲管理决定数据到了内存之后怎么排队等待处理,两者是一对搭档,缺一不可。
4.3 在现代存储与网络场景中的体现
很多同学学完这章觉得过时了,其实不是。现在的SSD、NVMe协议、万兆网卡,核心机制依然是DMA思想,只不过花样更多了。NVMe的设备队列就支持把多个读写命令组成队列交给控制器,控制器自己按优先级处理,处理完再通过中断通知CPU,这和通道控制方式的“批处理”思路是同一个道理。网卡驱动里的多队列和中断合并(coalescing)机制,本质上也是为了降低DMA完成中断对CPU的骚扰频率:让网卡攒一批包再统一中断一次,而不是每个包打断CPU一次。
嵌入式设备里也有类似演进。你在调试某个物联网设备时,可能看到过“PIO模式”和“DMA模式”两种配置。PIO就是程序直接控制的继承者,CPU一点一点搬运;DMA模式则把搬运交给硬件。很多设备默认跑PIO也能工作,但传输大块传感器数据时CPU占用率居高不下,切成DMA模式后,CPU轻松一大截。这些都是四种控制方式在现代硬件上的直系后代,理解了历史脉络,看新硬件的数据手册会顺手很多。
5. 常见理解误区与排查思路实录
最后这一节我整理一下最容易踩的坑,顺便给你一套排查思路。我见过太多人在这个地方绕晕,有些问题甚至驱动写了两三年才真正想明白。
5.1 初学者最容易被绕晕的几个点
先列一张速查表,把高频误区一次说清楚:
| 误区 | 正确理解 |
|---|---|
| “中断方式比轮询方式快” | 从CPU利用率角度是对的,但数据仍在经过CPU,吞吐量瓶颈还在 |
| “DMA不需要CPU参与” | DMA开始前需要CPU初始化参数,结束后需要CPU响应中断,只是中间不参与 |
| “DMA就是通道” | DMA是定制搬运工,通道能执行I/O程序、管理多设备,复杂度不同 |
| “缓冲区越多越好” | 缓冲区多会占用内存并增加调度开销,够用才是最优 |
| “所有设备都适合DMA” | 键盘这类低速设备用中断更合适,DMA反而增加硬件复杂度 |
还有一个抽象但很重要的点:数据传送控制方式的选择,不是越高级越好,而是匹配设备的速度和系统成本预算。低速设备上挂了昂贵通道硬件,是浪费;高速设备上坚持轮询,是自杀式设计。
5.2 排查“设备慢”问题的实际切入点
如果你在实际项目里遇到外设传输慢、CPU占用高,我建议你这样排查。
第一步,确认当前设备是什么IO模式。很多驱动里都有参数可以查,比如某个DMA控制器有寄存器显示当前是PIO还是DMA。如果确认在跑PIO,优先切到DMA,CPU占用会立刻降下来。
第二步,看中断频率。用系统性能工具观察“中断数量”是不是和吞吐量成正比增长,如果中断数大得离谱,说明数据粒度太小,该考虑开启中断合并,或者把驱动里的“批量传输阈值”调大,让驱动攒够一定量数据再发起传输。
第三步,检查DMA缓冲区是否分配在可访问的物理内存范围。有的嵌入式平台上DMA无法访问高地址内存,如果驱动手里的缓冲区地址超出了可访问范围,DMA传输会直接失败或者数据错乱,这个问题排查起来非常隐蔽。
第四步,验证DMA描述符链是否正确。多块非连续内存交给DMA传输时,通常要组织成描述符链,任何一个描述符的地址或长度写错,数据就会乱掉。调试时可以打印每块内存的物理地址和长度,逐项核对。
这套思路我实践过很多次,尤其在把一套老驱动适配到新主控平台时,几乎每次都稳定复现这几个坑。记住一个原则:数据传送控制方式不是孤立的硬件特性,它是一个需要驱动、中断、缓冲、物理内存共同配合的系统工程,任何一环掉链子,最终都会表现为“设备慢”或者“CPU高”。
我个人在后来的工作中有一个很深的体会:这章内容表面上是在讲历史演进,其实是一条完整的设计哲学路线——把系统的关键资源从低效的等待和重复中解放出来。轮询是亲力亲为,中断是随叫随到,DMA是交给专业的人,通道是交给专业的管理者。你写驱动、做性能调优时,反复思考这句话,很多选择自然而然地就对了。如果后面你在自己的项目里观察到某个设备特别吃CPU,不妨先别急着优化算法,回去看看它的数据传送控制方式是不是还在用老一套——往往改这一下,效果比什么奇技淫巧都明显。