做Linux驱动开发绕不开DMA。我最早是在裸机STM32上接触DMA的,那时候只需要把外设地址、内存地址、传输长度填进寄存器,然后等着外设自动搬数据。后来转到嵌入式Linux,第一次被cache一致性折腾到怀疑人生,才明白Linux下DMA的真正难点不在“怎么触发搬运”,而在“怎么保证CPU视角和设备视角看到的数据一致”。这篇是Linux DMA开发指南的第一篇,重点讲清楚地址映射、方向、一致性这些最基础也最容易出错的点,适合已经有字符设备驱动基础、但还没系统梳理过DMA的开发者。读完你会知道dma_map_single、dma_alloc_coherent到底该在什么场景用,也知道为什么MCU上那套“直接操作寄存器”的DMA思路在Linux上经常行不通。
1. Linux DMA不是“配寄存器”那么简单
1.1 DMA在系统里的位置:CPU、内存、外设三方协作
DMA全称是Direct Memory Access,直译为直接内存访问。它的意义在于,CPU只需要告诉DMA引擎“从哪里搬、搬到哪里、搬多少”,然后就可以去执行别的任务。很多外设的FIFO深度只有几十字节,比如串口接收寄存器,如果不给数据通路加DMA,CPU必须在每个字节到达时及时响应中断并读走,否则一个硬件中断风暴就会吞掉整个CPU性能。DMA的作用就是把这些零碎的搬运工作从CPU手里接过去。
现代SoC里DMA的应用场景覆盖以太网、USB、SD/MMC、显示控制器、音频、SPI、UART等等。有些SoC有独立的DMA控制器,外设之间通过请求信号和握手逻辑连接;有些外设本身内部就带DMA能力。Linux内核并不关心底层硬件怎么接线,它只提供了一套统一的API给你用,这套API叫做DMA Mapping API。无论你是驱动一个SD主机控制器,还是写一个基于dmaengine的SPI驱动,最终都要和这套API打交道。
我在初次接触Linux DMA时最大的困惑是:为什么不能像STM32那样,直接取一个物理地址填进外设寄存器?原因很简单,Linux系统有MMU,有cache,可能还有IOMMU/SMMU。CPU用的地址、物理内存的地址、外设能访问的地址,这三者不一定相等。更关键的是,外设的DMA操作会绕过CPU的cache。如果CPU在cache里缓存了一份数据,而DMA把新数据写到了物理内存,CPU读的时候可能读到的还是cache里的旧值,这就是经典的cache一致性问题。
1.2 从MCU DMA到Linux DMA,思维上要补哪几课
如果你是只做过STM32、GD32这类MCU的DMA,直接跳来做Linux DMA,一开始会非常不习惯。先看一张对比表,能帮你把脑子里那套裸机DMA模型和Linux DMA模型对齐。
| 对比项 | MCU裸机DMA | Linux内核DMA |
|---|---|---|
| 地址空间 | 地址就是物理地址,CPU和外设看到的是同一个地址 | 有虚拟地址、物理地址、总线地址多重映射 |
| 内存访问 | 通常无cache问题,或有少量cache需要手动清理 | cache一致性是硬伤,需要API保证 |
| 缓冲区管理 | 随时定义一个全局数组就能用 | 要分配、映射、同步、取消映射,有生命周期 |
| 寄存器访问 | 直接操作寄存器地址 | 使用ioremap后的虚拟地址,或寄存器的readl/writel |
| 地址宽度 | 基本固定32位 | dma_set_mask_and_coherent需要明确设置 |
| 隔离与重映射 | 没有,外设可访问全部内存 | IOMMU/SMMU可做地址重映射和设备隔离 |
| 错误排查 | 寄存器状态一目了然 | 有时需要dma-debug、IOMMU、ftrace联合排错 |
这张表不是让你死记,而是让你意识到:在Linux里写DMA驱动,本质是在管理地址映射和cache状态,而不是在“操纵DMA引擎”。“让DMA跑起来”是最简单的部分,“让DMA跑完以后数据是正确的”才是重中之重。
1.3 先记住四个概念,后面不会乱
在深入API之前,需要先建立几个概念。这里我用自己的话解释,尽量不引经据典。
第一个是CPU虚拟地址。你使用kmalloc或dma_alloc_coherent得到的地址,能直接被CPU的指针操作访问。这个地址是给CPU用的。第二个是物理地址,它代表实际RAM里的位置,但不是CPU运行时使用的地址,在启用MMU的系统上,物理地址不能直接被CPU指针访问。第三个是总线地址,也常叫设备地址或DMA地址,用dma_addr_t类型表示。这是外设发起DMA访问时使用的地址,经过总线地址转换,可能是物理地址,也可能被IOMMU重映射成另一套地址。第四个是dma_handle,它是一个dma_addr_t变量,在所有一致性映射API中都会出现,对应外设侧需要配置的地址。
这四个地址的关系可以用一句话概括:你往驱动代码里填的寄存器地址,不是CPU虚拟地址,也不是物理地址,而是dma_handle。很多新手第一次写Linux DMA驱动时,直接把虚拟地址强转成u32写进外设地址寄存器,然后在DMA完成后拿到一坨垃圾数据,就是因为在地址视角上搞错了。
另外还要记住DMA方向。Linux定义了DMA_TO_DEVICE、DMA_FROM_DEVICE和DMA_BIDIRECTIONAL。方向不仅是一个语义标签,它直接决定DMA映射API会做flush cache还是invalidate cache。方向搞反,轻则读到脏数据,重则把内存里的内容清空,调试起来非常痛苦。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. DMA映射API选型:一致性映射还是流式映射
2.1 长期共享数据用dma_alloc_coherent
Linux DMA Mapping API把映射分成两大类:一致性映射(coherent mapping)和流式映射(streaming mapping)。这两个概念如果不弄清楚,后面看代码会一头雾水。
一致性映射最适合“CPU和外设长期共享同一块内存”的场景。典型例子是以太网描述符、USB请求块、DMA控制器本身的描述符表。你的驱动需要反复往这块内存里写控制结构,外设也需要反复读这块内存里的控制信息,双方都要求每次更新立即可见。如果这块内存还经过cache,CPU写完描述符后,外设DMA去存储器读数据时可能读到旧数据,外设写完状态,CPU也可能读不到最新值。所以dma_alloc_coherent在底层分配的内存,通常是“非cache”的,或者经过特殊配置,让CPU访问时不会命中cache。这样CPU和DMA看到的都是同一份物理内存,避免手动同步。
使用方式很简单,代码示意如下:
c复制struct device *dev = &pdev->dev;
dma_addr_t dma_handle;
struct desc *desc;
size_t size = 64 * sizeof(struct desc);
desc = dma_alloc_coherent(dev, size, &dma_handle, GFP_KERNEL);
if (!desc) {
dev_err(dev, "failed to allocate coherent DMA buffer\n");
return -ENOMEM;
}
// desc是CPU虚拟地址,dma_handle是外设使用的地址
writel(dma_handle, dev->base + REG_DESC_BASE);
writel(size, dev->base + REG_DESC_SIZE);
释放时对应调用:
c复制dma_free_coherent(dev, size, desc, dma_handle);
如果你需要大量小对象,比如很多个描述符,一次性分配一大块再自己切分,或者使用dma_pool这样的子分配器。dma_pool可以在一致性内存基础上做对齐和固定大小分配,适合频繁创建和销毁的小对象。所有一致性分配都应该在设备probe阶段完成,不要在中断上下文里临时去alloc。dma_alloc_coherent底层很可能涉及内存分配或IOMMU映射,这是可以睡眠的操作。
2.2 一次一传的数据用dma_map_single和dma_map_sg
流式映射用在“每次传输都临时映射”的场景。比如网络收包,一个skb数据包映射给网卡DMA使用,发送完成之后立刻取消映射。这类缓冲区是普通的kmalloc缓冲区或页面缓冲区,并不是一致性内存,所以必须通过流式映射API来管理cache同步。
最简单的流式映射是dma_map_single:
c复制dma_addr_t addr;
addr = dma_map_single(dev, cpu_buf, len, DMA_TO_DEVICE);
if (dma_mapping_error(dev, addr)) {
dev_err(dev, "dma_map_single failed\n");
return -ENOMEM;
}
// 把addr写入外设DMA地址寄存器,启动传输
writel(addr, dev->base + REG_DMA_ADDR);
writel(len, dev->base + REG_DMA_LEN);
writel(1, dev->base + REG_DMA_START);
传输完成后:
c复制dma_unmap_single(dev, addr, len, DMA_TO_DEVICE);
对于多个物理上不连续的内存片段,可以使用dma_map_sg。它把scatter-gather表里的多个片段映射成外设能理解的一组地址和长度。注意,dma_map_sg函数返回值可能和传入的nents不一样,因为某些架构可以将多个相邻内存条目合并,外设收到的映射项数可能比物理内存的段数少。所以后续DMA操作必须使用返回值。
c复制struct scatterlist *sgl = sgt->sgl;
int nents = dma_map_sg(dev, sgl, sgt->nents, DMA_TO_DEVICE);
if (nents <= 0) {
dev_err(dev, "dma_map_sg failed\n");
return -ENOMEM;
}
// 遍历时要用dma_map_sg返回的nents,不能用sgt->nents
for_each_sg(sgl, sg, nents, i) {
dma_addr_t addr = sg_dma_address(sg);
size_t len = sg_dma_len(sg);
// 将addr/len填入外设描述符
}
流式映射有一个严格的使用规则:在DMA没有完成之前,CPU绝对不要直接访问这块缓冲区。比如使用dma_map_single把一块kmalloc内存映射给外设做DMA写入,在外设还没完成时CPU去读这块内存,拿到的数据可能是脏的,也可能引发严重错误。如果需要CPU在传输中途访问,必须显式调用dma_sync_single_for_cpu,处理完之后再调用dma_sync_single_for_device。
2.3 DMA方向错误,后果比想象中严重
设置方向时,要站在“数据流动方向”来理解:DMA_TO_DEVICE意思是CPU写数据给外设,DMA从内存读数据;DMA_FROM_DEVICE意思是外设写数据回内存,DMA往内存写数据。听起来简单,但不少人会在接收缓冲区上使用DMA_TO_DEVICE来映射,导致传输失败。
从API实现角度,DMA_TO_DEVICE映射通常会对CPU cache做flush操作,确保CPU在映射前写过的最新数据从cache刷到内存。DMA_FROM_DEVICE映射则可能做invalidate操作,避免后续DMA写入内存后,CPU读到的是cache里的旧内容。如果发送方向用DMA_FROM_DEVICE,理论上映射时把cache invalidate了,CPU刚写好的数据可能还在cache里,DMA却去内存读,读到的是旧数据。如果接收方向用DMA_TO_DEVICE,CPU在DMA完成之后还是要靠cache无效化才能拿到新数据,但因为方向标记错误,API并不会做这件事,于是数据读取结果是未定义的。
| 方向 | 典型场景 | CPU cache操作原则 |
|---|---|---|
| DMA_TO_DEVICE | 内存到外设,比如向UART发送缓冲区数据 | 映射前flush CPU cache,确保新数据可见 |
| DMA_FROM_DEVICE | 外设到内存,比如UART接收缓冲区 | 传输前/后invalidate CPU cache,避免旧数据 |
| DMA_BIDIRECTIONAL | 双方都写,比如双口RAM | 每次都做完整同步,性能开销最大 |
DMA_BIDIRECTIONAL是最通用的方向,但也是最贵的。由于不确定数据流向,通常需要在CPU读写前后都做同步,这会让每个访问都付出cache维护代价。在驱动里如果没有明确双向读写需求,不要为了图省事使用DMA_BIDIRECTIONAL。
2.4 设置DMA掩码,不设会坑死自己
调用dma_set_mask_and_coherent是为了告诉内核这个外设的DMA地址总线最多支持多少位。很多设备硬件只有32位DMA地址寄存器,但系统是64位,如果你不设置,内核可能默认按64位能力来处理,最后外设拿到的地址超过32位,写进低32位寄存器就变成截断地址,DMA直接乱飞。
常见写法:
c复制if (dma_set_mask_and_coherent(dev, DMA_BIT_MASK(32))) {
dev_err(dev, "no suitable DMA mask available\n");
return -ENXIO;
}
dma_set_mask_and_coherent这个函数同时设置DMA掩码和一致性DMA掩码。它会影响后续dma_alloc_coherent、dma_map_*在分配/映射时对地址范围的偏好。如果你的设备硬件手册写明“支持64位DMA地址”,可以设成DMA_BIT_MASK(64)。如果手册没有明确,先设32位最稳妥,因为现代SoC里的IOMMU和SWIOTLB会兜底,即使物理内存超过4GB,也能通过 bounce buffer 方式完成DMA。
但要注意,设置64位掩码不等于万事大吉。如果设备驱动和硬件之间存在bug,比如外设驱动拿到了DMA地址但配置时把高32位寄存器忘了写,DMA就会走到错误的内存。所以很多稳妥的驱动会同时检查dma_addr_t的高位,或者写寄存器时把高32位和低32位都写入。
3. 实操:一个Linux外设驱动的DMA传输全流程
3.1 从设备树到驱动的资源获取
在实际项目中,DMA资源通常通过设备树描述。一个带DMA能力的SPI控制器节点往往长这样:
dts复制spi0: spi@10000000 {
compatible = "vendor,spi-dma";
reg = <0x10000000 0x1000>;
interrupts = <GIC_SPI 42 IRQ_TYPE_LEVEL_HIGH>;
dmas = <&dma0 1>;
dma-names = "rx";
};
驱动里可以使用dmaengine框架来请求DMA通道,也可以自己通过平台资源和寄存器配置DMA控制器。我写这类驱动时,更偏向让DMA控制器驱动去处理描述符链,而外设驱动只负责数据缓冲区映射和请求提交。下面用一个简化但真实的模型来说明:“外设侧集成一个简单DMA引擎,驱动需要通过寄存器告诉它缓冲区地址和长度”。这种方式更贴近大多数MCU风格外设的Linux驱动。
获取资源后的初始化代码大概是:
c复制static int mydev_probe(struct platform_device *pdev)
{
struct device *dev = &pdev->dev;
struct mydev *mydev;
int ret;
mydev = devm_kzalloc(dev, sizeof(*mydev), GFP_KERNEL);
if (!mydev)
return -ENOMEM;
platform_set_drvdata(pdev, mydev);
mydev->base = devm_platform_ioremap_resource(pdev, 0);
if (IS_ERR(mydev->base))
return PTR_ERR(mydev->base);
if (dma_set_mask_and_coherent(dev, DMA_BIT_MASK(32))) {
dev_err(dev, "cannot set DMA mask\n");
return -ENXIO;
}
ret = mydev_init_rx_ring(mydev);
if (ret)
return ret;
return 0;
}
这里的关键是先设置DMA掩码,再初始化DMA缓冲区。顺序很重要,因为内核要根据掩码选择内存分配和IOMMU映射策略。
3.2 发送路径:把数据安全地交给外设
假设外设支持DMA发送,驱动提供write函数。你拿到的用户缓冲区可能是kernel空间,也可能是用户态程序的buffer,但内核驱动里通常先在驱动或协议层面把数据拷贝到一个“内核DMA安全缓冲区”,再执行映射。接下来是发送路径的完整步骤。
第一步,确认要发送的数据在内存里是连续的,如果不是,则需要使用scatter-gather或做一次拷贝。
第二步,调用dma_map_single:
c复制dma_addr_t dma_addr;
dma_addr = dma_map_single(dev, buf, len, DMA_TO_DEVICE);
if (dma_mapping_error(dev, dma_addr)) {
dev_err(dev, "failed to map TX buffer\n");
return -ENOMEM;
}
第三步,把dma_addr和len写入外设寄存器。这里有个容易忽视的细节,写寄存器之前需要加内存屏障。为什么?因为寄存器写在CPU视角是有顺序的,但DMA控制器或其他总线主设备看到的内存数据更新可能没这么快。如果DMA启动命令比前面的数据写操作先被外设看到,外设可能会读到旧数据。Linux内核提供了dma_wmb()函数,它的语义是“保证DMA设备发起的写操作之前,所有内存写操作对外设可见”。
c复制writel(dma_addr, mydev->base + REG_TX_ADDR);
writel(len, mydev->base + REG_TX_LEN);
dma_wmb();
writel(1, mydev->base + REG_TX_START);
第四步,等待传输完成。完成方式可以是外设中断,也可以是轮询状态寄存器。如果是中断,在中断处理函数里,你要先判断传输状态,再调用dma_unmap_single。取消映射必须在CPU访问buffer之前完成,或者在调用dma_sync之前完成。
c复制static irqreturn_t mydev_tx_irq(int irq, void *data)
{
struct mydev *mydev = data;
// 读出完成状态后,取消DMA映射
dma_unmap_single(mydev->dev, mydev->tx_dma_addr,
mydev->tx_len, DMA_TO_DEVICE);
// 通知上层发送完成
complete(&mydev->tx_done);
return IRQ_HANDLED;
}
这里要提醒一句:不要试图在dma_unmap_single之前直接访问tx buffer里的数据,哪怕只是打印调试信息,也可能让整块数据变得不可预测。正确的访问时机是取消映射之后。
3.3 接收路径:环形缓冲的经典玩法
接收路径比发送复杂一些,因为外设随时可能把数据写入内存,驱动必须提前准备好一个或多个接收缓冲区,并让外设知道这些缓冲区的地址。环形缓冲是最常用的方案:驱动预先准备N个相同大小的缓冲区,每个缓冲区都做DMA映射,并把地址和长度写入外设的接收队列。DMA完成后,外设中断通知驱动处理已完成的缓冲区,驱动处理完后再重新映射并放回队列。
这里我用一个简化但正式的结构来说明:
c复制struct rx_buf {
void *cpu_ptr;
dma_addr_t dma_addr;
size_t len;
bool in_use;
};
#define RX_RING_SIZE 16
#define RX_BUF_SIZE 2048
static int mydev_init_rx_ring(struct mydev *mydev)
{
int i;
for (i = 0; i < RX_RING_SIZE; i++) {
struct rx_buf *rx = &mydev->rx_ring[i];
rx->cpu_ptr = kzalloc(RX_BUF_SIZE, GFP_KERNEL);
if (!rx->cpu_ptr)
return -ENOMEM;
rx->dma_addr = dma_map_single(mydev->dev, rx->cpu_ptr,
RX_BUF_SIZE, DMA_FROM_DEVICE);
if (dma_mapping_error(mydev->dev, rx->dma_addr))
return -ENOMEM;
rx->len = RX_BUF_SIZE;
rx->in_use = true;
// 把rx->dma_addr写入外设接收队列
mydev_add_rx_desc(mydev, rx->dma_addr, rx->len);
}
return 0;
}
接收完成中断里,第一件事是取消映射:
c复制static irqreturn_t mydev_rx_irq(int irq, void *data)
{
struct mydev *mydev = data;
int idx = mydev->rx_completed_idx;
dma_unmap_single(mydev->dev, mydev->rx_ring[idx].dma_addr,
mydev->rx_ring[idx].len, DMA_FROM_DEVICE);
// 现在CPU可以安全读取数据了
process_rx_packet(mydev->rx_ring[idx].cpu_ptr,
mydev->rx_ring[idx].received_len);
// 重新分配并重新映射,放回环形队列
mydev->rx_ring[idx].cpu_ptr = kzalloc(RX_BUF_SIZE, GFP_ATOMIC);
mydev->rx_ring[idx].dma_addr =
dma_map_single(mydev->dev, mydev->rx_ring[idx].cpu_ptr,
RX_BUF_SIZE, DMA_FROM_DEVICE);
mydev_add_rx_desc(mydev, mydev->rx_ring[idx].dma_addr,
mydev->rx_ring[idx].len);
return IRQ_HANDLED;
}
这里的代码是示意性的,实际驱动里不会在中断里重新kzalloc,而是更倾向于提前分配一个备用缓冲区,或者让描述符和数据区都来自一致性DMA内存,这样中断里就不需要再做流式映射,只需要通过访问描述符状态来获取数据。这就是dma_alloc_coherent在接收路径中最典型的应用场景:描述符和缓冲区一起做成一致性映射区域,外设把数据写进缓冲区后,CPU通过描述符状态位来判断哪个缓冲区有效。因为映射是一致性的,CPU读取数据的时候不会遇到cache陈旧问题。
3.4 需要反复复用缓冲区时,用dma_sync而不反复map/unmap
如果同一个缓冲区要被反复使用,并且每次DMA传输结束后CPU都要读取数据,比如网卡RX环形队列,每次unmap再map的代价太高。这时候就需要更细粒度的cache同步。
处理流程是:
- 在DMA传输前,确认缓冲区处于“设备拥有”状态,调用dma_sync_single_for_device。
- 启动DMA。
- DMA完成后,CPU访问数据前,调用dma_sync_single_for_cpu。
- 处理完数据后,如果要再次交给DMA使用,再调用dma_sync_single_for_device。
c复制// CPU读取前
dma_sync_single_for_cpu(dev, dma_addr, len, DMA_FROM_DEVICE);
// 现在可以访问cpu_addr指向的缓冲区了
process_data(cpu_addr, len);
// 重新交给DMA前
dma_sync_single_for_device(dev, dma_addr, len, DMA_FROM_DEVICE);
writel(dma_addr, mydev->base + REG_RX_ADDR);
dma_wmb();
writel(1, mydev->base + REG_RX_START);
这里的方向必须和流式映射时保持一致。如果用DMA_FROM_DEVICE映射的缓冲区,CPU读取前就要用DMA_FROM_DEVICE调用dma_sync_single_for_cpu。不要自己猜测“同步”的方向,API文档里写得很清楚,按数据传输方向来。
4. 常见问题与排查技巧
4.1 cache一致性导致的数据“不更新”
这种问题最常见。现象是DMA确实完成了,状态寄存器也置位了,但CPU读数据时发现是零或者上次的旧值。原因就是CPU cache中还有旧数据,DMA已经把新数据写进了RAM,但CPU访问缓存命中,没有去RAM读。
如果是用一致性映射分配的内存,通常不会出现这个问题。如果是流式映射,多半是读取前没有调用dma_unmap_single或dma_sync_single_for_cpu。我见过很多人在中断处理函数里直接拿cpu_ptr开始打印,打印出一堆旧数据,接着怀疑外设时序,折腾半天最后发现只是漏了unmap。
排查时可以打开内核的dma-debug,捕获这类非法访问。更快的办法是先用dma_alloc_coherent临时替换流式缓冲区,看数据是否正常。如果替换后一切正常,基本可以确定是cache同步缺失,而不是硬件时序问题。
4.2 遇到SWIOTLB或IOMMU报错怎么定位
当DMA API返回一个设备地址,但你发现它和物理地址完全不是一个值时,不要惊讶。这可能是IOMMU在做地址重映射,也可能是SWIOTLB在做bounce buffer。SWIOTLB是一个由内核管理的低地址DMA缓冲区池,当外设DMA能力受限或者IOMMU没有开启时,内核会把数据先拷贝到这块特殊内存,再发起DMA。代价是额外一次内存拷贝,性能会有所下降。
常见报错类似:
code复制swiotlb buffer is full
DMA-API: device driver maps memory before dma_set_mask
遇到这类信息,先检查驱动是否在probe里调用了dma_set_mask_and_coherent。如果没调用,很多DMA API可能不会正常工作。再检查设备树里是否缺少dma-ranges属性,导致设备DMA地址空间被错误映射。
如果SWIOTLB满,通常是大量DMA buffer没有及时释放,或者缓冲区分配过大。可以从这几个方向排查:确认每个DMA映射都有对应的unmap;确认发送完成中断里真的调了dma_unmap;确认环形队列在初始化时没有把每个缓冲区都映射成“长驻”状态却从不释放。
4.3 编译内核时打开DMA_API_DEBUG,把问题提前暴露
DMA API的错误很多是“用错但不会立刻崩”,比如忘记unmap、重复map、映射后CPU继续访问缓冲区。这类问题靠写代码的直觉很难拦住,最有效的工具是内核自带的dma-debug。
启用方法很简单,编译内核时配置:
code复制CONFIG_DMA_API_DEBUG=y
CONFIG_DMA_API_DEBUG_SG=y
然后在启动参数里加dma_debug=1,或者运行过程中通过debugfs使能。启用后,内核会在DMA映射生命周期管理违规时输出类似这样的提示:
code复制DMA-API: device driver maps memory before dma_set_mask
DMA-API: device driver unmap while device still has DMA operation in flight
DMA-API: device driver leaked dma mapping
这些日志通常会打包栈和驱动名,直接搜索“DMA-API”就能定位到具体调用点。配合ftrace,可以跟踪dma_map_single和dma_unmap_single的调用序列,确认是否成对出现。
我在实际项目里,新驱动第一次上板时都会打开dma-debug跑一遍,能省掉很多半夜排查的时间。等驱动稳定后再关闭,避免调试开销影响性能。
4.4 启动DMA前的内存屏障,不要忽略
DMA启动之前,除了要保证寄存器写入顺序正确,还要保证CPU写过的内存数据已经“到达”外设。因为现代CPU和总线上都存在写缓冲区和乱序执行,如果先写了启动寄存器的bit,DMA控制器可能立刻读取RAM,而RAM还没收到CPU的写数据。这时候就需要有一个“屏障”来保证顺序。
Linux内核提供的dma_wmb()就是专门用于DMA场景的写内存屏障,目的是确保DMA设备发起读操作前,CPU对内存的写入对外设可见。类似的还有读屏障dma_rmb(),用于DMA完成后CPU读取数据前,确保外设写入的状态和其他数据到达CPU可观察的顺序。
c复制// 典型发送路径
writel(dma_addr, dev->base + REG_ADDR);
writel(len, dev->base + REG_LEN);
dma_wmb();
writel(1, dev->base + REG_TRIGGER);
有些驱动会直接用wmb(),功能上限更高但开销也更大。在嵌入式ARM开发中,这类问题并不少见;在x86上因为内存模型较强,问题可能藏得很深,直到换一个PCIe网卡才暴露。不要觉得“以前没加也没事”,到了不同SoC或不同总线上,可能就是偶发数据损坏。
5. 从MCU转Linux DMA,我的实操心得
5.1 先学会把“分配、映射、同步、释放”四步黏成一条线
我做过的DMA驱动项目里,最终稳定的代码几乎都遵守同一个规律:每个DMA缓冲区都有明确的归属状态,要么“CPU拥有”,要么“外设拥有”。CPU在访问缓冲区之前必须确保自己拥有它,外设访问之前必须确保外设拥有它。状态切换靠的就是dma_map/unmap、dma_sync_for_cpu/device这套API。
裸机MCU上的DMA不需要这套状态机,因为cache和MMU的作用很弱,地址和内存天然可见。移植到Linux之后,我看到很多新人的代码只是在原来的寄存器操作外面套了一层Linux风格,实际上还是“拿数组地址往寄存器里写”的思路,最后一定会掉进cache坑里。所以不管你从什么平台切换过来,建议先把状态转换写在注释里,再写代码。
5.2 多看内核自带的DMA驱动,比自己造轮子强
Linux内核里有大量使用DMA API的好代码。想理解一致性映射,去看drivers/net/ethernet/目录下的MAC驱动;想理解scatter-gather映射,去看NVMe或USB HCD驱动;想理解环形描述符,去看8250串口DMA驱动和drivers/dma/目录下的控制器驱动。这些代码都经历过无数次真实硬件验证,比网上二手教程严谨得多。
我尤其推荐看8250的DMA收发射路径,因为串口DMA足够简单,又能把map、unmap、start、irq全流程串起来。先把搜索路径记下来:drivers/tty/serial/8250/8250_dma.c。这个文件不算长,但几乎涵盖了Linux DMA开发的常见套路。
5.3 为下一部分留个问题入口
这一篇重点解决了DMA映射和cache一致性问题,但Linux DMA开发里还有几个大块没有展开:dmaengine控制器驱动怎么写,scatter-gather描述符链怎么维护,IOMMU/SMMU对DMA地址空间的影响,以及page pool这种高性能缓冲区复用方案。如果这篇对你有帮助,下一部分我会挑一个具体硬件,比如SPI DMA收发的中断驱动模型,把环形缓冲和dmaengine请求流程完整走一遍。
我个人的体会是,DMA驱动最不值钱的部分是“把寄存器写对”,真正值钱的部分是“把缓冲区生命周期和内存序搞对”。希望这一篇能帮你少踩几个坑。
