
做嵌入式Linux这几年几乎每个项目都绕不开DMA。串口高速收发、网卡收包、音频I2S、图像采集甚至ADC采样底层全是DMA在搬数据。很多刚入门的朋友一听DMA就觉得难其实它本质就是个“搬运工”难点不在概念而在配置、内存映射和调试这三个环节。这篇Linux DMA开发指南一我按自己的实际开发路径来写先从框架和核心API讲清楚再给串口DMA、ADC多通道DMA这类高频场景做拆解最后把rk3588、GD32这些平台上常见的问题排查经验摆出来。适合正在做驱动开发、或者被DMA整得一头雾水的嵌入式工程师。1. DMA基础与Linux DMA框架设计思路1.1 为什么需要DMA从一次串口收发说起先从最典型的场景聊。假设你负责一个4G模块的串口通信波特率从9600提到921600数据量一下子涨了近百倍。没有DMA的时候每个字节过来CPU都要进一次中断中断服务程序里读状态寄存器、读数据寄存器、判断帧结束再往环形缓冲区里放。一次中断几百纳秒到一两微秒看起来不多但数据量大起来CPU就再也干不了别的活了。我在一个项目里实测过跑115200波特率、持续收发时CPU占用率大概10%换到1Mbps甚至更高打满30%都正常。对Linux系统来说CPU还要跑协议栈、跑应用这种中断风暴根本扛不住。DMA解决的核心问题就是“CPU不参与搬运”。它是一套独立的硬件搬运引擎有自己的源地址、目的地址、搬运长度甚至能自己构造描述符链表一块接一块地搬。让DMA搬数据CPU只需要在开始的时候配置好搬运任务然后等一整个传输完成后再去处理结果。这样中断频率从“每个字节一次”降到了“一整包一次”CPU占用率能降一个数量级。这里要打消一个误区DMA不是“快到飞起”的魔法它的总线带宽可能和CPU直接拷贝差不多甚至还要额外消耗总线周期。真正的价值在于释放CPU让CPU可以去做协议解析、业务逻辑而不是沦为纯搬运工。所以判断要不要上DMA标准很简单数据量大不大、CPU是不是瓶颈。如果只是几十个字节的控制命令中断完全够用盲目上DMA反而增加复杂度。1.2 Linux DMA API的层次划分Linux内核里的DMA相关API看起来很多新手容易懵其实可以分成两大类。第一类是给“外设传输”用的dmaengine框架对应include/linux/dmaengine.h。这套API是内核把各厂商DMA控制器的驱动统一抽象之后提供的SoC厂商负责实现底层的dmaengine驱动你作为驱动开发者只需要调用通用接口比如dma_request_chan、dmaengine_prep_slave_single、dmaengine_submit、dmaengine_issue_pending。它的适用场景是设备外设UART、SPI、I2S、ADC等和内存之间搬数据由DMA控制器控制总线事务。你要做的是配置外设侧地址、内存侧缓冲区、方向和突发长度。第二类是给“任意内存参与DMA”用的DMA映射API对应include/linux/dma-mapping.h。这套API解决的是更底层的问题DMA控制器访问的是物理地址而内核分配的内存可能是虚拟地址、可能涉及页表映射还牵扯Cache一致性。dma_alloc_coherent、dma_map_single、dma_map_sg就是干这个的。不管你用dmaengine还是自己操作DMA寄存器只要内存要交给硬件访问基本都得和这些函数打交道。这两类的关系可以这么理解dmaengine是“叫车平台”你把起点终点告诉它它派一辆车去拉活DMA映射API是“修路和画车位”路不通、车位画错车跑不起来。实际项目中驱动很多时候两个都要用。比如我们做UART DMA接收先用dma_request_chan拿到DMA通道又用dma_alloc_coherent或dma_map_single给接收缓冲区做映射两边配合才行。另外设备树里DMA通道的声明是另一条线索。通常在设备节点里写dmas dma0 0, dma0 1; dma-names tx, rx;dma0对应的dma节点是SoC的DMA控制器后面的0、1是请求线编号代表硬件上这个外设的TX/RX请求接到了DMA控制器的哪一路。dma-names则是给驱动用的别名比如串口驱动里dma_request_chan(dev, rx)就是拿接收方向通道。这个编号特别容易出错设备树写错一点DMA请求就没法路由到正确的外设后面会提到一些典型报错。2. 核心细节解析从dma_slave_config到scatter-gather2.1 关键数据结构与配置参数在dmaengine框架里最核心的配置结构体是struct dma_slave_config。我用串口接收举一个实际例子struct dma_slave_config cfg {0}; cfg.direction DMA_DEV_TO_MEM; cfg.src_addr res-start; // 串口数据寄存器的物理地址 cfg.src_addr_width DMA_SLAVE_BUSWIDTH_1_BYTE; // 串口按字节读 cfg.src_maxburst 16; // 每次突发读16字节 cfg.dst_addr 0; cfg.dst_addr_width DMA_SLAVE_BUSWIDTH_UNDEFINED; cfg.dst_maxburst 0;这里有几个容易出错的点。src_addr必须填物理地址。很多新手直接把readl用的虚拟地址填进去DMA控制器可不知道什么mmu、页表它拿着这个地址去总线上找外设寄存器填虚拟地址大概率总线错误或者数据全是零。做ARM平台时ioremap返回的是虚拟地址要拿到物理地址可以使用of_address_to_resource解析设备树中的reg属性然后resource-start就是物理地址。src_addr_width是外设数据寄存器的位宽。串口一般是1字节老一点的16C550 UART FIFO是按字节读某些音频控制器是按4字节读。这个和maxburst配合决定了一次突发搬运多少字节。比如DMA_SLAVE_BUSWIDTH_1_BYTE加src_maxburst 16表示一次突发读16个字节DMA控制器会连续读16次、每次1字节。如果外设FIFO只有8个字节深你填16FIFO溢出数据就丢了。所以maxburst要看外设FIFO深度和数据手册。配置完成之后调用dmaengine_slave_config(chan, cfg);如果你的外设方向和参数可能动态变化比如半双工SPI一会儿发一会儿收每次切换时都要重新调dmaengine_slave_config否则DMA还会用旧的方向配置去干活。这个坑我在SPI驱动里踩过后面详细说。2.2 连续缓冲与离散缓冲dma_alloc_coherent与sg_tableDMA要搬运的数据放在内存里但内存怎么给DMA用里面讲究很多。如果驱动需要一块和硬件共享的小内存比如描述符、状态块最简单就是一致映射dma_addr_t dma_handle; void *cpu_addr; cpu_addr dma_alloc_coherent(dev, size, dma_handle, GFP_KERNEL);dma_alloc_coherent这块内存有几个特点物理上连续dma_handle是DMA能用的总线地址并且对CPU和设备来说Cache是一致的。所谓一致就是硬件写这块内存CPU随时能看到最新值CPU改了数据硬件也不会因为Cache残留读到旧值。代价是分配开销大、速度慢不适合大块数据缓冲。如果大块数据用流式映射dma_addr_t dma_handle; dma_handle dma_map_single(dev, cpu_addr, len, DMA_FROM_DEVICE); // 发起DMA传输... dma_unmap_single(dev, dma_handle, len, DMA_FROM_DEVICE);流式映射强调的是“一次传输一个方向”因此软件必须在map之后、unmap之前遵守规则。DMA_FROM_DEVICE模式下设备往内存写数据CPU在DMA完成后需要把数据到内存的Cache行失效才能读到正确数据。这个动作由dma_unmap_single内部完成前提是你真正调了unmap。很多人忘记unmap结果就是第二次、第三次传输时读到脏Cache里的旧数据表现成“数据偶尔更新、偶尔旧”。另一个方向DMA_TO_DEVICECPU先写数据然后要把Cache行刷回内存所以要在写完之后map、传输完成后unmap。再往上一个层次就是scatter-gather内核里用struct scatterlist组织多个不连续缓冲区构建成sg_table。如果DMA控制器硬件支持SG模式就可以通过dmaengine_prep_slave_sg一次提交多个段让DMA依次搬运搬运完一段自动切下一段。对网卡这类需要维护环形描述符的场景SG几乎是标配。很多人在问“spi需要两个dma吗”实际上如果SPI收发数据是分散在多个缓冲区里又开了SG那每个方向各需要一条DMA通道两条通道还要分别支持SG。2.3 一个简单的DMA读操作配合理论写一段简化但能运行的dmaengine读操作流程。以我们从某个外设读一小段数据为例#include linux/dmaengine.h #include linux/dma-mapping.h #include linux/completion.h struct dma_chan *chan; struct dma_slave_config cfg; struct dma_async_tx_descriptor *desc; struct completion cmp; dma_addr_t buf_dma; void *rx_buf; u32 len 1024; enum dma_status status; /* 1. 请求DMA通道 */ chan dma_request_chan(dev, rx); if (IS_ERR(chan)) { dev_err(dev, failed to request dma chan: %ld\n, PTR_ERR(chan)); return PTR_ERR(chan); } /* 2. 配置DMA传输参数 */ memset(cfg, 0, sizeof(cfg)); cfg.direction DMA_DEV_TO_MEM; cfg.src_addr phys_addr_of_device_fifo; cfg.src_addr_width DMA_SLAVE_BUSWIDTH_2_BYTES; cfg.src_maxburst 8; ret dmaengine_slave_config(chan, cfg); /* 3. 分配接收缓冲区并映射 */ rx_buf kzalloc(len, GFP_KERNEL); buf_dma dma_map_single(dev, rx_buf, len, DMA_FROM_DEVICE); /* 4. 准备描述符 */ init_completion(cmp); desc dmaengine_prep_slave_single(chan, buf_dma, len, DMA_DEV_TO_MEM, DMA_PREP_INTERRUPT); if (!desc) { dev_err(dev, failed to prep slave single\n); goto err; } desc-callback dma_read_done; desc-callback_param cmp; /* 5. 提交并启动 */ dmaengine_submit(desc); dma_async_issue_pending(chan); /* 6. 等待完成 */ ret wait_for_completion_interruptible_timeout(cmp, msecs_to_jiffies(1000)); if (ret 0) { dev_err(dev, dma read timeout\n); goto err; } /* 7. 收尾清理 */ dma_unmap_single(dev, buf_dma, len, DMA_FROM_DEVICE); dmaengine_terminate_all(chan); dma_release_channel(chan);这段流程是绝大多数dmaengine外设驱动的缩影。有几个细节必须强调。dmaengine_submit只是把描述符放进了DMA引擎的队列不会真正启动。启动动作是dma_async_issue_pending。如果你只submit不issueDMA永远不干活这是新手最常见的问题之一。有些平台提交后立即返回但语义上你必须叫一声“开始搬运”DMA才会动起来。回调callback会从DMA中断上下文调用所以里面不要做耗时操作通常就complete(cmp)唤醒等待线程。如果你的驱动要处理大量数据更推荐用dmaengine_tx_status轮询状态或者配合tasklet/NAPI而不是在中断里直接拷贝数据。wait_for_completion_interruptible_timeout的返回值要仔细判断。0表示超时负数表示被信号打断大于0才是完成。很多驱动代码只判断ret 0就报错但被信号打断时数据其实没传完此时应该重试或直接终止。另外超时之后一定要调用dmaengine_terminate_all停掉可能还在跑的硬件否则DMA还占着通道或者还在往缓冲区写再启动下次传输时会冲突。3. 实操过程串口DMA与ADC多通道DMA的实现要点3.1 串口DMA 空闲中断嵌入式最常用的接收方案串口在Linux下用DMA收发驱动层最常用的方案是“DMA接收空闲中断”。为什么要加空闲中断因为串口通信常常是不定长的DMA本身不知道一帧数据在哪里结束。如果每次都开固定长度DMA要么数据短了等不够一个长度、要么数据长了缓冲区装不下。空闲中断的作用是串口线路上超过一定时间没有新字节到达控制器就认为一帧数据接收完毕产生一次中断此时驱动把DMA缓冲区里的数据交给上层。这里的关键有两点。第一DMA缓冲区要足够大或者用环形DMA。Linux内核对UART的DMA接收多数是“半满中断空闲中断”组合DMA不停地把数据搬进环形缓冲区CPU只处理半满和空闲两个事件。如果你用固定长度接收必须确保缓冲区能覆盖最大帧长否则数据丢尾。我在实际项目里就吃过亏DMA buffer设了512字节应用层偶尔发一条700字节的AT指令后面几百字节全丢排查了大半天才发现是缓冲区长度问题。第二空闲中断的时间窗口要调好。这个时间一般和波特率相关波特率越高两个字节之间的间隔越短。窗口设大了短帧要等很久才被上报实时性差窗口设小了UART断断续续有数据时会被拆成多个包。很多SoC的串口驱动提供类似uart_set_thumb或者receive_timeout寄存器配置需要根据实际业务调整。我们的4G模块项目里115200波特率下空闲中断窗口设在2~3个字节时间大约200微秒左右效果比较平衡。还有个常见的坑是串口DMA接收时FIFO溢出。DMA虽然有maxburst突发读取但如果突发长度比FIFO深还大硬件会在读取过程中覆盖掉还没搬走的数据。看数据手册里FIFO是16字节还是32字节maxburst不要超过这个值。之前一个平台串口FIFO只有8字节DMA配了16字节突发跑高速时偶发丢字节后来改成8就正常了。3.2 ADC多通道DMA采集STM32CubeMX与Linux下的对照ADC多通道DMA采集这颗热词我在MCU和Linux两个方向都接触过。先说STM32CubeMX。配置时你要在ADC Settings里打开Scan Conversion Mode把Continuous Conversion Mode选成Enabled然后在DMA Settings里添加一个DMA Request模式选Circular数据宽度按ADC分辨率选16位就Half Word然后把ADC转换结果映射到一个数组。CubeMX自动生成的代码会调用HAL_ADC_Start_DMA(hadc, (uint32_t*)adc_buf, channel_num * sample_num)后续数据会持续填充到adc_bufCPU可以做别的事。有个值得注意的点多通道扫描时DMA是按通道顺序往缓冲区里填的。比如通道0~3扫描结果依次填到adc_buf[0]到adc_buf[3]循环往复。如果中间某个通道配置了采样时间长它的数据还是按固定顺序出现只是转换时间变长。所以解析数据时必须知道通道顺序别想当然地认为缓冲区第一项一定是通道0和初始化顺序、扫描顺序都对上才准。Linux下做ADC连续采样如果SoC里的ADC控制器支持DMA一般会接入内核IIO框架。IIO驱动里同样有DMA缓冲支持用户在应用层通过iio_read批量读数据底层也会用dmaengine把ADC的数据寄存器搬到内存。原理和STM32没本质区别ADC转换完成后触发DMA请求DMA把结果写入环形缓冲区采满半缓冲区或者达到一定条件就唤醒应用读取。差别只在于Linux要处理设备和内存映射的通用性IIO框架帮你把很多DMA细节屏蔽掉了。这里要说一个血泪经验ADC DMA数据紊乱很多时候不是DMA本身的问题而是数据缓冲区的“心跳”没处理好。比如STM32用了DMA半传输中断在中断里拷贝前半段数据但后半段还没填完CPU读到了旧数据表现就是数据错乱。解决方法是每次DMA传输完成或者半完成时只在“安全区”读数据通常用两个缓冲区交替ping-pong buffer一个被DMA写一个被CPU读写满后再交换角色。这个模式不仅适用于ADC串口、音频DMA全都在用。3.3 分配DMA通道SPI是否需要两个DMA关于“SPI需要两个DMA吗”直接说结论如果需要全双工收发标准SPI接口要求TX和RX同时进行所以大概率要两条DMA通道一条方向为DMA_MEM_TO_DEV负责发送一条方向为DMA_DEV_TO_MEM负责接收分别对应设备树里的tx和rx。但也不是绝对的。如果应用只发不收或者只收不发那只需要一条通道。另外如果SPI控制器的FIFO足够大、数据量又小纯轮询或者中断反而更简单。我在一个使用W25Q128 Flash的Linux项目里SPI读Flash数据量较大就只给读方向配了DMA写Flash时因为数据量相对小、且写操作本身还要等Flash内部编程完成就用普通的spi_sync传输CPU开销还能接受。配置SPI双通道时最容易忽略的是两边的同步。全双工SPI的TX DMA和RX DMA要同时启动但两个通道来自同一个DMA控制器时启动顺序如果不对可能出现收发错位。比如Master模式你先启动了RX DMA然后又启动TX DMA由于SPI时钟还没开始RX通道可能一直空等等你启动TX后两个通道才算配合上。内核的spi驱动里一般会同时准备两个描述符再通过dmaengine_submit和dma_async_issue_pending按顺序启动。调试时如果发现SPI收到的数据整体偏移了一个或多个字节先检查两条DMA通道的启动时序。另外某些SoC的DMA通道是有限的两个通道不一定能从同一个DMA控制器申请到。比如一个外设的TX请求只能接到DMA0RX请求只能接到DMA1这时候SPI驱动的DMA初始化代码必须分别从两个控制器申请通道不能假设一个dma_request_chan就能全搞定。设备树里dmas属性写两条不同controller的线dma-names分别命名驱动侧也要对应处理。4. 常见问题与排查技巧实录4.1 rk3588报错 failed to reset the dma 怎么查这个报错在rk3588平台上特别常见尤其是以太网方向。完整日志经常是rk_gmac-dwmac fe2b0000.ethernet eth0: failed to reset the dma出现这个说明DMA控制器在初始化复位时超时或失败系统无法把DMA引擎恢复到初始状态。我做过一个RK3588项目板子偶尔启动时网络起不来dmesg里就是这个报错。排查思路我总结成三步。第一先看电源、时钟、复位信号。RK系列的DMA控制器挂在特定的Power Domain下如果驱动在DMA复位前没有打开对应的power-domain或者Clock被关闭DMA模块根本没工作复位自然失败。设备树里检查clocks、resets属性是否齐全系统起来后看/sys/kernel/debug/clk/clk_summary确认时钟是否开启。我用devmem2直接读DMA控制器的状态寄存器发现复位期间寄存器全为0基本就是没供电。第二检查DMA控制器是否被别的驱动占用。如果SoC里有一个DMA通道被其他外设长时间占用或者该外设没有正确释放通道DMA控制器在复位时会检测到通道忙。这种情况下复位操作会卡住在等通道空闲直到超时报错。排查方法是关掉一部分外设驱动比如先用最小系统只保留网络看报错是否消失。第三查看DMA控制器的errata或者SoC的BSP补丁。有些RK平台对DMA复位有特殊的寄存器操作顺序公开SDK的dwmac驱动或dmaengine驱动里可能已经有规避代码。如果你的代码基于旧内核移植到新芯片很可能漏掉了官方补丁。我后来把内核升级到BSP指定的版本问题就解决了。遇到这个报错别一上来就怀疑驱动逻辑先把“能不能复位”这个前提打牢。DMA控制器都复位不了后面配置描述符都是白搭。4.2 DMA数据紊乱GD32 ADC DMA和缓存一致性“GD32E230 ADC DMA数据紊乱”这颗热词我猜很多人是被ADC多通道扫描DMA不定长数据坑过。GD32E230是Cortex-M23内核没有复杂Cache所以问题一般不在Cache一致性上。常见原因是多通道扫描时DMA缓冲区宽度和ADC采样结果宽度不匹配。GD32 ADC分辨率可能是12位而DMA配置如果是按字节搬运一个通道结果占2个字节DMA和ADC的“步长”对不上数据就会错位通道0的数据跑到通道1的位置去。解决办法是把DMA数据宽度配成半字16bit同时确保缓冲区元素类型是uint16_t。如果开了多通道扫描还要确保DMA传输数据长度是通道数乘以采样次数别少配一个通道。在Linux侧DMA数据紊乱最常见的原因就是缓存一致性问题。一个反面案例驱动里用kmalloc分配缓冲区然后virt_to_phys拿物理地址填给DMA控制器结果硬件写入的数据在内存里CPU却因为Cache里保留了旧值读出来全是脏数据。或者反过来CPU写了新数据Cache没刷到内存DMA搬走的还是旧数据。正确做法是能用dma_alloc_coherent就用它实在要用普通缓冲区就必须dma_map_single/dma_unmap_single走一遍。dma_alloc_coherent在支持非一致性DMA的架构上会帮你处理好Cache属性让你不用操心刷Cache的时机。调试这类问题一个很笨但很有效的办法关掉Cache再测试。ARM平台可以临时修改页表属性或使用DCACHE_OFF这种调试手段如果关Cache后数据正常了基本可以断定是Cache一致性问题。否则问题更可能在DMA配置或外设时序上。4.3 中断接收还是DMA接收如何决定“CAN总线一般中断接收还是DMA接收”这种问题经常被问我的判断原则很直接看协议和硬件设计。中断接收适合数据量小、频率低、不定长但每帧间隔明显CPU占用可以接受。比如CAN总线标准CAN控制器自带多个硬件FIFO/邮箱往往已经有中断去读而且帧最大才8字节CAN FD 64字节中断开销不大一般不需要DMA参与。显然你为每个CAN帧配一个DMA搬运反而浪费。DMA接收适合数据量大、频繁、连续比如高速UART、SPI Flash多页读、ADC连续采样、网卡收包。这些场景中每个数据单位很短但总量很大中断处理一件一件读会拖垮CPU。可以把两者列成一个对照表维度中断接收DMA接收CPU占用每个数据进中断CPU开销高搬运由硬件完成CPU只处理帧事件实时性单字节延迟低但处理长数据易阻塞中断频率低批量处理延迟略高实现复杂度简单仅配置外设中断需要配置通道、内存映射、缓冲管理适用场景低频命令、CAN帧、少量传感器高速串口、音频、ADC、网卡批量数据丢数据风险中断响应不及时可能溢出缓冲区配置不当更容易丢数据有个折中方案低速段用中断数据量起来后自动切DMA。其实Linux的串口驱动里就是这么做的比如8250驱动在小数据量时用中断当数据流量大时启动DMA。这个切换策略需要经验起步阶段不必强求先把单一模式调稳定更重要。4.4 DMA调试工具与经验最后分享几个我常用的DMA调试手段。第一dmesg永远是最先看的。DMA通道申请失败、配置失败一般都会打印error。很多报错信息非常直白比如Engine TX was not ready、failed to get dma channel搜索引擎一搜就有答案。第二/proc/interrupts可以看中断频率。如果DMA配置成中断通知中断号下的计数变化能反映DMA工作是否正常。串口DMA接收时如果中断计数和报文数量对不上说明有些传输没触发通知。第三/sys/kernel/debug/dmaengine/summary这个节点可以查看系统里dmaengine通道的状态哪个通道被哪个驱动占用、当前是否有活跃传输。有些SoC没有实现但值得一试。第四devmem直接读寄存器。DMA控制器一般都有状态寄存器、描述符地址寄存器、剩余传输长度寄存器。比如你怀疑DMA没启动读一下控制寄存器的使能位怀疑传输长度不对读剩余长度寄存器对比期待值。别小看这个土办法它能绕过所有软件层干扰直接确定硬件在干嘛。另外perf或ftrace可以用来追踪函数调用。比如用trace-cmd record -e dmaengine:dma_async_tx_submit这类事件能看到描述符提交量。不过对入门阶段先掌握前几种就够用了。调试DMA时我发现大部分问题最后都指向三个地方设备树通道编号写错、没有调用dma_async_issue_pending启动传输、忘记处理Cache一致性。如果你排查卡住了先按这三条对一遍比漫无目的地看代码有效得多。还有一个容易混淆的概念是“DMA Proxy”。Linux网络虚拟化里的DMA Proxy是针对虚拟机迁移用的机制和嵌入式驱动开发里说的DMA搬运完全是两码事别在同一次搜索里被带偏。做入门开发先专注dmaengine这个体系就行。这段内容写下来基本覆盖了Linux DMA开发的入门主线从框架划分、核心配置到串口和ADC的实用场景再到常见报错排查。对我个人来说DMA开发真正难的点从来不是函数怎么调而是理解硬件时序和内存一致性。多读SoC的DMA控制器手册多在真实板子上用devmem看寄存器多攒几个“踩坑复现”的案例你的DMA调试速度会越来越快。后面再遇到更复杂的场景比如video buffer的DMA、CMA连续内存分配、多队列DMA性能调优再另开一篇慢慢聊。