一聚教程网:一个值得你收藏的教程网站

热门教程

DMA 不只是设备绕过 CPU 访问内存这么简单

时间:2026-06-12 09:18:48 编辑:袖梨 来源:一聚教程网

很多人第一次接触 DMA,脑子里都会先记住一句话:

不经过 CPU,外设直接访问内存。

这句话不能算错。

但如果你真的拿着这句话去理解驱动,去看网卡、看 NVMe、看音视频采集,后面大概率还是会越来越糊。

因为 DMA 真正改变的,不只是“少拷贝一次数据”,也不只是“让 CPU 省点事”。它真正改变的,其实是:整个系统里数据流动的方式。

平时我们一说高性能 I/O,脑子里会想到很多东西:网卡收包、NVMe 读写、GPU 提交命令、视频采集、音频流处理。这些场景看起来差别很大,但底下往往都绕不开同一个东西:DMA。

所以 DMA 这个概念,表面上看不难,真到系统里一落地,反而特别容易把人绕进去。尤其是一旦系统里有 Cache,很多事情就会突然变得不再直观。

你明明觉得数据已经写好了,设备却像没看见。你明明觉得 DMA 已经写回来了,CPU 读出来却还是旧值。代码看上去没什么问题,但结果就是不对。

这时候如果还只是把 DMA 理解成“设备绕过 CPU 访问内存”,通常就不太够了。

因为真正让 DMA 变复杂的,不只是设备和内存。而是当系统里有了 Cache 之后,事情会慢慢变成:CPU、Cache、Memory、Device,这四者之间的数据可见性问题。

而 DMA 最难的地方,往往也就卡在这里。

先别急着谈 Cache。先把最基础的问题想清楚:

DMA 到底解决了什么?

如果没有 DMA,设备和内存之间交换数据,通常得让 CPU 自己参与。比如设备要把数据送进内存,路径大概就是这样:

Device → CPU → Memory

如果反过来,要把内存里的数据发给设备,也差不多:

Memory → CPU → Device

这种方式一般叫 PIO,Programmed I/O。

说白了,就是 CPU 自己一趟一趟搬。

这种模式最大的问题,不在于“不能用”,而在于太浪费 CPU

因为 CPU 原本应该做的是控制、调度、协议处理、业务逻辑。结果现在却被拉去干体力活:不停 load,不停 store,把数据从一边搬到另一边。

DMA 的出现,就是把这件事拆出去。

它不是让 CPU 搬得更快。而是干脆说:这件事别让 CPU 一条指令一条指令去搬了,交给 DMA 去做。

CPU 自己只负责三件事:

  • 先把参数配好;

  • 告诉 DMA 可以开始了;

  • 等搬完之后,再处理后面的逻辑。

所以 DMA 的本质,不是“更快的 memcpy”。

更接近真实的说法应该是:

DMA 把数据搬运这件事,从 CPU 的指令路径里剥离了出去。

这句话,比“绕过 CPU 访问内存”更接近它的核心。

到这里,很多人会继续问:那 DMA 为什么能提升性能?

原因其实并不神秘。

最直接的一点,是 CPU 不再是搬运工了

以前在 PIO 模式下,CPU 得一下一下地读设备、写内存,或者从内存读出来,再写给设备。这个过程机械、重复,而且很占时间。用了 DMA 之后,CPU 至少可以把精力用在更值得它做的事情上。

第二点,是 DMA 特别适合大块数据传输。网卡包、NVMe 数据块、视频流、音频流,这类东西有个共同点:数据量大,而且是连续流动的。

这类工作交给 DMA 来做,天生就比让 CPU 一点点搬更合适。

还有一点很关键,但经常被一句话带过去,那就是:CPU 和 I/O 可以并行了。

DMA 在后台搬数据的时候,CPU 不一定非得原地等着。CPU 可以去做别的事,DMA 也在另一边继续跑。现代系统之所以能做出高吞吐 I/O,背后很大一部分基础,其实就是这种并行。

所以 DMA 不是一个“小优化”,它更像是现代高性能 I/O 的基础能力。

没有它,很多事情都能做,但很难做得漂亮。

DMA 真正好理解的时候,往往是在一个具体场景里。

比如,最经典的:网卡收包

假设现在网卡收到了一个网络包,这个数据最终要进内存,然后操作系统协议栈再来处理。

如果按最笨的方式来,当然也能做:先让 CPU 从网卡那边把数据读出来,再由 CPU 搬进内存。

但真这么干,CPU 很快就会忙不过来。

实际系统一般不是这么做的。

驱动通常会提前准备好一批接收缓冲区,也就是 RX ring 里的那些 buffer。这些 buffer 放在内存里,驱动会把它们对应的 DMA 地址交给网卡,相当于提前告诉它:

“后面你收到包,就直接往这些位置写。”

等网卡真的收到数据以后,它就通过 DMA,把数据直接写进这些 buffer。整个搬运过程里,CPU 不负责逐字节复制。

等数据写完了,网卡再通过中断或者轮询通知 CPU:

“数据已经到了,你现在可以来处理了。”

这时候 CPU 才开始干它真正该干的事:解析包头、跑协议栈、把数据交给上层应用。

所以这个场景里,分工其实非常清楚:DMA 负责把数据搬进来。CPU 负责在数据到了之后,处理控制和逻辑。

你一旦把网卡收包这个过程想顺了,对 DMA 的理解就会比单纯背定义靠谱很多。

如果系统里只有 CPU、Memory、Device 这三个角色,其实很多事情还没那么绕。真正让 DMA 开始变难的,往往是 Cache

因为现代 CPU 平时访问数据,通常不是每次都直接去内存,而是优先看 Cache。这时候,系统里的参与者就不再只是三个,而是变成了:

CPU / Cache / Memory / Device

而问题也恰恰出在这里。

设备做 DMA 时,它看到的通常是 Memory。CPU 平时读写数据时,它先看到的往往却是 Cache。

只要这两个视角没同步好,麻烦就来了。

很多 DMA bug,看起来表面现象千奇百怪:设备读到旧数据、CPU 看不到 DMA 更新、偶发脏数据、CRC 错、丢包、花屏。

但往下追,经常最后都能归到同一个问题上:CPU 看到的数据,不一定等于内存里的数据。

这个地方,才是 DMA 真正开始变复杂的起点。

最典型的第一种情况,是:CPU 写了新数据,但设备读到的还是旧数据。

比如 CPU 往一个 buffer 里写了一堆内容。从 CPU 的角度看,这事已经结束了,因为代码已经写完了。但实际上,这些新数据可能还停留在 cache 里,并没有及时回写到 memory。

这时候设备去做 DMA 读取,它读到的是 memory 里的内容。而 memory 里如果还是旧值,那设备拿到的自然也是旧数据。

于是现象就会变成:CPU 明明写过了,设备却像没看见。

这类问题在发包、描述符更新、命令提交这类场景里都特别常见。

第二种情况,正好反过来:设备已经 DMA 写回内存了,但 CPU 看到的还是旧值。

比如设备通过 DMA 把新数据写进了 memory。但 CPU 之前已经读过这个地址,对应的 cache line 还保留着老内容。那 CPU 再去读的时候,很可能直接命中 cache,看到的仍然是旧数据。

从设备的角度看,数据明明写好了。从 CPU 的角度看,像是什么都没发生。

这个感觉特别让人头疼,因为双方都没“错”,只是它们根本没在看同一份东西。

所以很多 DMA 问题,真正麻烦的地方并不是“搬运有没有发生”,而是:这份数据现在到底对谁可见。

讲到这里,很多人会发现另一个现象:

为什么有的平台上写 DMA 驱动,感觉没那么痛苦;有的平台上却老是出各种奇怪问题?

这里就得区分两个概念了:DMA coherent,和 DMA non-coherent。

有的平台会在硬件层面帮你维护一致性。CPU 这边写了,设备那边比较容易看到;设备 DMA 写了,CPU 这边也比较容易看到更新。这类平台上,驱动开发体验会轻松很多。

但也有很多平台并不会自动替你兜这些事。这种情况下,Cache 和 Memory 之间的一致性,需要软件自己显式处理。DMA 前该同步的同步,DMA 后该失效的失效,不然 CPU 和设备看到的就很容易不是同一份数据。

很多嵌入式 SoC,尤其 ARM、RISC-V 这类平台上,DMA 问题之所以容易让人头大,很大程度上就是因为这里没有被自动抹平。

这也是为什么,同样是 DMA,大家的“使用体验”会差很多。不是 DMA 这个概念变了,而是平台替你做了多少事情,不一样。

然后很多人会很自然地想到一个办法:那我自己 flush cache,不就行了吗?

这个念头特别正常,但真写驱动时,通常不能这么想得太简单。

因为 DMA 面对的,从来不只是一个 cache flush 的问题。它经常还连着地址转换、IOMMU、内存屏障、设备可达地址范围这些事情。你以为自己在修一个 cache 问题,实际上碰到的是整套 DMA 可见性和访问语义的问题。

而且不同架构下,cache 操作方式本来就差很多。x86、ARM、RISC-V 的处理方式不一样,平台能力也不一样。驱动如果自己把这些细节写死,通常既不好维护,也很难移植。

所以 Linux 才提供了 DMA API。

像dma_alloc_coherent()、dma_map_single()、dma_unmap_single()、dma_sync_single_for_cpu()、dma_sync_single_for_device() 这些接口,表面上看像是一组函数,实质上描述的是另一件事:

CPU 和设备之间,这份数据的使用权和可见性,什么时候发生切换。

也就是说,它们不是单纯在做“地址转换”或者“cache 操作”,而是在帮你表达:

现在这块数据要交给设备了;现在这块数据又交还给 CPU 了。

这么去理解 DMA API,会比把它们当成几个零散工具函数更顺。

除了可见性问题,DMA 还有一个特别容易让新手误判的点:

DMA 地址不是 CPU 随手拿到的那个指针。

很多人第一次写 DMA 时,都会下意识地觉得:

“这块 buffer 是我申请的,我手里已经有地址了,直接给设备不就行了吗?”

但这件事,在概念上其实并不成立。

因为 CPU 代码里拿到的,往往是 CPU 自己视角下的地址。而设备做 DMA 时,需要的是设备自己能用的地址

中间可能会经过物理地址转换,可能有 IOMMU,也可能有平台自己的地址重映射。所以 CPU 能用的地址,不一定就是设备能直接拿去发 DMA 的地址。

这也是为什么驱动里,明明你已经有了一个 buffer,还会再冒出来一个 DMA address。

看起来像多此一举,其实不是。

它只是提醒你一件事:CPU 的地址,不等于设备的地址。

所以 DMA 真正难的地方,常常也不在“怎么搬”,而在:地址该怎么理解,数据什么时候对谁可见。

讲到这里,其实很多原来看起来很玄的现象,就都能解释了。

为什么驱动里数据明明填好了,设备却读到旧内容?因为 CPU 看到的可能只是 Cache 里的新值,Memory 那边还没同步过去。

为什么 DMA 明明已经完成了,CPU 再读还是老数据?因为设备写进的是 Memory,CPU 可能还在看自己的旧 Cache。

为什么同样的驱动逻辑,在一个平台上没事,换个平台就开始出现各种怪问题?因为平台对 DMA 一致性的处理方式不同,有的帮你做了很多,有的几乎没帮你做。

为什么 DMA API 不能随便省,不能觉得“我直接传指针也差不多”?因为它本来就在帮你处理“设备能看到什么、CPU 能看到什么”这件事。

很多时候,并不是 DMA 本身很神秘。而是你如果一直把它理解成一句很粗糙的话——“设备直接访问内存,不经过 CPU”——后面那些真正重要的问题,就都被藏起来了。

如果非要把这篇文章最后再压成一句话,那我会更想写成这样:DMA 真正难的地方,不是搬数据,而是这份数据在当前这一刻,到底对谁可见。

而如果再进一步压成一句最适合当结尾的话,那我会写:别只盯着数据搬没搬过去,更要先问一句:现在这份数据,CPU 和设备看到的是不是同一份。

因为 DMA 里的很多坑,难不在概念有多高深,而在于它把“数据搬运”这件事,变成了一个多视角的数据可见性问题

一旦你把这一层想明白,很多原来看起来很诡异的 DMA bug,突然就没那么玄了。

作者丨言谦
来源丨网址:https://zhuanlan.zhihu.com/p/2019404823229899580
dbaplus社群欢迎广大技术人员投稿,投稿邮箱:[email protected]

热门栏目