先把术语摆正:DMA 是"设备自己搬数据"的能力
直接内存访问(Direct Memory Access,DMA)指的是这样一种机制:某些硬件子系统可以不经过 CPU,直接访问主存。它对应的反面是程序控制 I/O(Programmed I/O,PIO)——每一次数据单元传送都由程序里的一条指令发起,CPU 在整个传送过程中都要参与。换成 DMA 之后,CPU 只负责启动这次传送,随后可以去干别的活,等传送结束再收到一个完成通知来收尾——这个通知通常是一次中断,由 DMA 控制器或设备本身发出,取决于 DMA 的实现方式。
这个定义听起来简单,但它牵出三个必须先分清的东西:地址、控制权和完成通知。工控现场遇到的"采集卡跑不满带宽""多串口卡一开高速率 CPU 就顶满""加了 IOMMU 之后老卡不认",追下去大多落在这三点上,而不是"卡不行"。
先看地址。一次 DMA 传输里同时存在三种地址:CPU 用的虚拟地址、CPU 的物理地址、以及设备在总线上使用的总线地址。虚拟地址由页表与 TLB 转换为物理地址,物理地址到总线地址的转换则由 IOMMU 或主机桥完成;设备能直接使用的是总线地址。在很多系统上总线地址和物理地址相同,但在有 IOMMU 或主机桥做转换的平台上并不相同——Linux 内核的 DMA API 文档把这层关系画得很清楚:驱动拿到的虚拟地址要交给 dma_map_single() 之类的接口,换回一个可供设备使用的 DMA 地址,中间可能由 IOMMU 完成到物理地址的映射。所以驱动不能把 CPU 侧的指针直接塞给设备,设备也不认识虚拟地址。
一次搬运里,四方各干什么
把一次 DMA 传输拆开看,参与方和分工是这样的:
- CPU:准备内存缓冲区,填写控制信息(地址、长度、状态标志),打开设备的 DMA 使能位,然后等完成通知;结束后回收缓冲区、更新队列指针。
- 设备或 DMA 引擎:按准备好的信息生成总线地址,主动发起存储器读/写周期,把数据搬到位。
- 总线、主机桥与 IOMMU:负责地址转换、事务排序与访问隔离。
- 系统内存:数据的目的地或来源。
在 PCI/PCIe 设备上,还有一道很具体的开关:配置空间里 Command 寄存器的第 2 位是总线主控使能位(Bus Master Enable)。Linux 内核的 pci_regs.h 把它定义为 PCI_COMMAND_MASTER 0x4,注释就是 "Enable bus mastering"。这一位不置起来,设备即使内部逻辑准备好了,也不能发起 DMA 请求。排查"卡在、驱动也在,就是不搬数据"时,这一位和它的上层开关值得先确认。
顺带说明一个容易混淆的说法。PCIe 是点对点链路,不存在传统共享总线那种"多个设备抢一条总线、由仲裁器发放使用权"的过程。但"总线主控"这个词沿用下来了,含义变成"设备有能力主动发起存储器读写请求"。需要总线仲裁的是允许多个设备争用同一条总线的架构:当多个设备同时想当主设备时,必须靠仲裁机制防止总线争用。
两种实现:第三方 DMA 与总线主控
按"谁在生成地址"来分,DMA 有两条实现路线。
第三方 DMA(也叫标准 DMA)由一个独立的 DMA 控制器完成搬运。CPU 先把要传的字数和内存地址写进控制器的寄存器,再命令外设开始;之后由控制器提供地址与读写控制信号,每完成一个数据单元的传送就把内部地址寄存器推进到下一个单元(推进的粒度取决于控制器与外设,可能是字节、字或双字),直到整块数据传完。经典 PC 架构里的 DMA 控制器就属于这一类,而 ISA 的 DMA 通道只覆盖 24 位物理地址,缓冲区必须落在 16 MB 以下——这也是后来一些"ISA 芯片加 PCI 前端"的设备仍然保留 16 MB DMA 寻址限制的由来。
总线主控(first-party DMA)则把主动权交给外设本身:设备自己成为总线主设备,直接向系统内存写入,地址与控制信号由设备提供,CPU 不参与搬运过程。PCI 及之后的架构普遍允许设备成为总线主控,这也是通用操作系统上 DMA 性能明显更好的原因。需要注意的是,某些实时操作系统会禁止外设成为总线主控,因为在调度器失去对总线的掌控后,就无法给出确定性的延迟保证——这一点对运动控制类应用是有实际意义的取舍。
共享总线时代还留下三种总线占用方式,它们今天仍能解释不少现象:突发模式把整块数据一次传完再交还总线,CPU 被"挂起"的时间较长;周期窃取模式每次只传一个单元就交还总线,随即再次请求,CPU 与 DMA 交替使用总线,速度慢一些但不会被长时间阻塞;透明模式只在 CPU 不使用总线时传送,CPU 从不停机,代价是 DMA 得不到优先保证。现代 PCIe 上不再有这套 BR/BG 握手,但"一次搬多大块、什么时候让出通道"的选择,仍然以描述符大小、批量和中断合并的形式存在。
为什么这件事值得当成概念来学:CPU 占用与可扩展性
不用 DMA 的系统并非不能工作,代价体现在 CPU 上。PIO 的每个数据单元都由程序里的一条指令发起,CPU 要参与整个传送过程,数据率越高,被占用的 CPU 时间越多,留给应用的时间越少。串口是个典型例子:16550 系列 UART 带 16 字节 FIFO,可以按可编程的触发深度产生中断,把"每字节一次中断"降到"每若干字节一次";而更早的 8250/16450 只有 1 字节接收缓冲,每收到一个字节就中断一次,速率一高就既容易丢字节又占满 CPU。多串口卡的做法通常是"大 FIFO + 中断 + 必要时 DMA",让主机按块而不是按字节介入。
采集卡、网卡这类块数据传输的设备更依赖 DMA。它们的共同特征是:数据量大、连续、对 CPU 参与次数敏感。没有 DMA,主机只能靠 PIO 一条一条读设备寄存器;有了 DMA,主机把缓冲区的地址交出去,等一整块到位再处理。这里的关键不是"省了几条指令",而是把 CPU 的参与次数从"每个字节"降到"每个数据块"——这正是多通道、高帧率时系统还能留出算力的原因。
描述符与环形队列:DMA 的控制面
设备怎么知道该往哪搬、搬多少?靠描述符(descriptor)。一条描述符通常包含缓冲区的总线地址、长度,以及若干状态位:表示这块可用的有效/拥有位一般由 CPU 设置,表示这块已处理完的完成与状态位由设备写回。多个描述符串成一个环形队列(ring),设备按序取用,驱动按序回收,两边通过头尾指针交换进度。
环形队列带来两个直接好处。第一是批量:一次提交多个描述符,设备连续工作,CPU 不必每块数据都介入。第二是解耦:驱动可以在生产端填充、在消费端回收,两端的节奏允许有偏差,短时抖动被队列吸收。队列深度由此变成一个真实的工程参数——太浅,突发流量下容易溢出丢数据;太深,则单块完成的时延变长、内存占用上升。
如果数据分散在多个不连续的缓冲区里,还需要散列/聚集(scatter-gather):一次传输在多个缓冲区之间搬运同一路数据流,而不是要求一整块连续内存。这正是向量化 I/O 的思路——一次调用在多个缓冲区与单一数据流之间读写。对采集卡来说,这意味着一帧图像的分段缓冲也能一次提交完,不必为了"连续"而做额外拼接。
还有一层容易被忽略:描述符本身也必须让设备和 CPU 都能看到,而且要看得一致。Linux 内核文档举的正是这个例子——网卡的 DMA 环描述符属于典型的一致性(coherent)映射用途,因为设备随时可能读写它,要求 CPU 的写入立刻对设备可见、反之亦然。
地址、缓存与顺序:最容易出错的三个地方
第一,映射方式选错。内核把 DMA 映射分成两类。一致性映射通常在驱动初始化时建立、在卸载时释放,硬件保证设备与 CPU 能并行访问同一块数据并互相看到更新,无需软件显式刷新,适合描述符环、命令邮箱、设备固件微码这类长期共享的结构。流式映射则是"为一次传输而映射",传完即解除,硬件可以针对顺序访问做优化,适合收发缓冲区、文件系统读写缓冲这类一次性数据。把一次性的数据块做成长期一致性映射,或者反过来把描述符环当流式用,都会带来不必要的开销或同步负担。
第二,以为"一致性"就等于"不用管顺序"。一致性只保证可见性,不保证顺序。CPU 可能重排对内存的写入,所以如果设备要求在读到描述符的第二个字之前,第一个字(地址)已经写好,就必须插入写屏障。内核文档给的写法是先写地址、执行 wmb()、再写有效标志。这个细节在自研板卡配套驱动时最容易漏,症状是"偶尔搬到旧地址"或"偶发数据错位"。
第三,缓冲区来源不合规。不是所有内核内存都能拿来做 DMA。通过 vmalloc() 拿到的内存不能直接用于 DMA(它背后是分散的物理页),内核镜像、模块镜像和栈上的地址同样不行,kmap() 的返回值也不行。此外,DMA 地址掩码要按设备能力设置:内核默认按 32 位 DMA 寻址处理,64 位设备需要显式提高掩码,能力受限的设备则需要降低,否则映射可能落在设备够不到的地址上。
这些机制怎样影响选型与性能判断
把上面的内容压成可检查的几条,选型或调优时可以按顺序看:
| 判断点 | 看什么 | 对性能的影响 |
|---|---|---|
| 是否具备 DMA 能力并已启用 | 设备是否支持总线主控;配置空间 Command 寄存器的总线主控使能位是否置起;驱动是否调用了对应的映射接口 | 未启用时设备无法发起 DMA;若设备与驱动另有 PIO 路径并实际回退,则表现为 PIO 的高 CPU 占用 |
| 描述符与散列支持 | 是否支持 scatter-gather;单次传输的最大长度;环形队列深度是否可调 | 决定大批量数据能否一次提交,直接影响吞吐上限与突发容忍度 |
| 映射方式与缓冲区来源 | 典型做法是描述符环用一致性映射、数据缓冲用流式映射;缓冲区是否来自可 DMA 的内存 | 映射方式不匹配会带来多余的同步或缓存维护开销 |
| 地址空间与 IOMMU | 设备支持的 DMA 地址位宽;平台是否启用 IOMMU;是否存在 16 MB 之类的历史限制 | 限制过严会造成缓冲区被迫落在低端内存,甚至映射失败 |
| 完成通知的配合 | 每块数据一次中断,还是攒批后通知 | 事件频率高时,通知策略比 DMA 本身更影响 CPU 占用 |
这张表里最容易被跳过的是第一行。很多"性能不达标"的现场,设备本身支持 DMA,驱动也在,但某个环节没让总线主控真正生效:若设备与驱动另有 PIO 路径并实际回退,整条链路就变成 CPU 逐字节搬运,表现为"数据能收到,但一上速率 CPU 就满、延迟就抖";若没有 PIO 回退路径,则可能干脆搬不动数据。这类问题的定位成本远低于换硬件。
四个常见误解
- "用了 DMA,CPU 就完全不参与":不对。CPU 仍要准备缓冲区、填写描述符、打开使能、处理完成通知、回收缓冲。DMA 减少的是"每个数据单元的搬运",不是"整条数据通路的控制"。
- "DMA 一定比 PIO 快":不一定。小数据量、低频次时,建立映射与处理描述符的固定开销可能超过搬运本身,此时 PIO 反而更简单直接。DMA 的收益随单次传输长度和传输频率上升。
- "DMA 就等于零拷贝":两回事。DMA 解决的是设备与内存之间的搬运;数据从内核缓冲区到用户态缓冲区是否还要拷贝,取决于驱动与框架的设计。
- "一致性内存不需要内存屏障":不对。一致性保证可见性,顺序仍要软件显式约束,写屏障不能省。
小结
DMA 的核心只有一句话:让设备自己把数据搬进或搬出内存,CPU 只做发起与收尾。围绕这句话展开的三件事——地址怎么翻译、控制信息怎么排队、完成与顺序怎么保证——才是实际影响吞吐、CPU 占用和延迟抖动的地方。工控扩展卡上看到的"带宽上不去""多通道一开就卡",先按地址、描述符、映射三条线排查,通常比对着参数表横向比拼更有效。