我接手过不少嵌入式 Linux 项目,真正让我觉得“水很深”的,往往不是业务逻辑,而是 DMA。印象最深的一次,设备跑两三天就偶发死机,日志里只有一个空指针,完全看不出问题。后来把内核里分配的 CPU 地址、DMA 实际拿到的地址、总线上读到的地址逐层打出来,才发现 DMA 引擎从头到尾访问的都不是我脑子里想的那块内存。从那以后我有个习惯:任何驱动只要涉及 DMA,第一件事不是写代码,而是先把地址空间、映射方式和缓冲区生命周期画清楚。这篇文章就围绕“从缓冲区到传输”这条主线,把 Linux 内核里 DMA 管理的关键知识点、实战配置和排障思路完整梳理一遍。适合正在写或准备写 DMA 相关内核驱动的开发者,也适合做嵌入式 Linux 底层移植的工程师参考。
1. 为什么 DMA 的“地址”必须交给内核翻译
1.1 三个地址空间:虚拟地址、物理地址、总线地址
很多刚从 MCU 转来做嵌入式 Linux 的工程师,对 DMA 的第一印象是“把内存地址配给 DMA 控制器,它就会自己去搬运”。这句话在裸机环境下基本成立,但在 Linux 内核里必须加上一个前提:DMA 控制器几乎不会直接使用 CPU 的虚拟地址。
CPU 通过 MMU 访问内存,应用程序和内核代码里看到的指针都是虚拟地址。DMA 控制器是独立于 CPU 的硬件,它访问内存时通常走自己的总线端口。在简单的 SoC 里,DMA 看到的是物理地址;但只要系统中有 IOMMU/SMMU,或者总线地址空间和物理地址空间存在偏移,DMA 看到的地址就变成了“总线地址”或“设备地址”。这三个地址并不总是一样的。
dma_alloc_coherent、dma_map_single 这类 API 的核心价值,就是帮你完成从“CPU 能用的缓冲区”到“DMA 控制器能识别的地址”之间的翻译,并且把缓存一致性、IOMMU 映射这些脏活都隐藏起来。驱动作者要做的,就是永远不要假设 dma_addr_t 等于物理地址,更不要假设它等于你 kmalloc 返回的指针值。
1.2 直接传 kmalloc 地址给 DMA 控制器:为什么能跑是运气
我见过不少第一版驱动是这样写的:分配一块内存,拿到指针,强制类型转换成 u32,直接写进 DMA 控制器的地址寄存器。在某些 32 位 ARM 平台、内存不大、没有开 MMU 或 IOMMU 的情况下,虚拟地址低位和物理地址确实可能碰巧一致,所以功能能跑通。一旦移植到 64 位平台、内存超过某个阈值、开了 IOMMU 或者设备挂到 PCIe 后面,这种写法立刻出问题。
正确做法是使用内核提供的 DMA 映射 API。下面是最基础的一次性传输映射过程:
c复制void *buf;
dma_addr_t dma_handle;
buf = kmalloc(BUF_SIZE, GFP_KERNEL);
if (!buf)
return -ENOMEM;
dma_handle = dma_map_single(dev, buf, BUF_SIZE, DMA_TO_DEVICE);
if (dma_mapping_error(dev, dma_handle)) {
kfree(buf);
return -EIO;
}
/* 把 dma_handle 写到 DMA 控制器的地址寄存器 */
writel(lower_32bits(dma_handle), dev->base + DMA_ADDR_LOW);
writel(upper_32bits(dma_handle), dev->base + DMA_ADDR_HIGH);
/* 等待传输完成,然后释放映射 */
dma_unmap_single(dev, dma_handle, BUF_SIZE, DMA_TO_DEVICE);
kfree(buf);
这里 dma_handle 才是 DMA 控制器真正需要的地址。你用 kmalloc 返回的 buf 指针去配置 DMA,就是拿错地图导航。能跑只能说运气好,不能跑才是常态。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 选一致性映射还是流式映射,决定了你后面要不要填坑
2.1 dma_alloc_coherent:最省心的分配方式,但别拿它做高频大流量
dma_alloc_coherent 分配的缓冲区,CPU 和 DMA 控制器都能访问,而且系统保证两边看到的缓存是一致的。它的底层实现通常会利用 CMA 或者页表属性,把这段内存标记为不可缓存或非缓存一致区域。
这种分配方式适合描述符、环形 buffer 表、控制块、寄存器影子区等 CPU 和 DMA 都会频繁访问的小块内存。用起来非常简单:
c复制struct dma_desc {
u32 addr;
u32 len;
u32 ctrl;
};
struct dma_desc *desc;
dma_addr_t desc_dma;
desc = dma_alloc_coherent(dev, sizeof(*desc) * NUM_DESC, &desc_dma, GFP_KERNEL);
if (!desc)
return -ENOMEM;
之后 CPU 通过 desc 指针读写,DMA 硬件通过 desc_dma 访问,不需要手动做 cache sync。代价是这段内存可能长期占用较高的 TLB/缓存成本,而且分配和释放的开销不小。如果你拿它做每毫秒几十 KB 的流式数据搬运,性能大概率不理想。
2.2 dma_map_single/dma_map_sg:高性能的代价是手动管理 cache
流式映射的典型场景是:数据已经存在某块内存里(比如协议栈的 skb、文件系统的 page),你只想让 DMA 引擎搬运一次,搬完立刻归还。它不会长期占用一致性映射,开销更小,但你必须自己管理缓存同步。
以发送方向为例,CPU 写完缓冲区后,要保证缓存里的内容真的刷到内存,DMA 设备才能读到正确数据。这一步由 dma_map_single 在内部完成。传输结束后,CPU 想读这段数据(比如接收方向),需要用 dma_sync_single_for_cpu 作废缓存,否则 CPU 可能读到旧数据。常见写法:
c复制dma_handle = dma_map_single(dev, buf, BUF_SIZE, DMA_FROM_DEVICE);
if (dma_mapping_error(dev, dma_handle))
return -EIO;
/* 启动 DMA 接收,等待中断或 poll */
dma_sync_single_for_cpu(dev, dma_handle, BUF_SIZE, DMA_FROM_DEVICE);
/* 处理 buf 中的数据 */
dma_sync_single_for_device(dev, dma_handle, BUF_SIZE, DMA_FROM_DEVICE);
dma_unmap_single(dev, dma_handle, BUF_SIZE, DMA_FROM_DEVICE);
有人会问,接收方向明明不需要在 map 时刷 cache,为什么 dma_map_single 也要传方向?因为实现可能为了安全性或者统一逻辑做了额外操作。所以不要试图“优化掉” map 过程,只要用了 DMA 映射 API,就要遵守它的完整协议。
2.3 一张表看明白两种模型的差异
| 对比项 | dma_alloc_coherent | dma_map_single / dma_map_sg |
|---|---|---|
| 适用场景 | 描述符、控制块、环形队列等持久小对象 | 大块数据的单次/批量传输 |
| 缓存一致性 | 系统自动保证 | 需要手动 sync |
| 分配粒度 | 较大,申请/释放开销高 | 基于已有缓冲区,开销相对低 |
| 地址使用周期 | 长期持有 | 一次 map/unmap 周期有限 |
| 常见副作用 | 可能占用 CMA 连续内存 | 映射后禁止直接通过原指针乱序访问 |
这两套 API 不是竞争关系,而是配合关系。真实驱动里,环形队列的描述符表用 dma_alloc_coherent,数据缓冲区则用流式映射,非常常见。
3. 用 dmaengine 写一次真实传输:串口 TX 链路走读
3.1 从设备树拿到 dma channel
直接操作 DMA 控制器寄存器当然可行,但代码会绑死在某个 SoC 上。Linux 内核推荐的做法是通过 dmaengine 框架,让驱动和具体 DMA 控制器解耦。第一步是获取 DMA 通道。
设备树里通常这样描述串口的 DMA 能力:
dts复制uart0: serial@10000000 {
compatible = "...";
reg = <0x10000000 0x1000>;
dmas = <&dma0 0>, <&dma0 1>;
dma-names = "tx", "rx";
};
驱动里用 dma_request_chan 获取通道:
c复制struct dma_chan *tx_chan;
tx_chan = dma_request_chan(dev, "tx");
if (IS_ERR(tx_chan))
return PTR_ERR(tx_chan);
dma-names 里的 “tx” 必须和 dma_request_chan 的第二个参数一致。这一层出错很隐蔽,因为编译不会报错,运行时 dmesg 会提示 DMA channel request failed。
3.2 prep_slave_sg + submit + issue_pending:三步提交
拿到通道后,先配置从设备方向、目的地址和数据宽度:
c复制struct dma_slave_config cfg = {
.direction = DMA_MEM_TO_DEV,
.dst_addr = uart_base + UART_THR,
.dst_addr_width = DMA_SLAVE_BUSWIDTH_1_BYTE,
.dst_maxburst = 4,
};
dmaengine_slave_config(tx_chan, &cfg);
然后是构造 scatterlist、映射缓冲区、准备描述符和提交:
c复制struct scatterlist sg;
struct dma_async_tx_descriptor *desc;
dma_cookie_t cookie;
sg_init_table(&sg, 1);
sg_set_buf(&sg, buf, len);
dma_map_sg(tx_chan->device->dev, &sg, 1, DMA_TO_DEVICE);
desc = dmaengine_prep_slave_sg(tx_chan, &sg, 1, DMA_MEM_TO_DEV,
DMA_PREP_INTERRUPT | DMA_CTRL_ACK);
if (!desc) {
dma_unmap_sg(tx_chan->device->dev, &sg, 1, DMA_TO_DEVICE);
return -ENOMEM;
}
desc->callback = my_tx_done;
desc->callback_param = buf;
cookie = dmaengine_submit(desc);
dma_async_issue_pending(tx_chan);
到这里,传输已经交给 DMA 引擎了。DMA_PREP_INTERRUPT 表示完成后产生中断并回调,DMA_CTRL_ACK 表示描述符完成后可以被自动复用。提交后要保存 cookie,将来可以配合 dmaengine_pause/resume/terminate_all 做状态管理。
3.3 callback 里到底能不能直接 unmap?
这个问题我见过至少三种答案。实际经验是:可以在 callback 里 unmap,但必须保证 unmap 发生在 DMA 真正结束后。dmaengine 的 callback 通常在 DMA 完成中断里被调用,此时硬件已经不再访问这块缓冲区,所以可以在回调里安全地执行 dma_unmap_sg。
但要注意 callback 的执行上下文是中断上下文或 tasklet,不能睡眠。如果在 unmap 路径里有可能导致休眠的函数,就要改用 workqueue 延后处理。另外,回调里拿到的 buf 指针只是你传入的 callback_param,不代表传输一定成功;如果需要判断结果,还要检查 desc 的状态或者使用 dmaengine_tx_status。
4. 从“一块缓冲区”到“一条数据流”:环形缓冲区的设计思路
4.1 为什么一次性映射不够用
一次 map、传输、unmap 的模式适合低频数据。但串口、网卡、音频这类连续数据流场景,如果每次传输都 map/unmap,不但耗时,而且容易在临界区里把缓冲区释放得太早。更麻烦的是,硬件可能希望按照固定的速率持续搬运数据,CPU 不可能每传输几个字节就介入一次。
所以实际驱动里几乎都是环形缓冲区方案:分配一块完整的内存区域,切成多个描述符或多个 slot,让 DMA 在多个 slot 之间循环搬运,CPU 和 DMA 各自维护自己的读写进度。CPU 处理过的 slot 可以重新提交给 DMA,DMA 写完的 slot 则还给 CPU 处理。
4.2 描述符队列和读写指针:硬件与软件怎么握手
假设我用 dma_alloc_coherent 分配了一块包含 32 个描述符的环形表,每个描述符指向一个独立的流式映射数据缓冲区。软件维护一个 head,表示下一个要提交的 slot;硬件维护一个 tail,表示已经完成的 slot。
当软件把新的缓冲区地址和长度写入 head 指向的描述符,并更新 head 后,DMA 控制器会在当前描述符处理完后自动跳到下一个 slot。硬件完成某个描述符后,会更新 tail 并产生中断。软件在中断处理中比较 head 和 tail,回收已完成 slot 的数据。
这个流程里最容易出问题的,是内存屏障。很多 DMA 控制器不会在完成描述符写入后自动做 CPU 可见性保障,软件读取 tail 前,需要用 dma_rmb() 确保看到的是最新值;软件写入 head 前,也需要用 dma_wmb() 确保 DMA 看到更新后的描述符内容。不要小看这两个屏障,漏掉它们,在延迟较高的总线上数据错乱是迟早的事。
一个接收方向的简化流程:
- 初始时,所有 slot 都归属于 DMA。
- 软件把 head 指向第一个 slot,DMA 开始填充。
- DMA 完成第 N 个 slot,硬件更新 tail 并触发中断。
- 中断处理中,软件从 tail 开始循环读取已完成 slot,直到 tail 追平 head。
- 软件处理完数据后,重新初始化该 slot 的地址和长度,把 head 前移,通知 DMA 可以继续使用这块区域。
这个流程中,任何一步忘记更新指针,都会导致丢包或者覆盖。更常见的是对“head 和 tail 的追平条件”理解错误,把空状态和满状态搞混,这就是很多 DMA 偶发故障的根源。
4.3 DMA 不只用于“搬运内存”:PWM 占空比也算是 DMA 传输
有时候 DMA 的“缓冲区”不是内存,而是外设寄存器。比如在 STM32、GD32 这类 MCU 平台,经常用 DMA 往 PWM 比较寄存器里连续灌占空比数据,从而生成周期性变化的波形。dmaengine 在这类场景下表现为 DMA_MEM_TO_DEV,dst_addr 指向 PWM 的比较寄存器,src_addr 指向内存里的占空比表。
在 Linux 里如果 PWM 驱动支持 DMA 模式,也可以用 dmaengine_prep_dma_cyclic 来回搬运占空比数据。它和串口 DMA 的原理完全一样,只是目的寄存器不同。这类“另类”用法告诉我们,DMA 的本质是把数据从一个总线地址搬到另一个总线地址,至于是内存还是外设寄存器,只是地址不同而已。
5. 排障:DMA 数据错乱或者完全不动的排查链路
5.1 先看映射与 unmap 是否匹配:DMA_API_DEBUG 出场
遇到 DMA 问题,我第一件事不是上示波器,而是打开内核的 DMA API 调试功能。在 kernel config 里开启:
text复制CONFIG_DMA_API_DEBUG=y
CONFIG_DMA_API_DEBUG_SG=y
开启后,内核会检查 dma_map 和 dma_unmap 的调用是否成对、映射的大小是否一致、是否在错误的时间访问了缓冲。常见的错误包括:
- 映射了 1024 字节,unmap 时只传了 128 字节。
- 对栈内存调用 dma_map_single,DMA 不可能持续访问栈,因为栈是动态变化的。
- 对 vmalloc 内存直接 map,未先转成物理连续内存。
- 重复 unmap 同一块缓冲区。
如果内核检测到问题,dmesg 里会有一行带 DMA-API 前缀的错误,通常还会指出调用栈。这能让很多隐藏很久的 bug 当场现形。
5.2 再查设备树和 dmaengine 的配置是否真的生效
如果 DMA API 没有报错,DMA 却不动,就要检查链路配置。先用 dmesg 看 DMA 通道是否申请成功,然后确认 dma_slave_config 的方向、地址、宽度、突发长度是否和外设寄存器一致。串口 TX 的方向是 DMA_MEM_TO_DEV,目的地址是数据寄存器,不是状态寄存器。方向错了,DMA 引擎可能压根不接受配置。
设备树里 dmas 和 dma-names 的索引顺序也要检查。dma_request_chan(dev, "tx") 会解析 dma-names,如果设备树里把 rx 写在了 tx 前面,你请求 tx 实际拿到的可能就是 rx 通道。这种问题玄学且难排查,最可靠的办法是打开设备树覆写,把 dma-names 和 dmas 一一对应打印出来。
5.3 最后用示波器验证,而不是只盯寄存器
软件配置全部正常,DMA 仍然没有输出或者数据错乱,这时候才轮到硬件验证。我习惯用逻辑分析仪抓外设的 DMA 请求信号、时钟和数据线。很多时候,CPU 侧已经配置好了地址和长度,但 DMA 控制器因为时钟没使能、复位没释放、或者中断线没接对,根本没有发起总线上事务。
另外要提一个经验:从 MCU 裸机移植过来的工程师,往往会下意识地直接看 DMA 控制器的寄存器,认为读到 CCR/NDTR 就等于看到问题。但在 Linux 里,这些寄存器前可能还有 IOMMU 翻译、寄存器访问权限、时钟电源管理。与其纠结寄存器里某一位是不是对的,不如先在驱动里把 dma_request_chan、dmaengine_slave_config、dmaengine_prep_xx 每一层的返回值都打出来,确定软件链路走到哪一步断了,再往下钻。
6. 一些容易忽略的边界问题与进阶思路
6.1 申请“连续内存”不等于调用 kmalloc
有些驱动作者在需要 DMA 缓冲区时,第一反应是 kmalloc 一个大块。kmalloc 确实申请物理连续内存,但它的最大值受内存碎片和 slab 限制,很容易失败。而且 kmalloc 返回的是虚拟地址,你仍然需要 dma_map_single 得到 dma_addr_t。
如果确实需要较大的连续物理内存,并且要长期使用,应该考虑 dma_alloc_coherent 或者直接走 CMA。CMA 能分配更大的连续物理区域,尤其适合图像 sensor、多媒体编解码这类需要大缓存的场景。这里也要注意,CMA 分配可能阻塞,不能在原子上下文随便用。
还有一点容易被忽略:dma_alloc_coherent 得到的缓冲区不一定物理连续。在 IOMMU 存在的情况下,它可以返回由 IOMMU 映射成设备视角连续的非物理连续内存。所以不要下意识地在使能了 SMMU 的平台里,把 dma_addr_t 强转成物理地址去操作。
6.2 dma_addr_t 不是物理地址:IOMMU 与地址句柄
现代 SoC 里 SMMU/IOMMU 越来越普遍。开启 SMMU 后,dma_map_single 返回的 dma_addr_t 已经不是在物理内存上直接使用的地址,而是一个“设备视角地址”,有点类似于给 DMA 控制器发的一个句柄。不同设备可能通过不同 SMMU 映射到同一块物理内存,但拿到的 dma_addr_t 却不一样。
对这个概念不熟悉的人,会把 dma_addr_t 打出来,然后拿着它去 /proc/iomem 里找对应物理内存,发现根本对不上,于是开始怀疑内核 bug。其实内核里有 dma_to_phys 和 phys_to_dma 这样的转换接口,但驱动里基本不需要自己转。你只要做到“配置 DMA 用 dma_addr_t,CPU 访问用原来那个虚拟地址”就行,不要让两者混着用。
6.3 把思维从“缓冲区”升级到“传输闭环”
最后想强调一个思路上的转变。很多 DMA 问题,本质不是“缓冲区怎么分配”,而是“数据所有权怎么交接”。从分配缓冲区、建立映射、配置通道、提交描述符、等待回调、unmap、释放缓冲区,是一条完整生命周期。你可以在任何一环偷懒,但迟早会在某个不可复现的深夜 bug 里还回来。
我现在的做法是,每个 DMA 驱动都先画一张状态图:CPU 持有缓冲区、DMA 持有缓冲区、CPU 正在处理、DMA 正在搬运,缓冲区在哪个状态里归谁管,一目了然。这套方法帮我在串口 DMA、网络 DMA、外设采集 DMA 上都少踩了很多坑。
DMA 这块内容,看文档总觉得简单,真上手就会遇到各种“离奇”问题。如果你刚接触,建议先拿串口 TX 练手,把 dma_alloc_coherent、dma_map_sg、dmaengine_prep_slave_sg、callback、unmap 这套流程完整跑通,再考虑性能和并发优化。等你能把一块缓冲区从分配一路追到外设波形上,DMA 的那些坑也就成了你的日常经验了。
