资讯动态

Linux DMA机制深度解析:从地址映射到cache一致性与调试实战

发布时间:2026/9/12 10:13:51 来源:尧图企业网站定制
1. DMA搬运数据的完整过程谁发起、谁执行、谁善后我在Linux下被DMA真正教育是在调一块嵌入式网卡驱动的时候。开机日志里一行failed to reset the dma让我对着数据手册翻了一下午。后来又在串口、ADC、SD/MMC、视频采集这几类设备上跟DMA打过不少交道发现很多人对DMA的理解还停留在“外设和内存之间直接搬运数据”这个层面真到写驱动时连dma_map_single和dma_alloc_coherent的区别都说不上来。这篇我只讲Linux下的DMA机制从搬运过程、地址映射、cache一致性到dmaengine框架和真实排查案例尽量把一条能直接落地的路径给你讲清楚。1.1 从“一整批数据搬运”理解DMA的存在价值先回答一个最基本的问题DMA到底解决了什么你可以把没有DMA时的数据收发想象成叫外卖。CPU是那个亲自下楼取餐的人每来一份餐CPU就要从工位上站起来、下楼、拿餐、上楼、放到桌上。如果外卖一分钟来一百份CPU什么正事都不用干了光是上下楼就累死。传统中断驱动的UART、SPI、GPIO模拟时序本质就是这样每个字节到达或发送完成都会触发中断CPU在中断里读一个寄存器、写一个寄存器。数据量小的时候无所谓一旦波特率跑高、采样率拉满、网络包疯狂进来CPU占用率会直接被打满。DMA把这个模型改成了快递柜。CPU只需要告诉DMA控制器货从哪个地址来、放到哪个地址去、一共多少件。然后DMA控制器自己走总线把数据搬完搬完了再发一个中断通知CPU“货已入库”。整个过程CPU只需要参与头尾两件事中间的数据搬运完全不占用CPU指令周期。在Linux驱动的视角里一次典型DMA传输是这样的驱动分配一块内存把虚拟地址通过DMA API转换成设备能识别的DMA地址。驱动把源地址、目的地址、传输长度、突发大小、传输方向这些参数配置到DMA控制器。DMA控制器作为总线主设备开始搬运搬运过程不需要CPU干预。传输完成DMA控制器触发中断驱动在中断处理中调用回调函数消费数据。这里有三个词很关键总线主设备、源地址和目的地址、传输方向。DMA控制器不是从设备它是自己去读写总线的所以它能搬内存到外设FIFO也能搬外设FIFO到内存还能搬内存到内存。理解了这个模型后面很多API设计的理由就顺了。1.2 DMA的“一次传输”到底有多小burst和FIFO怎么配另一个容易让新手懵的是DMA控制器里那一堆传输参数。比如burst size、FIFO深度、数据宽度。很多人的做法是照抄参考驱动能跑就再也不动直到某天发现高负载下丢数据才回头研究这些参数。我常用一个生活化的解释方式DMA搬运数据像搬家公司的货车一趟一趟运箱子。货车一次装多少箱burst决定跑多少趟。一辆车只装一箱虽然灵活但是效率极低一辆车能装64箱但需要仓库门口有足够大的暂存区FIFO来配合装卸。实际配置时burst size通常取总线位宽的一个合理倍数。比如32位总线上8拍的burst就是32字节一次。如果外设FIFO深度不够burst就要调小否则FIFO会溢出。很多DMA控制器配置寄存器里都有burst length或transfer width字段调试时可以先从最小burst开始确认数据正确后再逐步调大观察性能这也是验证链路稳定性的一个好方法。2. 地址翻译与dma_mask为什么驱动不能直接把内核地址交给DMA我见过不少刚接触驱动的同事试图把kmalloc返回的指针直接写进DMA描述符。这在某些x86裸机环境下能跑在Linux上则是完全错误的做法。因为DMA控制器看到的地址空间和CPU看到的内核虚拟地址空间根本不是一回事。2.1 四种地址一次说清Linux内核里至少有四种地址概念容易混淆地址类型说明能否直接交给DMA内核虚拟地址CPU通过页表访问的地址如kmalloc返回值不能物理地址CPU的物理地址空间可能需要经过MMU转换不一定能DMA控制器不一定按物理地址寻址总线地址/DMA地址设备在总线上看到的地址即dma_addr_t这就是DMA控制器能用的地址IOMMU映射地址有SMMU/IOMMU时设备看到的是IOVA不是物理地址是但由IOMMU翻译在没有IOMMU的平台上总线地址通常等于物理地址这也是很多人误以为“物理地址能直接给DMA”的来源。但Linux API的语义要求你必须用DMA API做转换因为一旦硬件平台引入了IOMMUdma_addr_t就是一个IOVA和你想象的物理地址完全无关。驱动应该永远把dma_addr_t当作一个不透明的整数不要自己去猜它的含义。2.2 dma_mask到底在表达什么dma_mask是设备结构体里的一个位掩码表示这个设备的DMA控制器最多能寻址多少位地址。如果设备只有32位DMA地址能力但驱动把64位物理地址直接给它高地址部分会被截断或者丢失轻则数据错乱重则直接触发总线错误。用dma_set_mask_and_coherent()设置这个掩码通常这样写if (dma_set_mask_and_coherent(dev, DMA_BIT_MASK(32))) { dev_err(dev, 无法设置DMA掩码\n); return -EIO; }有的驱动只调dma_set_mask()没有调coherent版本这会导致一致性映射使用的掩码没有设置某些平台上会分配出设备无法访问的高地址。我建议一律使用dma_set_mask_and_coherent()省得代码评审时被追问。2.3 物理地址在DMA场景下的两个陷阱第一个陷阱是内存类型。DMA使用的内存需要保证物理连续或者支持SG表离散传输。kmalloc分配的内存通常物理连续但只能分配较小的块大块连续内存要么用devm_kmalloc碰运气要么用CMA。第二个陷阱是内存的属性。有些平台区域被标记为device memory或strongly-orderedCPU读写行为和普通DMA缓冲不一致容易出现性能异常。所以Linux专门提供了一组DMA地址映射API它负责的不仅是地址转换还包括cache一致性和内存屏障这才是重点。3. cache一致性与两类映射从“数据不对”反推API设计“为什么我DMA收到的数据是老数据”这个问题在Linux驱动讨论里出现的频率高到可以单独开一个板块。大多数时候答案只有一个cache一致性没处理好。3.1 CPU cache和DMA之间的“数据倒挂”问题CPU cache是CPU和内存之间的高速缓存。当CPU写内存时数据可能还在cache里没真正落到RAM当CPU读内存时也可能直接把cache里的旧数据给了你根本没去内存拿。DMA绕过CPU cache直接读写RAM。于是经典问题来了设备往内存写了新数据CPU读时发现cache里还是旧副本于是读到旧数据。CPU刚往内存写了数据设备读时发现数据还没从cache写回RAM于是设备读到旧数据。这两个问题并称为cache一致性问题。解决办法只有两种思路一是让这段内存不走cache每次访问都直接穿透到RAM二是在DMA传输的关键节点手动做cache清理和失效操作。3.2 一致性映射为什么“贵”但省心dma_alloc_coherent()走的是第一种思路。它在分配内存时会保证这段内存区域对CPU和DMA控制器是一致的CPU访问它不会被cache干扰。dma_addr_t dma_handle; void *cpu_addr; cpu_addr dma_alloc_coherent(dev, size, dma_handle, GFP_KERNEL); if (!cpu_addr) return -ENOMEM;这个API返回两个东西cpu_addr是CPU访问这段内存的虚拟地址dma_handle是DMA控制器访问这段内存用的总线地址。传输过程中不需要你手动同步cache因为这段内存本身就用uncached或者write-through方式映射了。代价是什么分配和释放的开销比较大而且有些平台上这类内存是从专用内存池里割出来的不能无限分配。它适合CPU和设备都要频繁访问的共享结构比如描述符环、DMA控制块、状态标记位这种“小但关键”的区域。拿它当大数据搬运缓冲不划算。3.3 流式映射为什么必须按方向来dma_map_single()和dma_unmap_single()走的是第二种思路。它映射的是已经存在的一段内存比如kmalloc出来的缓冲区在map和unmap的边界处做cache维护。使用流式映射时方向不是随便填的方向枚举含义map阶段行为unmap阶段行为DMA_TO_DEVICECPU写设备读做cache写回保证数据落RAM一般不需要特殊处理DMA_FROM_DEVICE设备写CPU读做cache失效丢弃旧副本做cache失效让CPU读到新数据DMA_BIDIRECTIONAL双向写回加失效写回加失效DMA_NONE非法/调试不执行传输不执行传输这里有个很多人没想明白的点为什么DMA_FROM_DEVICE在map阶段就要做cache失效因为设备随时可能在map完成后开始写内存cache里如果保留一份旧数据后面CPU读这段内存时mmu一查cache命中了拿到的是设备写入之前的老数据。所以必须在DMA开始前就把cache里的旧副本丢弃。我自己的经验是DMA_BIDIRECTIONAL能不用就不用。它每次map/unmap都会做完整的writeback加invalidate性能比单向映射差不少而且双向访问的缓冲区在并发时容易出现谁也没预料到的数据竞争。3.4 权限模型DMA映射期间的“内存借用”理解流式映射一定要建立一个“所有权”概念。map之后这段内存的访问权实际上交给了DMA控制器CPU不应该再去读写它直到unmap完成。这不是API强制要求的但如果不遵守就会遇到“CPU改了数据、DMA没看到”或者“DMA写了数据、CPU读的是脏数据”这类问题。/* 发送方向CPU写数据到buf然后map给设备读 */ write_data_to_buf(buf); dma_addr dma_map_single(dev, buf, len, DMA_TO_DEVICE); /* map之后CPU不要再去动buf */ hw_start_transmit(dma_addr, len); ... /* 发送完成中断 */ dma_unmap_single(dev, dma_addr, len, DMA_TO_DEVICE); /* 现在CPU可以重新使用buf */这类API设计很像“借车”借给设备之前你要把车加满油写回数据设备使用过程中你不能抢着开还回来之后你才能再用。理解了这个模型很多诡异的DMA问题就都能对号入座了。4. 流式映射和SG表写驱动时最常用的那组DMA函数流式映射虽然概念简单但工程里用起来有不少细节。尤其是异步传输场景很多人会忽略map和unmap必须严格配对这一条最后在调试时被DMA-API: device driver tries to free DMA memory it has not allocated这种错误教做人。4.1 一次发送的完整映射生命周期一个典型的网络包发送路径映射大致是struct sk_buff *skb ...; dma_addr_t dma_addr; dma_addr dma_map_single(dev, skb-data, skb-len, DMA_TO_DEVICE); if (dma_mapping_error(dev, dma_addr)) { dev_err(dev, DMA映射失败\n); dev_kfree_skb(skb); return NETDEV_TX_OK; } /* 把dma_addr填进发送描述符触发硬件发送 */ tx_desc-addr dma_addr; tx_desc-len skb-len; wmb(); /* 确保描述符先写入内存再通知硬件 */ writel(1, dev-base TX_START_REG);注意检查dma_mapping_error()这在IOMMU场景下尤其重要因为IOVA空间耗尽时它会返回一个错误地址不检查会出大问题。发送完成中断里必须找到对应的skb并unmapdma_unmap_single(dev, tx_desc-dma_addr, tx_desc-len, DMA_TO_DEVICE); dev_kfree_skb(skb);这里有个工程细节发送完成中断可能比下一次发送晚到很多所以tx_desc结构里最好保存dma_addr、len、skb指针不能靠临时变量去猜。4.2 大块内存连续性问题与scatter-gather连续物理内存永远是稀缺资源。系统跑久了内存碎片化严重kmalloc一个几MB的缓冲区都可能失败。这时候就轮到scatter-gatherSG出场。SG表的核心思想是不要求一整块连续内存而是把分散的几个内存块通过描述符串成一个链表交给DMA控制器让它依次把每一段搬完。struct scatterlist sg[2]; sg_init_table(sg, 2); sg_set_buf(sg[0], buf1, len1); sg_set_buf(sg[1], buf2, len2); nents dma_map_sg(dev, sg, 2, DMA_FROM_DEVICE); if (!nents) return -ENOMEM; for_each_sg(sg, s, nents, i) { dma_addr_t addr sg_dma_address(s); unsigned len sg_dma_len(s); /* 配置硬件描述符 */ }这里最容易犯的错误是用s-address和s-length去填描述符而不是用sg_dma_address()和sg_dma_len()。在有IOMMU的情况下dma_map_sg()可能会把多个物理段合并成一个IOVA段返回的nents比传入的2更小只有用sg_dma_*宏才能拿到真正给设备看的地址。4.3 dma_pool和预分配描述符对高频小对象传输比如网络驱动里的描述符本身每次动态分配再map的开销不可忽略。dma_pool_create()和dma_pool_alloc()可以预先创建一块DMA内存池每次分配和释放的开销比通用kmalloc更可预期而且能保证对齐适合描述符环、DMA请求结构这类固定大小对象。struct dma_pool *pool dma_pool_create(rx_desc, dev, sizeof(struct desc), 16, 0); struct desc *d dma_pool_alloc(pool, GFP_KERNEL, dma_addr);我一般只在两种情况下用dma_pool一是对象固定大小且频率极高二是硬件对地址对齐有严格要求。如果只是单次大数据传输直接用流式映射或者一致性映射就够了。5. dmaengine框架用标准API驱动DMA控制器的完整链路前面讲的都是底层dma_map接口属于“内存映射”层。实际写驱动时如果硬件自带DMA控制器更常见的是用Linux的dmaengine框架。这个框架把各种DMA控制器的差异封装成一组统一API驱动不用关心你的DMA控制器是ARM PL330、DMA-330还是厂商私有IP。5.1 dmaengine的几个核心对象struct dma_device一个DMA控制器里面有device_prep_slave_sg、device_prep_dma_cyclic等回调。struct dma_chan一个DMA通道对应控制器上的一条物理DMA流。struct dma_async_tx_descriptor一次传输描述符类似一个“任务单”里面保存了传输参数、回调函数和cookie。驱动开发者一般不直接调用硬件寄存器而是通过dma_request_chan()申请通道然后用dmaengine_prep_xxx()系列函数创建描述符再dmaengine_submit()提交最后dma_async_issue_pending()让硬件动起来。5.2 从申请通道到传输完成一个从外设FIFO读取数据的例子struct dma_chan *chan dma_request_chan(dev, rx); if (IS_ERR(chan)) return PTR_ERR(chan); struct dma_slave_config cfg { .direction DMA_DEV_TO_MEM, .src_addr (phys_addr_t)dev-base FIFO_DATA, .src_addr_width DMA_SLAVE_BUSWIDTH_4_BYTES, .src_maxburst 16, }; dmaengine_slave_config(chan, cfg); struct dma_async_tx_descriptor *desc; desc dmaengine_prep_slave_single(chan, buf_dma, len, DMA_DEV_TO_MEM, DMA_PREP_INTERRUPT); if (!desc) { dma_release_channel(chan); return -ENOMEM; } desc-callback my_dma_callback; desc-callback_param priv; cookie dmaengine_submit(desc); dma_async_issue_pending(chan);传输完成时my_dma_callback会在DMA中断上下文里被调用。这个回调里不要做任何耗时操作也不要调用msleep、mutex_lock这类可能睡眠的函数。如果收完数据要做协议解析最好用tasklet或workqueue把数据丢到进程上下文处理。5.3 循环模式cyclic串口和音频的长期伴侣普通的一次性DMA传输描述符执行完就结束了。但串口、音频这类持续数据流需要DMA一直循环搬运这时候要使用dmaengine_prep_dma_cyclic()desc dmaengine_prep_dma_cyclic(chan, buf_dma, buf_size, period_size, DMA_DEV_TO_MEM, DMA_PREP_INTERRUPT);它会把一块缓冲区切分成若干个period每填满一个period就触发一次回调。驱动在回调里通过dmaengine_tx_status()查询当前传输位置计算还有多少新数据可读用环形缓冲区维护读写指针。这里最常见的一个坑cyclic模式启动后不会自己停必须显式调用dmaengine_terminate_sync()。而且不要在回调里直接调用terminate因为你正在使用这个通道强行停止可能导致控制器的状态机紊乱。需要停止时置一个标志位让线程上下文去操作。6. 串口、ADC、网卡三种典型DMA场景的驱动侧实现原理讲太多容易晕我拿三个具体场景把前面内容串起来。6.1 串口DMA高波特率下CPU不再成为瓶颈串口是很多人接触DMA的起点。115200波特率、8N1格式每秒数据量是11520字节CPU逐个字符中断还能扛。一旦跑到1.5Mbps甚至更高每个字符都中断的话CPU几乎被锁死在中断里。标准做法是给串口驱动配置cyclic DMA环形缓冲。驱动初始化时分配一块环形DMA缓冲区注册period回调。每当一个period的数据到达回调里就把这段数据交给tty层。代码逻辑不复杂但对timing要求高如果回调处理太慢DMA已经在覆盖老数据就会丢数据。所以回调里绝不能再做加锁、分配内存这类耗时操作只做数据搬运和软中断调度。6.2 ADC多通道扫描顺序决定数据布局我用ADC采集多路模拟量时第一次拿到的DMA数据怎么都对不上通道。后来看数据手册才发现问题DMA缓冲区里的数据顺序不是按ADC通道编号排的而是按扫描顺序排的。最简单的理解是ADC配置成规则组扫描从通道0扫到通道3DMA buffer里的顺序就是[ch0, ch1, ch2, ch3, ch0, ch1, ...]。如果你在软件里按[ch3, ch2, ch1, ch0]去解析结果当然是乱的。另外还要注意ADC数据寄存器宽度和DMA搬运宽度必须匹配。很多MCU的ADC数据寄存器是16位DMA配置成32位搬运时一次会把两个采样数据打包搬运软件如果不按对应宽度去取数据就串位了。我的排查习惯是先配置单通道读回一组已知值确认基础通路再扩展到多通道逐组对比扫描顺序和DMA buffer内的排列。6.3 网卡收包page pool驱动的高频DMA映射网卡是Linux里DMA最频繁的设备之一每个包都可能涉及一次DMA映射。为了减少dma_map_single()的开销现代驱动普遍使用page pool和NAPI配合从page pool拿一整页内存分割成多个packet buffermap一次后就重复使用只有skb被协议栈收走或释放时才unmap。实际调驱动时很多人忽略一个点page pool分配出来的页可能来自不同内存区DMA地址范围不一定是设备支持的。网卡初始化时要正确设置dma_set_mask_and_coherent()否则某些平台会随机出现RX DMA错误。这个问题在开启IOMMU的平台上尤其隐蔽因为IOVA空间往往比物理内存范围大掩盖了真实DMA位宽限制。7. 从“failed to reset the dma”到“ADC数据错乱”几个真实排查案例理论说得再多最终还是要落到“日志报错了怎么办”。这一节我按真实排查链路写几个常见问题重点是排查思路不是直接甩答案。7.1 网卡报failed to reset the dma到底先查什么我第一次看到failed to reset the dma时第一反应是DMA API用错了。查了半天代码发现这行日志来自MAC驱动内部的软件复位流程和驱动对DMA的映射完全无关。它的本质是CPU写了一个复位寄存器但轮询等待硬件复位完成时超时了。碰到这类日志按顺序查三件事时钟和电源域DMA/MAC控制器的时钟有没有打开电源域是否正常。寄存器访问全返回0xFF或全0大概率是时钟没使能。复位信号冲突复位控制器里有没有别的设备正占用同一个复位域导致MAC复位一直完成不了。PHY的时钟和接口时序以太网MAC做DMA复位时有时候依赖PHY提供的参考时钟已经稳定。RGMII情况下PHY时钟没起来MAC状态机无法完成初始化就会报DMA复位超时。如果是RK3588这类多核SoC还要对比主线内核和厂商BSP里dts的差异。很多时候就是dts里少了assigned-clock-rates或resets属性导致驱动probe时硬件还没准备好。7.2 DMA continuous requests分配失败不是所有连续内存都叫CMA“DMA continuous requests”失败本质是连续物理内存分配不出来。常见日志有failed to allocate coherent memory、CMA: failed to allocate等。排查关键不是去看驱动代码而是先确认系统里CMA区域到底有多大cat /proc/meminfo | grep Cma如果CmaTotal本身就只有16MB而驱动申请8MB连续buffer再叠加系统的正常DMA使用很容易失败。解决方案有三种在cmdline里调大cma64M这样的保留区大小把驱动改用SG表让硬件支持离散缓冲使用dma_pool按需分配而不是一次性申请超大块。我之前还遇过一种情况dmesg里看不到明显CMA失败但驱动申请就是返回NULL。原因是驱动里用了GFP_ATOMIC在中断上下文直接调dma_alloc_coherent而该上下文不允许睡眠等待内存回收。这类问题通过增加预分配缓冲或者把分配时机提前到probe阶段解决。7.3 ADC多通道DMA数据乱一次和cache无关的错位有个项目上ADC用了四个通道DMA采集数据时而对、时而错看起来像极了cache一致性bug。我把dma_alloc_coherent换成了普通内存加dma_map_single并手动做invalidate问题依旧。后来打印DMA buffer的原始字节才发现数据确实是整齐的只是每个通道的值整体错位了一个索引。原因出在ADC初始化时配置了“通道扫描顺序为2,1,0,3”而DMA解析代码按“0,1,2,3”来取。这种错位问题肉眼很难发现因为你看到的每个通道值都来自某一个真实通道只是映射关系不对。排查ADC类DMA问题我总结了一个固定流程单通道模式采集一个已知电压确认基础通路正确。多通道模式下打印原始DMA buffer前N个样本不要做任何软件重排。对照数据手册里的扫描顺序确认buffer排列和预期一致。最后才怀疑cache和内存对齐问题。按这个顺序大概率能在十分钟内定位问题。7.4 排查DMA问题的三板斧第一斧是开CONFIG_DMA_API_DEBUG。它能在map/unmap不配对、invalid地址释放、错误大小释放时输出stack trace是DMA驱动问题最直接的“裁判”。代价是性能明显下降我只在问题复现时才开。第二斧是看/proc/interrupts里DMA中断次数。如果中断频率远超预期说明burst配置太小或者环形缓冲区深度不够硬件频繁打断CPUDMA的优势被浪费了。第三斧是ftrace的dmaengine事件。内核有/sys/kernel/debug/tracing/events/dmaengine可以看到描述符提交和完成的时序诊断回调跟不跟得上硬件传输速度。8. 调试DMA的常用手段与经验清单写DMA驱动这么多年我越来越觉得DMA问题最难的不是代码逻辑而是“地址、内存、cache、中断”这几个概念交织在一起时现象往往指向错误的方向。所以最后分享几个我日常调DMA时积累的工具使用方法算不上高大上但确实管用。内核编译时打开CONFIG_DMA_API_DEBUG后再配合dma_debug模块参数可以控制调试输出层级。如果怀疑映射配对问题观察dmesg里是否有类似DMA-API: device driver tries to free DMA memory it has not allocated的输出。它明确指出“你有一次unmap提前或者重复了并且把出错的调用栈打出来”比人肉翻代码快得多。/sys/kernel/debug/dma-api/目录下还有映射记录的统计信息包括当前映射条数、错误计数。这个目录在release版内核里常常不存在所以复现问题时要确保内核打开了相关配置。针对中断频率过高我会用一段简单的shell抓取for i in $(seq 1 5); do grep eth0 /proc/interrupts sleep 1 done看每秒新增次数是否和设备吞吐量匹配。如果发送10MB数据却产生了几万次中断那么burst或者描述符深度一定需要调大。我再整理一些零散但很重要的经验dma_map_single返回的地址不能长期保存复用。每次map/unmap必须严格配对。如果硬件支持doorbell机制每次提交后要smp_wmb()确保描述符写入对DMA控制器可见。回调函数在DMA中断上下文执行绝对不能睡眠。如果回调里要锁要用spin_lock而且要确认锁是否可替代成lockless队列。数据量大时优先用NAPI那种poll模式把处理延后到软中断。一致性映射地址dma_alloc_coherent返回的dma_addr_t不等于物理地址“等于”只是一种巧合。不要拿它去做ioremap或计算物理页号。用户空间程序不能直接触碰dma_alloc_coherent返回的内存它是内核虚拟地址。如果确实需要用户态读写DMA缓冲要么用mmap方式重新映射要么通过dma_buf框架导出但缓存属性必须配置正确否则在带cache的平台上用户态读到的数据仍然可能是脏的。最后说一个我常跟团队讲的实践DMA驱动调试不要一口气把所有优化全加上。先保证功能正确验证数据一致再调burst、多描述符、环形缓冲和page pool这些性能项。否则一旦出错你根本分不清是映射错了、cache没同步、还是burst配得太大导致的硬件状态异常。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价