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数据要占用大量指令周期。DMA(Direct 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里最基础的一个区分,是一致性DMA(coherent DMA)和流式DMA(streaming DMA)。这两个概念新手最容易混。
一致性DMA用dma_alloc_coherent申请,特点是:这块内存的cache行为由架构保证一致,CPU和DMA看到的数据始终同步,驱动不需要手动flush/invalidate。代价是这块内存通常被映射成uncached或write-combine,CPU访问它比访问普通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 line,CPU读的时候命中了旧cache。
解决方式就是正确的方向参数。DMA_FROM_DEVICE在map时会invalidate对应cache line,让CPU后续读能拿到DMA写的新数据。但这里有个陷阱:invalidate的粒度是cache line。如果DMA只写了buffer的一部分,而buffer和相邻数据共享一个cache line,invalidate会把相邻数据的修改也丢掉。所以流式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-coherent),dma_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、cookie
dmaengine的核心模型可以概括为:通道(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。核心操作包括:attach(importer接入时调用,可以在这里做映射准备)、map_dma_buf(返回sg列表给importer)、unmap_dma_buf、release(引用计数归零时释放内存)、begin_cpu_access/end_cpu_access(CPU访问前后的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并导出fd;GPU驱动作为importer,通过fd attach,map_attachment拿到sg,映射给GPU的MMU;GPU渲染完,通过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的fence,B等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-buf,NPU导入做推理,结果再放另一个dma-buf给CPU后处理。这样避免了多次拷贝。
实操中要注意的是格式和布局的约定。dma-buf本身只描述内存,不描述数据格式。共享双方要约定好像素格式、stride、plane数量等。通常通过ioctl或元数据传递这些信息。如果格式不匹配,导入方按错误的stride解析,画面就会错位。
6. 常见问题与排查技巧实录
6.1 缓存一致性问题的快速定位
缓存一致性问题最难查,因为现象随机、和时序相关。我的排查套路是:先确认方向参数对不对,再确认map/unmap配对,然后看buffer对齐。
如果怀疑是cache问题,可以临时把buffer改成dma_alloc_coherent(uncached)验证。如果换成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的接口,在虚拟化和容器场景里用得越来越多。这块内容展开又是另一大篇了,有机会再单独聊。