Skip to content

DMA:让设备自己搬数据,把 CPU 从苦力活里解放出来

只要你写过的程序读过盘、收过网,就一定享用过 DMA,只是未必知道它叫这个名字。从磁盘把一个文件搬进内存,几万字节凭空就从设备流进了内存条——这中间 CPU 其实没怎么出力。这一篇咱们就讲清楚:DMA 到底是个什么东西,它凭什么能替 CPU 把搬运的苦力活干了,以及为什么后面 Cinux 的存储驱动里那些「缓冲必须连续物理」「scatter-gather 段表」的反直觉讲究,全都根植于 DMA 的工作方式。不懂这些,后面 DMA 池那一章就像在念咒。

先垫一块底:外设的数据是怎么进内存的

聊 DMA 之前,得先把「设备和内存之间怎么交换数据」这件事说清,不然 DMA 无从谈起。

CPU、内存、各种外设(磁盘控制器、网卡、USB 控制器),物理上全挂在同一组总线(bus)上。总线就是它们之间搬数据的高速公路。数据要从磁盘进内存,本质是在这条公路上跑一趟。问题来了:这一趟,由谁来开车? 历史上答案有过三种,一种比一种省 CPU。

最古老的办法叫程序控制 I/O(programmed I/O,常简称 PIO,也叫轮询)。CPU 亲自当司机:它执行一段循环,每轮从设备的端口寄存器读出一个字、写进内存,循环到搬完为止。听起来直白,代价是 CPU 全程被拴死——搬一个 4 KiB 的页要四千多次循环,这期间 CPU 干不了任何别的事,而它的算术单元、流水线全闲着却走不开。

进一步的办法是中断驱动 I/O。CPU 发一个命令让设备准备数据,然后去干别的;设备每搬好一个字节(或一个字)就拉一次中断,CPU 跳进中断处理程序,把这份数据挪进内存。这比 PIO 强在 CPU 不用死等,可每个字节都要中断一次,CPU 还是被打断得稀碎,中断本身的开销(保存现场、恢复现场)吃掉了不少省下来的时间。

第三种,就是这一篇的主角——DMA(Direct Memory Access,直接内存访问)。

为什么 CPU 搬数据是浪费

在讲 DMA 怎么干之前,咱们先想清楚一件事:CPU 亲自搬数据,到底浪费在哪?

CPU 每个时钟周期能发出一次总线读写。搬一个 4 KiB 的页,就是 4096 次读写,而这 4096 个周期里它做的全部事情,就是「从设备读一个字、往内存写一个字」这种最机械的动作。它的整数单元、浮点单元、流水线的预测和乱序执行,通通帮不上忙——它们能做极复杂的运算,可搬运这活儿压根用不上这些本事,CPU 却被钉在原地走不开。

打个比方。你是这家公司里最贵的工程师,一天能写几千行代码。现在仓库到了一批货,需要搬到办公室。老板偏让你去搬——你当然搬得动,可你搬货的时候一行代码也写不了,而你的身价是按写代码算的。聪明的做法是雇个搬运工,你只交代一句「这批货从 A 搬到 B、一共 N 箱」,搬完他回来喊你一声,你全程在工位上写代码。

DMA 就是那个搬运工。

DMA 控制器:一个会自己开车的小硬件

DMA 背后是一个独立的硬件,叫 DMA 控制器(在系统里常被称为 bus master,总线主控)。它和 CPU 一样挂在总线上,区别在于它能自己当总线的司机——往总线上发地址、发读信号、发写信号,把数据从一个地方搬到另一个地方。

CPU 和它之间的接口简洁得很,就是一组寄存器:源地址、目的地址、要搬多少字节、传输方向、完成中断。CPU 把这几项填好、启动,DMA 控制器就接管总线上这段传输。整块数据搬运期间,CPU 完全不参与。 搬完,DMA 控制器拉一次中断通知 CPU,CPU 一看「好了」,数据已经躺在内存里等着用了。

这里有个关键的对比值得记住:PIO 搬一个 4 KiB 页,CPU 要忙四千多个周期;DMA 搬同样一页,CPU 只花寥寥几个周期下达命令,剩下的时间全是它自己的。这才叫把 CPU 真正解放出来去做它擅长的事——算逻辑、跑进程,而不是在总线上当搬运工。

真正的讲究:DMA 不过 MMU,它只认物理地址

讲到这里,DMA 像是个百利无害的好东西。可它有一条根本性的限制,后面 Cinux 存储驱动里所有那些反直觉的设计,根子全在这——

DMA 控制器是外设,它发出的地址直接上总线、直达内存,不经过 CPU 的内存管理单元(MMU),看不懂虚拟地址。 CPU 平时用得很顺手的那些虚拟地址,在 DMA 控制器眼里是无效的;它能认的,只有物理地址

这条限制引出两件必须接受的事。

第一,DMA 缓冲要么在物理上连续,要么走 scatter-gather。 一台最朴素的 DMA 控制器,CPU 给它一个起始物理地址和一个长度,它就从那个地址开始、线性递增地搬够那么长。它不懂「这段虚拟地址在物理上其实散落在几个不连续的页里」——它只会一根筋地往前走。所以你交给它的缓冲,一旦在物理上断开了,它搬到断点就冲进了错的物理地址,数据写花、内存踩烂,都是轻的。

这就解释了为什么 Cinux 后面要专门搞一个 DMA 池:普通的内存分配器给你的是虚拟地址,而且很可能在物理上东一块西一块;DMA 用不了这种东西。DMA 池要保证分配出来的是连续的物理页,再把这段物理映射到一个内核能访问的虚拟地址上,两头各取所需。

第二,现代设备用 scatter-gather 打破「必须连续」的枷锁。 连续物理缓冲是个挺苛刻的要求——内存用久了会碎片化(这个咱们在《内存碎片化》那篇讲过),要随时凑出一大块连续物理页并不总是办得到。于是稍微强些的 DMA 控制器,以及绝大多数现代存储设备,支持一种叫 scatter-gather(分散-聚集)的模式:CPU 不再只给它一个起始地址加长度,而是给它一张段表——每一段写明「一个物理地址 + 这一段多长」——它照着表一段一段地搬、自己负责在段之间跳。这么一来,物理上不连续的若干页,也能拼成一次完整的传输。

Cinux 里 AHCI 的 PRDT、NVMe 的 PRP,干的就是这件事:把上层交来的、可能不连续的缓冲,拆成一段段设备能消化的描述,交给控制器逐段搬。它们长得不一样、格式各异,但背后的道理是同一个——给一个不懂虚拟地址、只会在物理地址上线性递增的硬件,喂一张「哪几段物理、各多长」的清单。

这一篇留下了什么

把 DMA 讲透,是为了给后面存储驱动那一连串的「为什么非这么设计」铺一块底。一句话概括:DMA 把数据搬运的苦力活从 CPU 卸载给一个独立的总线主控硬件,CPU 只管下命令、等中断。

但这个卸载不是白来的,代价就是那条铁律——DMA 不过 MMU,只认物理地址。从这条铁律往下,自然推出两件事:给 DMA 的缓冲必须用物理地址(普通的虚拟地址分配满足不了);而且要么在物理上连续(给最朴素的控制器),要么走 scatter-gather 段表(给现代控制器),因为 DMA 控制器只会在物理地址上线性递增,它不会自己替你拼那些散落的虚拟页。

下一篇咱们就看 Cinux 怎么把散落在各个驱动里、口径不一的 DMA 代码,收成一个设备无关的池子——那个池子要替所有驱动管好的,正是这一篇讲的「给 DMA 一段连续的物理缓冲、再把它映射到内核能用的虚拟地址」。

035_multi_terminal-45-gf25de18 · f25de18 · 2026-08-04