我接手过不少嵌入式 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 或者页表属性,把这段内存标记为不可缓存或非缓存一致区域。
