嵌入式DMA驱动开发实战:从STM32串口收发到Linux DMA引擎
发布时间:2026/10/30 6:06:05 锦皓数字建站

DMA这东西刚接触嵌入式的人容易走两个极端。一种是觉得它高深莫测能不用就不用串口收数据老老实实一个字节一个字节进中断CPU被频繁打断还自我感觉良好另一种是听说DMA能解放CPU上来就配一堆通道结果数据错位、传输不完整、中断进不去调了两天怀疑人生。我在带新人和做项目的过程中这两种情况都见过太多次。这篇就围绕嵌入式驱动开发里的DMA把它的工作机制、配置思路、常见坑和实测经验一次讲透不管你是刚学STM32的在校生还是已经在做Linux驱动、GD32、HC32F460这类平台的工程师都能从中找到能直接用的东西。1. DMA到底在替CPU干什么活1.1 从一次串口收包说起先看一个最朴素的场景你用STM32的串口接收一帧100字节的数据。不用DMA的写法通常是开接收中断每来一个字节进一次中断在中断里把数据存进缓冲区再判断帧头帧尾。100字节就是100次中断如果波特率是115200理论上每秒最多能来11520个字节也就是每秒上万次中断。CPU主频就算跑到72MHz这么频繁地进出中断上下文保存恢复的开销累积起来也相当可观更别说你还有别的任务要跑。DMADirect Memory Access直接存储器访问解决的正是这个问题。它本质上是一个独立于CPU内核的外设控制器能在存储器和外设之间、存储器和存储器之间直接搬运数据整个过程不需要CPU逐字节参与。CPU只需要做三件事配置好源地址、目的地址、传输长度和触发方式然后启动传输剩下的搬运工作DMA自己完成搬完了再通过中断通知CPU一声。打个比方CPU是公司老板数据搬运是搬砖。没有DMA的时候老板亲自一块一块搬砖搬一块记一下账累得半死还干不了别的。有了DMA老板雇了个搬运工DMA控制器告诉他从哪搬到哪、搬多少然后老板去开会处理别的事搬运工搬完了敲个门说搬完了老板再回来验收。这个类比虽然糙但把DMA的核心价值说清楚了把CPU从重复的、机械的数据搬运中解放出来。1.2 DMA控制器的内部结构长什么样理解DMA的内部结构对后面配置和排错非常关键。以常见的STM32F103/F407系列为例DMA控制器内部大致有这么几个核心部件通道Channel或流Stream每个通道对应一组固定的外设请求源。F103是7个通道的DMA1加5个通道的DMA2F407则换成了流的概念每个流有8个可选通道。通道和外设的对应关系是硬件固定的不能随便乱配这是新手最容易踩的坑之一。源地址寄存器与目的地址寄存器分别存放数据从哪来、到哪去。这两个地址在传输过程中会自动递增或保持不变取决于你配置的增量模式。传输计数器NDTR记录还剩多少数据没搬。每搬一个数据单元计数器减一减到零触发传输完成中断。优先级仲裁器当多个通道同时请求时硬件根据软件优先级和硬件优先级决定谁先搬。FIFO仅F4/F7/H7等高级系列有用于缓冲和打包数据支持突发传输Burst能显著提升总线利用率。这里要特别强调一点DMA通道和外设的映射关系是硬件写死的。比如STM32F103的USART1_TX固定对应DMA1_Channel4USART1_RX固定对应DMA1_Channel5你配错了通道数据根本不会动。很多配置看起来没问题但就是不通的案例最后查出来都是通道选错了。查手册的时候一定要翻到DMA request mapping那张表别凭记忆。1.3 三种传输方向与增量模式的组合逻辑DMA的传输方向有三种外设到存储器、存储器到外设、存储器到存储器。每种方向下源和目的地址的增量模式Increment需要分别配置这个组合逻辑如果搞混数据就会全部堆在同一个地址上或者地址乱跳导致越界。传输方向典型场景源地址增量目的地址增量外设到存储器串口接收、ADC采集禁止外设寄存器地址固定使能缓冲区逐字节写入存储器到外设串口发送、DAC输出使能数据逐字节读出禁止外设寄存器地址固定存储器到存储器内存拷贝、图像缓冲使能使能这个表看着简单但实际配置时外设寄存器的地址是固定的所以外设那一侧的增量必须禁止否则DMA会去访问一个不存在的地址轻则传输失败重则触发硬件错误。存储器那一侧通常要使能增量让数据依次写入缓冲区的不同位置。我见过有人把串口接收配成源地址增量结果DMA去读了一堆无关的寄存器地址收到的数据全是乱的。2. 串口DMA收发从配置到跑通的完整链路2.1 为什么串口场景最适合用DMA串口是DMA最经典的应用场景没有之一。原因很直接串口数据是流式的、连续的、方向明确的而且收发双方的速度往往不匹配。比如你用115200波特率接收上位机发来的固件包一包可能几KB如果靠中断逐字节收CPU会被打断得七零八落如果靠轮询那CPU就彻底被占死了别的活别想干。用DMA接收配置好缓冲区和长度启动后CPU该干嘛干嘛收满一帧或者收到空闲帧IDLE再处理。用DMA发送把要发的数据丢进缓冲区启动传输发完了中断通知你中间CPU完全不用管。这就是为什么几乎所有正经的嵌入式项目串口都会上DMA。在Linux驱动开发里串口DMA的体现形式又不一样。Linux的串口子系统serial core已经帮你把DMA框架搭好了驱动工程师要做的是实现DMA相关的回调比如dma_request_chan申请通道、在start_tx里启动DMA传输、在DMA完成中断里调用uart_write_wakeup唤醒上层。这块后面单独展开。2.2 STM32 HAL库下的串口DMA接收配置以STM32F407 HAL库为例串口DMA接收的配置步骤大致如下。这里我用CubeMX生成基础代码再手动补充关键逻辑这样最贴近实际项目流程。第一步在CubeMX里使能USART1模式选Asynchronous波特率1152008N1。然后在DMA Settings标签页点Add给USART1_RX添加一个DMA请求通道会自动选到DMA2_Stream2F407的映射方向Peripheral To Memory优先级Medium模式选Normal还是Circular看需求。第二步配置DMA参数。Mode我一般接收用Circular循环模式这样缓冲区满了自动从头覆盖配合IDLE中断做不定长接收特别顺手发送用Normal发完就停。Data Width两边都选Byte因为串口是字节流。Memory地址增量使能Peripheral地址增量禁止。第三步生成代码后在main里启动接收uint8_t rx_buf[256]; // 启动DMA接收循环模式 HAL_UART_Receive_DMA(huart1, rx_buf, sizeof(rx_buf)); // 使能IDLE中断用于检测一帧结束 __HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE);第四步在中断服务函数里处理IDLE事件。HAL库默认的USART1_IRQHandler会调用HAL_UART_IRQHandler但它不处理IDLE需要你自己加void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(huart1); // 计算本次接收到的数据长度 uint16_t recv_len sizeof(rx_buf) - __HAL_DMA_GET_COUNTER(huart1.hdmarx); // 处理数据注意这里要快进快出 process_rx_data(rx_buf, recv_len); // 循环模式下不需要重新启动Normal模式需要重新调用HAL_UART_Receive_DMA } HAL_UART_IRQHandler(huart1); }这段代码里有个关键点__HAL_DMA_GET_COUNTER返回的是DMA还剩多少没搬用缓冲区总长减去它就是本次实际收到的字节数。这是IDLE DMA组合接收不定长数据的标准套路实测非常稳。2.3 串口DMA发送的两种触发方式发送比接收简单因为发送的时机是你自己控制的。常见两种方式一种是发一包启动一次适合数据量小、发送不频繁的场景。调用HAL_UART_Transmit_DMA传进去缓冲区和长度HAL库会自动配置DMA并启动发完进HAL_UART_TxCpltCallback回调。注意这个回调里不能做耗时操作最好只置个标志位。另一种是环形缓冲 持续发送适合数据量大、需要连续输出的场景比如日志打印。自己维护一个环形缓冲区上层往里写DMA从里往外读读指针追上写指针就停有新数据再启动。这种模式实现起来复杂一些但能避免频繁启动DMA带来的开销。注意DMA发送期间绝对不能修改正在发送的缓冲区内容否则发出去的数据会错乱。如果上层要复用这个缓冲区必须等发送完成回调触发后再改。2.4 实测中串口DMA最容易翻车的三个点第一个坑是缓冲区对齐和大小。有些系列比如带Cache的H7要求DMA缓冲区按Cache Line对齐否则会出现数据不一致。F1/F4没有Cache这个问题不明显但一旦上H7或者M7内核就必须处理Cache一致性问题要么把缓冲区放到非Cache区域要么在DMA传输前后做Cache清理和无效化。第二个坑是IDLE中断和DMA完成中断的竞争。如果一帧数据刚好填满缓冲区DMA完成中断和IDLE中断可能同时触发处理不当会重复处理数据或者丢数据。我的做法是在IDLE处理里先关DMA处理完再开或者用状态机标记当前帧是否已处理。第三个坑是波特率太高时DMA响应不过来。理论上DMA搬运速度远高于串口速率但如果总线上有更高优先级的DMA在跑比如LCD刷屏、SD卡读写串口DMA可能被延迟导致溢出错误ORE。这时候要么提高串口DMA优先级要么降低其他DMA的占用。3. ADC DMA多通道采集的正确打开方式3.1 单通道轮询的痛点和DMA的解法ADC采集是另一个DMA重度应用场景。假设你要采集8个通道的模拟量用轮询方式切换通道、启动转换、等EOC标志、读数据循环8次。每次转换加上等待时间8个通道轮一遍可能就要几十微秒而且CPU全程被占着。用DMA就不一样了。配置ADC为扫描模式Scan Mode开启连续转换Continuous Conversion使能DMA请求DMA配置成循环模式目的地址指向一个8元素的数组。启动一次ADC就自动按顺序转换8个通道每转换完一个DMA自动把结果搬到数组对应位置转完一轮自动从头再来。CPU完全不用管需要数据的时候直接读数组就行。这个方案在STM32F407上实测8通道12位精度采样率可以轻松跑到几百kHzCPU占用几乎为零。关键词里提到的stm32f407vet6 adc dma 中断 stm32cubemax配置说的就是这个典型场景。3.2 CubeMX配置ADC多通道DMA的细节在CubeMX里配置ADC多通道DMA有几个地方容易配错Scan Conversion Mode必须使能否则只会转换第一个通道。Continuous Conversion Mode根据需要选。如果要DMA循环搬运这个通常使能如果是一次性采集可以禁止。DMA Continuous Requests这个选项在F4系列里很关键。使能后ADC每转换完一个通道就发一次DMA请求DMA搬一个数据禁止的话DMA要等整个序列转换完才搬。多通道采集必须使能这个选项否则数据会错位。Number Of Conversions要设成通道数比如8个通道就设8。DMA模式选Circular这样数组会被循环填充。配置完生成代码启动采集uint16_t adc_buf[8]; HAL_ADC_Start_DMA(hadc1, (uint32_t *)adc_buf, 8); // 需要数据时直接读adc_buf[0]~adc_buf[7]这里有个细节HAL_ADC_Start_DMA的第三个参数是转换次数不是字节数。对于16位ADC结果DMA的Data Width要配成Half Word这样8个通道对应8个半字长度传8就对了。如果配成Byte长度就要传16而且数据会拆成高低字节读出来是错的。3.3 ADC DMA的数据对齐与精度问题ADC的转换结果有左对齐和右对齐两种。12位精度下右对齐时结果在低12位高4位是0左对齐时结果在高12位。DMA搬运的是整个16位寄存器所以读出来的值要根据对齐方式做移位处理。我一般用右对齐读出来直接就是0~4095省事。还有一个容易被忽略的点ADC的采样时间和DMA的搬运速度要匹配。如果采样时间设得太短采样电容还没充够电就转换结果会偏小。特别是高阻抗信号源采样时间要适当加长。这个和DMA没关系但经常和DMA问题混在一起排查时要分开看。4. DMA测速怎么知道你的DMA到底跑多快4.1 测速的意义和常见误区DMA测速这个词在搜索里出现频率很高说明很多人关心DMA的实际吞吐能力。测速的意义在于验证配置是否正确、评估系统瓶颈、对比不同传输模式的效率。但测速有个常见误区很多人只测了启动到完成的总时间然后除以数据量算出速率这个结果包含了中断响应、函数调用等开销不能反映DMA本身的搬运能力。要测DMA的真实带宽应该用硬件定时器在DMA启动前清零、完成中断里读值这样测出来的才是纯搬运时间。4.2 用定时器给DMA传输计时以STM32F407为例用TIM2做微秒级计时测一次内存到内存的DMA传输uint32_t dma_test_src[1024]; uint32_t dma_test_dst[1024]; volatile uint32_t dma_time_us 0; // 配置TIM2为1MHz计数即每微秒加一 void tim2_init(void) { __HAL_RCC_TIM2_CLK_ENABLE(); TIM2-PSC 84 - 1; // 84MHz / 84 1MHz TIM2-ARR 0xFFFFFFFF; TIM2-CR1 | TIM_CR1_CEN; } // 启动DMA前 TIM2-CNT 0; HAL_DMA_Start_IT(hdma_memtomem, (uint32_t)dma_test_src, (uint32_t)dma_test_dst, 1024); // DMA完成回调里 void DMA2_Stream0_IRQHandler(void) { dma_time_us TIM2-CNT; // 1024个32位字 4096字节 // 速率 4096 / (dma_time_us * 1e-6) 字节/秒 }实测F407在168MHz主频下内存到内存DMA传输4KB大约需要几微秒算下来带宽在几百MB/s量级。这个数字受总线仲裁、源目的地址是否在同一总线等因素影响不同配置下差异可能很大。4.3 影响DMA速率的几个硬件因素测速结果不理想时别急着怀疑代码先看看这几个硬件层面的因素总线矩阵和仲裁DMA和CPU、其他主设备共享总线如果CPU正在密集访问FlashDMA访问SRAM的速率会受影响。源和目的是否在同一总线比如从SRAM搬到SRAM和从外设搬到SRAM路径不同速率不同。数据宽度32位传输比8位传输效率高因为同样数据量下事务数少。突发传输BurstF4以上的DMA支持突发模式一次总线仲裁搬多个数据能显著提升效率。关键词里的stm32的tim dma burst应用说的就是这个。FIFO的使用合理配置FIFO阈值和突发长度能减少总线占用次数。我做过一组对比测试同样搬4KB数据8位宽度无突发大概要十几微秒32位宽度加4拍突发能压到几微秒差距非常明显。所以如果对DMA吞吐有要求一定要把数据宽度和突发模式配到位。5. Linux驱动开发中的DMA和裸机完全不同的思路5.1 Linux DMA引擎框架的分层从裸机转到Linux驱动DMA的使用方式会发生根本性变化。裸机是你直接操作DMA寄存器Linux下则要通过DMA引擎框架DMA Engine Framework来申请和使用DMA通道。这个框架把DMA控制器抽象成provider把使用DMA的设备抽象成consumer中间通过dma_request_chan、dmaengine_prep_slave_sg、dmaengine_submit、dma_async_issue_pending这一套API来交互。为什么要这么设计因为Linux要支持各种不同的DMA控制器如果每个驱动都直接操作寄存器代码没法复用也没法统一管理通道资源。框架层帮你处理了通道分配、描述符管理、中断处理等通用逻辑驱动工程师只需要关心我要搬什么数据、从哪到哪。5.2 串口驱动里DMA的接入点以Linux串口驱动为例DMA的接入点主要有这么几个probe阶段调用dma_request_chan申请TX和RX通道申请失败要能优雅降级到PIO模式。start_tx把待发送的数据映射成DMA描述符提交并启动。这里要用dma_map_single做地址映射确保DMA能访问到物理地址。RX路径通常用循环缓冲区加DMA配合定时器或者字符超时中断来判断帧结束。中断处理DMA完成中断里调用dmaengine_tx_status检查状态然后唤醒上层或者启动下一次传输。这里有个和裸机完全不同的概念Cache一致性。在带Cache的ARM处理器上CPU写的数据可能还在Cache里没落到内存DMA直接去内存搬就会搬到旧数据。所以DMA传输前要dma_sync_single_for_device传输后要dma_sync_single_for_cpu。这个在裸机上基本不用考虑但在Linux下是必考题嵌入式面试八股文里经常出现。5.3 设备树里怎么描述DMA通道Linux下DMA通道的分配是通过设备树Device Tree描述的。以串口为例设备树节点里会有这样的属性uart1: serial40011000 { compatible st,stm32-uart; reg 0x40011000 0x400; interrupts 37; dmas dma1 4 0x400, dma1 5 0x400; dma-names tx, rx; status okay; };dmas属性里第一个cell是DMA控制器 phandle第二个是通道号第三个是配置标志。dma-names给通道起了名字驱动里用dma_request_chan(dev, tx)就能拿到对应的通道。这套机制比裸机灵活得多但也要求你对设备树语法和DMA控制器的binding文档有足够了解。6. 那些年我在DMA上踩过的坑6.1 通道映射记错导致传输静默失败这个坑我踩过不止一次。有一次用STM32F103做串口发送凭记忆把USART1_TX配到了DMA1_Channel5结果数据死活发不出去也不报错就是没反应。查了半天代码没发现问题最后翻参考手册的DMA请求映射表发现USART1_TX是Channel4Channel5是USART1_RX。改过来立马就通了。教训就是DMA通道映射永远查手册别凭记忆。不同系列、不同外设的映射都不一样F1和F4的编号规则完全不同F4还多了流的概念更容易搞混。6.2 忘记清除传输完成标志导致中断反复触发DMA传输完成中断触发后必须清除对应的标志位否则中断会一直触发CPU被卡死在中断里。HAL库的HAL_DMA_IRQHandler会帮你清标志但如果你自己写中断服务函数就要手动清。这个坑在裸机开发里很常见表现是程序跑着跑着就卡住了用调试器一看一直在进中断。6.3 缓冲区被优化掉或未初始化编译器优化有时候会把看起来没被使用的DMA缓冲区优化掉或者放到错误的段里。DMA缓冲区一定要用volatile修饰或者用__attribute__((section(.dma_buffer)))放到指定段确保它真的在内存里而且地址是DMA能访问的。另外缓冲区一定要初始化否则第一次传输可能读到随机值。6.4 传输长度单位搞错DMA的传输长度单位是数据单元个数不是字节数。如果你配的是16位数据宽度长度传100实际搬的是200字节。这个在ADC多通道采集和SPI DMA里特别容易搞错。我一般会在代码里加注释标明单位避免后面自己都忘了。6.5 循环模式下数据覆盖循环模式Circular很方便但如果不配合IDLE中断或者半传输中断缓冲区满了会自动从头覆盖旧数据就丢了。用循环模式一定要有机制知道当前数据写到哪了否则读到的数据可能是新旧混合的。IDLE中断、半传输中断HT、传输完成中断TC这三个配合使用才能可靠地处理不定长数据。7. 不同平台的DMA差异与选型参考7.1 STM32、GD32、HC32F460的DMA对比国产MCU这几年用得越来越多GD32和HC32F460的DMA和STM32有相似之处但细节差异不小。平台DMA架构通道/流数量特色移植注意STM32F1通道式DMA1 7通道 DMA2 5通道经典资料多通道映射固定STM32F4流式2控制器 x 8流 x 8通道支持FIFO和突发流和通道双层选择GD32F303通道式兼容F1主频更高寄存器基本兼容时序略有差异HC32F460通道式多通道支持链式支持描述符链配置寄存器不同需查手册MSPM0G3507通道式较简单TI新平台用DriverLib配置GD32的DMA和STM32F1高度兼容很多代码可以直接移植但要注意GD32的主频更高如果DMA和CPU访问同一块SRAM仲裁行为可能不同。HC32F460支持描述符链式传输适合复杂的多段搬运场景但配置方式和小华自己的库绑定移植性差一些。7.2 SPI DMA的组合应用SPI是高速外设配合DMA能发挥最大威力。比如驱动LCD屏、读写Flash、和AD7606这类高速ADC通信都适合用SPI DMA。关键词里的安富莱ad7606和spi dma说的就是这个场景。AD7606是8通道16位并行/串行ADC用SPI接口读取时如果靠CPU逐字节读采样率上不去。用SPI DMA配置好接收缓冲区和长度启动一次就能把8个通道的数据全部搬回来。注意SPI的时钟极性和相位要和AD7606匹配DMA的接收长度要算对8通道 x 2字节 16字节。7.3 DMA在嵌入式AI和工装测试中的角色现在嵌入式AI很热边缘设备上跑推理数据搬运量很大。DMA在这里的作用是把传感器数据高效地搬到内存再把内存里的数据搬到加速器或者NPU。比如摄像头采集、麦克风阵列采样都是DMA的重度用户。工装测试场景里DMA用来做批量数据采集和比对。比如产线上要测试一块板子的ADC精度用DMA连续采集几千个点然后上位机分析。这时候DMA的稳定性和测速就很重要采集速度上不去产线节拍就达不到。8. 给不同阶段工程师的DMA学习建议8.1 新手先把一个场景吃透如果你刚开始学DMA别贪多。选一个场景比如串口DMA接收把它彻底搞明白通道怎么选、寄存器怎么配、中断怎么处理、数据怎么读。把这一个场景跑通、跑稳你对DMA的理解就超过一大半人了。然后再扩展到ADC、SPI会发现套路是相通的。推荐的学习路径是先看参考手册的DMA章节理解通道映射和传输模式然后找一个官方例程对着手册逐行看懂最后自己从零配一遍不看例程。这个过程走下来DMA就不再是黑盒了。8.2 进阶理解总线、Cache和性能当你已经能熟练配置DMA下一步要关注的是性能和一致性。理解总线矩阵、仲裁机制、Cache一致性这些决定了你的DMA能不能跑出理论带宽。特别是在Cortex-M7和Cortex-A系列上Cache问题不处理DMA数据就是错的而且错得很随机很难查。建议动手做一次DMA测速实验用定时器精确测量不同配置下的传输时间对比数据宽度、突发模式、FIFO配置的影响。这个实验做完你对DMA性能的理解会有一个质的飞跃。8.3 面试DMA相关的高频问题嵌入式面试里DMA是高频考点常见问题包括DMA和中断的区别、DMA传输的三种方向、如何用DMA接收不定长数据、Cache一致性问题怎么处理、Linux下DMA引擎框架的工作流程。回答的时候别只背概念结合你实际做过的项目讲比如我在串口接收里用IDLE中断加DMA循环模式处理不定长帧具体是这样做的……这样面试官会觉得你是真做过。我个人在实际项目中的体会是DMA这东西看十遍手册不如动手配一遍。它涉及的细节太多通道映射、增量模式、中断标志、缓冲区管理任何一个地方疏忽都会导致问题。但一旦你真正跑通了几个场景就会发现它的设计其实很优雅是嵌入式系统里性价比最高的性能优化手段之一。后面如果要做高速数据采集或者多外设并发DMA更是绕不开的基础设施早点把它啃下来后面会省很多事。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。