内核DMA深度解析:dma-mapping、dmaengine与dma-buf实战指南
1. 从一个“玄学Bug”说起为什么内核DMA值得单独聊我第一次真正被DMA“教育”是在一块STM32F103的板子上做SPI高速采集。当时用轮询方式读一颗外部ADC采样率一上去主循环就卡得连串口打印都断断续续。后来改成中断情况好一点但高频中断把CPU吃得干干净净稍微加点滤波运算就丢数据。直到换成DMA搬运CPU占用率一下子从接近满载掉到个位数整个系统才“活”过来。这件事让我意识到DMA不是一个“可选优化项”而是嵌入式和高性能系统里绕不开的基础设施。而当我们把视角从单片机抬到Linux内核DMA这件事就从一个寄存器配置问题变成了一整套子系统dma-mapping、dmaengine、dma-buf还有各种缓存一致性、IOMMU、地址翻译的坑。标题叫“内核DMA理解浅谈”我理解这个“浅谈”不是敷衍而是想把复杂的东西讲清楚——毕竟内核DMA这块文档散、概念多、平台差异大很多人是“会用但说不清”。这篇内容我打算按一个从业者的实际理解路径来写先讲清楚内核里DMA到底解决什么问题、整体是怎么分层的再逐个拆解dma-mapping、dmaengine、dma-buf这三个核心概念然后落到实操——怎么在驱动里正确申请和映射DMA、怎么用dmaengine提交传输、怎么用dma-buf做零拷贝共享。中间会穿插我自己踩过的缓存一致性、地址对齐、生命周期管理的坑。适合正在写字符设备驱动、做音视频采集、搞异构计算或者单纯想搞懂内核DMA的读者。不管你是刚接触驱动的新手还是已经能写dmaengine slave驱动但想系统梳理的老手应该都能找到点有用的东西。2. 内核DMA的整体设计与分层思路2.1 DMA到底解决了什么问题内核为什么要“管”它先说最朴素的DMA概念。CPU执行内存拷贝是“读一个字节、写一个字节”这样一条条指令搬的搬1MB数据要占用大量指令周期。DMADirect Memory Access的本质是让一个独立的外设控制器直接在外设和内存之间搬数据CPU只负责“下命令”和“收完成通知”搬运过程不占用CPU指令流。这就是为什么SPI、UART、ADC、网卡、存储控制器都爱用DMA。但到了内核层面问题就复杂了。设备看到的“内存地址”和CPU看到的“虚拟地址”往往不是一回事。原因有几个一是CPU有MMU跑的是虚拟地址二是很多外设的DMA控制器地址位宽有限比如32位设备访问不了64位物理内存的高地址段三是有IOMMU的设备设备地址还要经过一层翻译四是缓存——CPU写的数据可能还在cache里没落到内存DMA直接去内存读就会读到旧数据。所以内核不能简单地“把指针给设备就完事”它必须提供一套抽象帮驱动回答三个问题这块内存对设备来说地址是多少这块内存的cache状态和DMA是否一致这块内存的生命周期谁来管这三个问题分别对应了dma-mapping、缓存一致性处理和dma-buf的共享管理。理解了这个动机后面所有API的存在就都顺理成章了。2.2 三层抽象mapping、engine、buffer各管一摊我习惯把内核DMA相关的东西分成三层来理解这样不容易乱。最底层是dma-mapping它管的是“地址和一致性”。驱动拿到一块内存可能是kmalloc的、可能是vmalloc的、可能是用户态传进来的要通过dma_map_single、dma_map_sg、dma_map_page这类接口把它映射成设备能用的DMA地址同时处理cache的flush/invalidate。这一层是“地基”任何DMA操作都绕不开。中间层是dmaengine它管的是“传输的抽象”。不同SoC的DMA控制器寄存器完全不一样如果每个驱动都直接操作寄存器代码没法复用。dmaengine提供了一套统一的框架驱动通过dma_request_chan申请一个通道用dmaengine_prep_slave_sg、dmaengine_prep_dma_memcpy等准备描述符然后dmaengine_submit提交、dma_async_issue_pending触发。具体怎么操作硬件由DMA控制器驱动比如dw-axi-dmac、pl330去实现。这一层是“发动机”。最上层是dma-buf它管的是“跨设备共享”。一个典型场景摄像头采集了一帧数据要同时给GPU渲染、给编码器压缩、给显示控制器输出。如果每个设备都拷贝一份内存和带宽都受不了。dma-buf提供了一种文件描述符式的共享机制让多个设备驱动能引用同一块底层buffer配合fence做同步。这一层是“共享协议”。这三层不是孤立的而是配合使用dma-buf共享的buffer最终要通过dma-mapping映射给各个设备而搬运动作可能由dmaengine完成。把这三层的关系理清内核DMA的脉络就清楚了。2.3 为什么不能“一个API打天下”有人会问既然都是DMA为什么内核不搞一个万能接口我理解核心原因是硬件差异太大抽象层次必须分开。从地址维度看有的平台是设备树描述DMA通道有的是ACPI有的有IOMMU有的没有有的cache是硬件一致的比如很多ARM64平台有的需要软件维护比如一些MIPS、部分ARM32。从传输维度看memcpy、slave_sg、cyclic循环缓冲音频常用、interleaved交织音频多通道语义完全不同。从共享维度看有同步共享、有异步fence、有跨进程跨设备。如果强行合并要么抽象泄漏把硬件细节暴露给上层要么性能损失为了通用牺牲特化路径。内核选择分层让每层只解决一类问题驱动按需组合。这也是为什么你看内核DMA代码会觉得“接口好多”但每一类接口背后都有明确的场景。理解了场景就不会觉得乱了。3. dma-mapping地址映射与缓存一致性的核心细节3.1 一致性DMA与流式DMA到底该选哪个dma-mapping里最基础的一个区分是一致性DMAcoherent DMA和流式DMAstreaming DMA。这两个概念新手最容易混。一致性DMA用dma_alloc_coherent申请特点是这块内存的cache行为由架构保证一致CPU和DMA看到的数据始终同步驱动不需要手动flush/invalidate。代价是这块内存通常被映射成uncached或write-combineCPU访问它比访问普通cache内存慢。适合什么场景适合长期存在、频繁被CPU和DMA交替访问的控制结构比如网卡的描述符环descriptor ring、DMA控制块的元数据。流式DMA用dma_map_single/dma_map_sg映射已有的内存特点是映射时做一次cache同步传输期间驱动要保证CPU不去碰这块内存或者碰了要重新同步传输完用dma_unmap_*解除映射。它保留了cache的性能优势适合“一次性搬运”的大块数据比如音频一帧、网络一个包、SPI一批采样。我自己的经验法则是控制面用一致性DMA数据面用流式DMA。描述符、状态标志这种小且频繁访问的用coherent大块payload用streaming。选错了要么性能差要么出诡异的数据不一致bug。3.2 dma_map_single的完整生命周期与参数计算流式DMA最常用的就是dma_map_single它的生命周期必须严格配对map → 使用 → unmap。我拿一个实际例子说明参数怎么算。假设驱动从kmalloc拿到一块buffer要发给SPI DMA发送void *buf kmalloc(len, GFP_KERNEL); dma_addr_t dma_handle; dma_handle dma_map_single(dev, buf, len, DMA_TO_DEVICE); if (dma_mapping_error(dev, dma_handle)) { /* 处理映射失败 */ } /* 把dma_handle配置给DMA控制器启动传输 */ /* ... 等待传输完成 ... */ dma_unmap_single(dev, dma_handle, len, DMA_TO_DEVICE);这里几个点必须注意。第一方向参数DMA_TO_DEVICE、DMA_FROM_DEVICE、DMA_BIDIRECTIONAL不是可有可无的它决定了cache同步的方向。DMA_TO_DEVICE表示数据从内存到设备映射时要flush cache把CPU写的脏数据刷到内存DMA_FROM_DEVICE表示设备到内存映射时要invalidate cache丢弃CPU cache里的旧数据避免覆盖DMA写入的新数据。方向写反数据就可能错乱。第二dma_mapping_error必须检查。在IOMMU存在或地址空间受限时映射可能失败不检查直接拿一个无效地址去配置硬件轻则传输失败重则总线错误。第三unmap的时机。必须在DMA传输真正完成后才能unmap否则可能提前释放映射导致设备访问非法地址。而且unmap之后dma_handle就失效了不能再用。第四长度和偏移。dma_map_single要求buffer是物理连续的kmalloc满足如果buffer跨页或者来自vmalloc要用dma_map_page或dma_map_sg。长度参数要和实际传输长度一致多映射少传输浪费少映射多传输越界。3.3 缓存一致性那些“数据看起来没更新”的真相缓存一致性是内核DMA里最容易出玄学bug的地方。我遇到过一个经典案例驱动用DMA从设备读数据到buffer然后CPU去读buffer发现读到的还是旧值。排查半天原因是buffer是cacheable的DMA写入内存后CPU cache里还留着旧的cache lineCPU读的时候命中了旧cache。解决方式就是正确的方向参数。DMA_FROM_DEVICE在map时会invalidate对应cache line让CPU后续读能拿到DMA写的新数据。但这里有个陷阱invalidate的粒度是cache line。如果DMA只写了buffer的一部分而buffer和相邻数据共享一个cache lineinvalidate会把相邻数据的修改也丢掉。所以流式DMA的buffer最好按cache line对齐或者确保DMA写入范围覆盖完整的cache line。另一个坑是map和unmap之间的CPU访问。规范要求流式DMA映射期间CPU不应该访问这块内存。如果非要访问比如传输中途想看进度必须在访问前做dma_sync_single_for_cpu访问后做dma_sync_single_for_device。这两个接口就是手动做cache同步的。我见过有人map之后直接memset buffer结果DMA读到的是cache里的新数据还是内存里的旧数据取决于架构行为不确定。还有一点不是所有平台都需要软件维护cache。ARM64很多平台是硬件一致的cache-coherentdma_map_single在这些平台上可能只是做个地址转换不做cache操作。所以同一份驱动在不同平台行为可能不同测试一定要在目标平台上做不能想当然。3.4 scatter-gather映射处理非连续内存的正确姿势实际数据往往不是物理连续的。比如网络包可能分散在多个page用户态buffer可能跨多个物理页。这时候要用scatter-gather映射。dma_map_sg接收一个scatterlist数组每个entry描述一段物理内存映射后返回实际映射的entry数量可能因为IOMMU合并而少于输入数量。驱动遍历映射后的sg列表把每段的dma_address和长度配置给DMA控制器。int nents dma_map_sg(dev, sgl, orig_nents, DMA_FROM_DEVICE); if (nents 0) { /* 失败 */ } for_each_sg(sgl, sg, nents, i) { /* sg-dma_address, sg-length 配置给硬件 */ } /* 传输完成后 */ dma_unmap_sg(dev, sgl, orig_nents, DMA_FROM_DEVICE);注意unmap时传的是原始nents不是映射后返回的nents这个细节很多人写错。另外sg列表的构建本身也有讲究比如用sg_alloc_table、sg_set_page等接口要保证每个entry的offset和length正确。对于需要DMA直接访问用户态buffer的场景比如零拷贝音视频通常先用get_user_pages把用户页pin住构建sg列表再dma_map_sg。这里要特别注意页的pin/unpin配对以及用户态buffer在DMA期间不能被换出或释放。4. dmaengine把“搬数据”抽象成统一接口4.1 dmaengine的模型channel、descriptor、cookiedmaengine的核心模型可以概括为通道channel 描述符descriptor cookie。通道是DMA资源的抽象一个DMA控制器可能有多个通道每个通道可以独立传输。驱动通过dma_request_chan旧接口是dma_request_slave_channel申请通道用完dma_release_channel释放。描述符描述一次传输任务。dmaengine提供了多种准备接口dmaengine_prep_slave_sg用于外设和内存之间的sg传输dmaengine_prep_dma_memcpy用于内存到内存dmaengine_prep_dma_cyclic用于循环缓冲音频播放/采集的经典场景dmaengine_prep_interleaved_dma用于交织传输。准备描述符时还可以设置回调函数和传输方向。cookie是提交传输后返回的一个标识用来追踪这次传输。dmaengine_submit返回dma_cookie_t驱动可以用它配合dma_async_is_tx_complete查询状态或者用完成回调异步处理。整个流程是申请通道 → 配置通道参数dma_slave_config→ 准备描述符 → 提交 → issue_pending触发 → 等待完成回调或轮询→ 释放通道。4.2 slave_sg传输的完整实操流程我拿一个SPI DMA接收的例子把dmaengine slave_sg的流程走一遍。假设要从SPI设备读一批数据到内存。第一步申请通道。在设备树里SPI节点会有dmas属性描述用哪个DMA控制器和通道驱动用dma_request_chan(dev, rx)申请接收通道。第二步配置slave参数。用dma_slave_config指定外设地址、传输方向、总线宽度、握手方式等struct dma_slave_config cfg { .direction DMA_DEV_TO_MEM, .src_addr spi_phys_addr, .src_addr_width DMA_SLAVE_BUSWIDTH_1_BYTE, .src_maxburst 8, .dst_addr_width DMA_SLAVE_BUSWIDTH_1_BYTE, .dst_maxburst 8, }; dmaengine_slave_config(chan, cfg);src_maxburst和dst_maxburst是突发长度影响DMA效率要根据外设FIFO深度和总线特性调。设太大可能外设来不及设太小效率低。第三步准备描述符。把接收buffer构建成sg列表映射后准备struct dma_async_tx_descriptor *desc; desc dmaengine_prep_slave_sg(chan, sgl, nents, DMA_DEV_TO_MEM, DMA_PREP_INTERRUPT); desc-callback rx_done_callback; desc-callback_param my_dev;DMA_PREP_INTERRUPT表示传输完成要产生中断这样回调才会被调用。第四步提交并触发dma_cookie_t cookie dmaengine_submit(desc); dma_async_issue_pending(chan);第五步在回调里处理数据然后重新准备下一批如果是持续采集。第六步停止和释放。用dmaengine_terminate_sync停止通道上所有传输然后dma_release_channel释放。4.3 cyclic传输音频场景的循环缓冲怎么用音频采集和播放是cyclic传输的典型场景。音频数据是连续流用普通的slave_sg每次传完要重新准备开销大且容易断流。cyclic传输让DMA在多个period之间循环搬运每个period完成产生一次中断驱动在中断里处理已完成的period同时DMA继续搬下一个。desc dmaengine_prep_dma_cyclic(chan, dma_addr, buf_len, period_len, DMA_DEV_TO_MEM, DMA_PREP_INTERRUPT);buf_len是总缓冲长度period_len是每个周期长度。回调里通过dmaengine_tx_status查询当前传输到哪个period处理对应的数据。这里的关键参数是period_len。它决定了中断频率和延迟period太小中断太频繁CPU开销大period太大延迟高音频会有卡顿感。一般按采样率、通道数、位宽算出一个period包含的帧数比如48kHz、2通道、16位period设1024帧就是约21ms的延迟比较常用。还有一个坑是缓冲对齐。cyclic缓冲的起始地址和period长度最好按cache line和DMA对齐要求对齐否则可能出现period边界处的数据不一致。4.4 dmaengine的常见坑与排查思路dmaengine用起来接口清晰但坑也不少。我整理几个高频问题。通道申请失败。常见原因是设备树dmas属性配错、DMA控制器驱动没加载、通道被占用。排查时先看dmesg有没有DMA控制器probe成功的日志再确认设备树里phandle和通道号对不对。传输卡住不完成。可能是issue_pending没调用或者描述符没设置DMA_PREP_INTERRUPT导致没有完成通知或者硬件握手信号没配对比如外设的DMA请求没使能。用dmaengine_tx_status查状态如果一直是DMA_IN_PROGRESS基本是硬件没真正启动。回调不执行。检查callback是否设置、DMA_PREP_INTERRUPT是否加了、中断是否被正确注册和处理。有些平台DMA完成中断是共享的要确认中断处理里正确区分了通道。数据错位或丢失。多半是burst配置和外设FIFO不匹配或者sg列表的地址长度算错或者cache一致性问题。可以先用小数据量、单次传输验证基本通路再逐步加复杂度。5. dma-buf跨设备零拷贝共享的机制与实操5.1 为什么需要dma-buf多设备共享同一块内存前面讲的dma-mapping和dmaengine解决的是“一个设备怎么搬数据”。但现代系统里一块数据往往要被多个设备用。比如手机拍照摄像头传感器采集 → ISP处理 → GPU渲染预览 → 编码器压缩存储 → 显示控制器输出。如果每个环节都拷贝一份内存带宽和功耗都扛不住。dma-buf就是为了解决跨设备、跨驱动、甚至跨进程的buffer共享。它的核心思想是buffer的分配者创建一个dma-buf对象导出成一个文件描述符fd其他设备通过这个fd导入attach拿到自己能用的DMA地址从而共享同一块底层内存。这个模型的好处是分配和映射解耦每个设备用自己的方式映射同一块内存生命周期由引用计数管理谁用谁attach用完detach配合fence可以做异步同步不需要忙等。5.2 exporter与importer一次共享的完整链路dma-buf的共享分两个角色exporter导出者和importer导入者。Exporter负责分配buffer并实现dma_buf_ops。核心操作包括attachimporter接入时调用可以在这里做映射准备、map_dma_buf返回sg列表给importer、unmap_dma_buf、release引用计数归零时释放内存、begin_cpu_access/end_cpu_accessCPU访问前后的cache同步。Importer拿到dma-buf fd后用dma_buf_get获取dma_buf对象然后dma_buf_attach建立attachment再dma_buf_map_attachment拿到sg列表最后用dma_map_sg映射给自己的设备用。用完dma_buf_unmap_attachment、dma_buf_detach、dma_buf_put。我拿一个GPU导入摄像头buffer的例子说明链路摄像头驱动作为exporter分配buffer并导出fdGPU驱动作为importer通过fd attachmap_attachment拿到sg映射给GPU的MMUGPU渲染完通过fence通知摄像头驱动收到fence后回收buffer。整个过程没有数据拷贝只有地址映射和同步。5.3 fence同步异步共享的关键dma-buf的同步靠fence。fence表示“某个操作完成”的信号比如“DMA传输完成”“GPU渲染完成”。exporter在buffer上附加一个reservation object里面记录当前有哪些fence。importer在使用buffer前要等待这些fence signaled使用完后可以附加自己的fence表示“我用完了”。内核里的dma_fence有几种实现dma_fence_array多个fence的组合、dma_fence_chain链式、以及各驱动自己的fence。用户态通过DMA_BUF_IOCTL_EXPORT_SYNC_FILE和DMA_BUF_IOCTL_IMPORT_SYNC_FILE把fence导出/导入成sync_file fd配合poll做异步等待。这块的坑在于死锁和循环等待。如果A等B的fenceB等A的fence就死锁了。所以fence的依赖关系要设计成有向无环图。另外fence的signaled回调是在中断上下文或工作队列里执行的回调里不能做睡眠操作。5.4 dma-buf在音视频与异构计算中的实际应用dma-buf在音视频管线里几乎是标配。V4L2的videobuf2框架支持DMABUF内存类型摄像头驱动可以导出dma-buf给显示或编码器。DRM/KMS的framebuffer也可以用dma-buf导入实现显示和GPU共享。ALSA的压缩音频也可以走dma-buf。在异构计算里dma-buf用于CPU、GPU、NPU、DSP之间的数据共享。比如一个推理任务CPU预处理的数据放dma-bufNPU导入做推理结果再放另一个dma-buf给CPU后处理。这样避免了多次拷贝。实操中要注意的是格式和布局的约定。dma-buf本身只描述内存不描述数据格式。共享双方要约定好像素格式、stride、plane数量等。通常通过ioctl或元数据传递这些信息。如果格式不匹配导入方按错误的stride解析画面就会错位。6. 常见问题与排查技巧实录6.1 缓存一致性问题的快速定位缓存一致性问题最难查因为现象随机、和时序相关。我的排查套路是先确认方向参数对不对再确认map/unmap配对然后看buffer对齐。如果怀疑是cache问题可以临时把buffer改成dma_alloc_coherentuncached验证。如果换成coherent就好了基本确定是cache同步问题。然后回头检查方向参数、sync调用、对齐。还有一个技巧是用dma_sync_single_for_cpu在CPU读之前强制同步看数据是否变正确。如果变正确了说明之前缺了同步。6.2 DMA地址映射失败的排查清单映射失败dma_mapping_error返回真通常有几个原因IOMMU映射表满、地址超出设备位宽、内存不可映射比如vmalloc的某些区域。排查时先看dmesg有没有IOMMU相关的错误再确认设备dma_mask和coherent_dma_mask设置是否正确。很多驱动忘了设置dma_mask导致默认32位掩码分配64位地址就失败。6.3 传输超时与中断丢失的处理传输超时一般是硬件没启动或完成中断没来。先确认DMA控制器的时钟和电源是否使能再确认外设的DMA请求是否使能。中断丢失可能是中断被屏蔽、中断处理没注册、或者共享中断里没正确判断。可以在中断处理里加计数看是否进了中断。6.4 常见问题速查表现象可能原因排查方向数据读到旧值cache未invalidate检查DMA_FROM_DEVICE方向、sync调用数据部分错乱cache line共享冲突buffer按cache line对齐映射失败dma_mask未设或IOMMU满检查dma_mask、dmesg传输不完成未issue_pending或握手未使能检查提交流程、外设DMA使能回调不执行未设DMA_PREP_INTERRUPT检查描述符flags、中断注册音频断流period设置不合理调整period_len、缓冲对齐dma-buf导入失败格式/stride不匹配核对双方格式约定7. 我个人的一些实操体会写内核DMA驱动这些年最大的体会是DMA的bug往往不在DMA本身而在它和cache、内存管理、并发的交互上。单纯配置寄存器搬数据不难难的是保证在各种边界条件下数据一致、生命周期正确。另一个体会是先在简单场景验证通路再上复杂场景。我习惯先用dma_alloc_coherent做一次memcpy测试确认DMA控制器基本工作再换成streaming映射测cache再上sg和cyclic。一步步来出问题容易定位。还有多看目标平台的DMA控制器驱动源码。dmaengine的框架是统一的但具体控制器的行为差异很大比如描述符格式、对齐要求、最大传输长度。这些细节在框架文档里不一定写但驱动源码里有。最后分享一个小技巧调试DMA时如果怀疑数据没搬对可以在DMA完成后用dma_sync_single_for_cpu同步然后dump内存对比。如果同步后数据对了就是cache问题如果还不对就是地址或长度配置问题。这个二分法能快速缩小范围。dma-buf这块后续如果要做跨进程共享可以研究一下udmabuf和dma-heap它们提供了用户态直接分配和共享DMA buffer的接口在虚拟化和容器场景里用得越来越多。这块内容展开又是另一大篇了有机会再单独聊。