嵌入式DMA驱动开发实战:从串口到高速ADC的配置经验与避坑指南

发布时间:2026/10/28 9:18:00
嵌入式DMA驱动开发实战:从串口到高速ADC的配置经验与避坑指南

做嵌入式驱动开发这么多年,DMA是我又爱又恨的外设。爱它是因为数据搬运这种重复劳动交给它之后,CPU能腾出来做真正要紧的控制逻辑;恨它是因为一旦配置有问题,坑往往藏在对齐规则、中断时序和缓冲区边界这些细节里,排查起来相当耗人。印象最深的一次,我调试串口DMA接收,波特率在921600下丢帧率接近4%,花了整整两天才发现是DMA循环模式的缓冲区长度没有按数据宽度对齐。这类问题不是跑一次两次就能暴露的,而是要压到一定速率、持续跑一段数据流才现原形。这篇是“嵌入式驱动开发经验”系列的第6期,专门聊DMA,从串口、SPI、高速ADC采集到TIM burst,把这几年的配置经验、实测结果和踩过的坑一次性整理出来,适合正在调DMA的驱动工程师和嵌入式软件开发者参考。

1. 为什么DMA让嵌入式开发者又爱又恨

1.1 DMA到底解决了什么问题

DMA的全称是Direct Memory Access,直译是直接存储器访问。它解决的核心问题一句话就能说清:数据搬运别再打扰CPU。具体到串口场景,假设波特率115200,一帧10位(1起始位+8数据位+1停止位),每秒到达的字节数大约是11520。如果不用DMA,每来一个字节就触发一次接收中断,CPU进中断搬走一个字节,再退出中断继续干活。每个字节平均80多微秒,MCU主频不高的场景里,CPU很轻松就被打断到“几乎没法做其他事”。如果把波特率提到921600,每个字节间隔只有10微秒左右,这种情况下CPU基本就被接收中断绑架了。

有了DMA之后,数据到达外设的数据寄存器时,DMA控制器直接把内容搬到预先指定的内存缓冲区,整个过程CPU不参与。等一批数据传输完毕,DMA触发一次传输完成中断,CPU只需要处理整块数据。对比一下:同样100字节的数据,不挂DMA会发生100次中断,挂了DMA通常只有1到2次中断。CPU占用率的差异是数量级的。

但要明确边界,DMA只是搬运工,不是解析器。它不会理解帧格式,也不会区分哪个字节是地址还是数据。数据到达缓冲区之后,解析、校验、协议处理仍然要靠CPU。所以DMA的本质是降低中断频率和CPU负载,而不是消灭中断和负载。理解了这个定位,后面做方案选型时就不容易跑偏。

1.2 驱动开发中DMA的常见误区

第一类误区是“什么地方都想挂DMA”。DMA本身有配置成本和维护成本,比如通道初始化、缓冲区管理、DMA中断处理。如果一个事件本来就低频,比如按键读取、LED翻转、单次寄存器配置,挂DMA增加的复杂度远大于收益。我判断是否该用DMA的标准很简单:这一路数据是否高频、定量、搬运模式固定。串口、SPI、ADC连续采样、存储块读写都属于典型场景;协议帧偶发、单次传感器读取则完全没必要上DMA。

第二类误区是“DMA配置完就一劳永逸”。DMA只是搬数据,缓冲区同步和异常恢复才是工作量的大头。以循环模式接收为例,CPU需要记住当前已经消费到哪个位置,DMA则持续往缓冲区写新数据,两边一旦不同步,轻则重复处理,重则缓冲区覆盖。此外,DMA通道故障、FIFO溢出、错误中断后的恢复逻辑也要提前写好,否则真出问题的时候整个链路会以很隐蔽的方式死掉。

第三类误区是“开了DMA就不用管内存一致性”。在带Cache的MCU上,这个坑尤其隐蔽。CPU读取某段地址时可能直接从Cache命中旧值,而DMA已经把新数据写进了物理内存,两边看到的内容不一致。解决办法要么把这段内存设置成Non-cacheable,要么在每次DMA传输前后来一次cache invalidate或clean操作。用F1这种没有Cache的Cortex-M3内核时不需要考虑,但换成Cortex-M7内核的板子就会立刻遇到。

2. DMA驱动的核心配置拆解

2.1 DMA通道、请求映射与优先级怎么选

配置DMA的第一步永远不是写代码,而是翻参考手册里的DMA请求映射表。以STM32F103C8T6这颗非常常见的MCU为例,DMA1有7个通道,DMA2有5个通道,每个通道能响应哪几个外设请求,芯片手册里写得很明确。比如USART1_TX在F1系列上对应DMA1的Channel4,USART1_RX对应DMA1的Channel5;SPI1_RX对应DMA1的Channel2,SPI1_TX对应DMA1的Channel3。但同样的外设到了F4系列,就变成DMA2_Stream0到DMA2_Stream7这种流结构,映射关系完全不一样。

MCU型号DMA外设USART1_TX映射USART1_RX映射SPI1_RX映射
STM32F103C8T6DMA1Channel4Channel5Channel2
STM32F407DMA2Stream7Stream2Stream2
ESP32GDMA挂在UART/SPI外设链路同左同左

这个表格只能作为快速参考,真正动手前还是建议翻开具体型号参考手册的DMA章节核对一遍,因为芯片型号后缀不同、封装不同,都可能影响外设复用映射。

选通道的同时还要统筹考虑优先级。DMA控制器一次只能服务一个请求,当多个通道同时申请搬运时,高优先级通道会先被处理。如果一个低速外设抢占了高优先级通道,高速外设的数据就可能因为等待过久被覆盖。所以我会先把所有DMA请求的实时性排一遍:对时间敏感的控制类数据设最高优先级,普通后台数据设低优先级。同一DMA控制器下总共有几路请求,也最好提前做一张优先级表格,免得后来加需求时打乱配置。

2.2 数据传输宽度、突发模式与循环模式

数据宽度配置是DMA最常见的错误源头之一。DMA搬运时涉及两个端点:外设端和内存端。以串口为例,数据寄存器是8位,两端数据宽度都必须配成Byte,否则DMA会把相邻字节组装成半字或字,导致数据顺序错乱。SPI接16位ADC芯片时,外设端数据宽度是HalfWord,内存缓冲区要用uint16_t数组对应。如果内存端配成Byte,高字节和低字节会被拆成两次传输,逻辑也会乱。

F4及以上的DMA控制器带有FIFO,打开之后可以配合突发模式工作。突发模式就是一次总线请求搬运多拍数据,典型配置是4拍或8拍。这样能减少总线仲裁次数,在大量连续数据传输时提升吞吐。但使用突发模式有两个硬性前提:内存起始地址需要按传输宽度对齐,通常是16字节对齐;每次突发传输的数据量必须是突发拍数的整数倍。我在工程中一般先关闭突发模式把功能跑通,再打开突发模式优化性能,这样定位问题边界时不容易混淆。

循环模式是连续数据传输的核心配置。开启之后,DMA计数从初始值递减到零,会自动重载回初始值继续搬运。对串口不定长接收、ADC连续采样这类场景,循环模式让DMA始终处于工作状态,CPU不需要反复重新启动传输。这里有个容易踩的坑:循环模式下缓冲区长度必须能被数据宽度整除。16位传输的缓冲区长度必须是偶数,否则最后一次DMA搬运会越界读,触发错误中断。

2.3 中断、回调与错误处理

DMA中断通常包括三件套:半传输中断、传输完成中断、传输错误中断。半传输中断用得好,能白赚一个双缓冲效果。以串口DMA接收为例,缓冲区前半段写满时触发半传输中断,CPU立刻处理前半段数据;与此同时DMA继续写后半段,处理与搬运天然重叠。处理完前半段之后CPU再等后半段写满,传输完成中断来了继续处理,等于把一个大缓冲区切成两个小缓冲交替复用。

传输完成中断在非循环模式下表示一次DMA传输已经结束,CPU可以安全读取缓冲区;在循环模式下表示缓冲区写了一整圈,下一次会回到头部。它不代表数据已经消费完毕,只是DMA搬运到了某个边界。数据消费这件事要由上层逻辑协调好,否则缓冲区后半段被DMA继续写时,CPU还在读旧数据就可能读到半新半旧的内容。

错误中断最容易被忽视。DMA遇到总线错误、FIFO溢出或配置冲突时会触发错误中断,同时传输停止。正确驱动里必须写错误恢复:关通道、清标志、重新初始化缓冲区、再次开启DMA。我曾经在一个项目里看到有人直接屏蔽错误中断,因为觉得平时用不到,结果是硬件时序一旦抖动,整条链路悄然死掉,中断都不报,排查难度直线上升。DMA错误一定要开着,哪怕只在错误中断里记录一个计数器,都比闷声不响强。

3. 实战案例一:串口DMA收发驱动的最佳实践

3.1 串口DMA接收的经典框架:空闲中断加DMA

串口接收最常见需求是不定长数据帧。早年我直接把接收挂在DMA完成中断上,结果发现每次都得等缓冲区满了才处理,10字节的短帧和1024字节的长帧混在一起时,短帧处理严重滞后,甚至会被下一轮覆盖。

后来固定用“串口空闲中断+DMA循环接收”的组合。DMA一直挂在接收通道,数据自动写入缓冲区;当一帧完整数据发送完成,总线上出现空闲状态时,串口外设会产生空闲中断,此时CPU通过DMA的当前计数值反推出这一帧实际接收了多长,再交给上层处理。这个方案在RS485、MODBUS这类一主一从的帧协议里很合适。

HAL库的代码框架一般长这样:

void uart_rx_dma_start(UART_HandleTypeDef *huart, uint8_t *buf, uint16_t size) { HAL_UART_Receive_DMA(huart, buf, size); __HAL_UART_ENABLE_IT(huart, UART_IT_IDLE); } void USARTx_IRQHandler(void) { uint16_t current_pos = 0; if (__HAL_UART_GET_FLAG(&huart, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(&huart); current_pos = RX_BUF_SIZE - __HAL_DMA_GET_COUNTER(&hdma_usartx_rx); if (last_pos < current_pos) { frame_len = current_pos - last_pos; rx_parse(&rx_buf[last_pos], frame_len); } else { // 处理跨越缓冲区边界的情况 frame_len = RX_BUF_SIZE - last_pos; rx_parse(&rx_buf[last_pos], frame_len); rx_parse(&rx_buf[0], current_pos); } last_pos = current_pos; } }

这里最重要的细节是处理“读过缓冲区边界”的情况。DMA循环写满会回到头部,如果一帧数据的尾巴跨过了边界,CPU要分两段取数据,否则数据顺序就错了。这个细节是串口DMA接收的经典易错点,我在多个项目里都为此加过专项测试。刚调通这套逻辑时,我只测了短数据包,没测跨边界的情况,后来压测时偶发乱帧,排查半天才定位到丢了这个分支。

3.2 ESP32串口DMA接收与发送端细节

有朋友问过:ESP32的串口DMA接收,发送端需要改变发送方式吗?我实测下来的结论是:不需要为接收端特意改变发送方式,关键在接收端自己如何处理DMA缓冲区。ESP32的UART底层自带DMA链路,DMA接收会把数据写入描述符指向的缓冲区,应用层逻辑上看起来仍是标准串口读取。

但有一个实际影响不可忽视:发送端持续高速发数据时,接收缓冲区一旦填满,新数据会覆盖旧数据。DMA只是搬运工,不会自动给数据让路。因此接收端要勤快地把DMA缓冲区里的数据搬到应用层的环形队列,或者把DMA缓冲区开得足够大。我的习惯是把接收DMA的描述符和缓冲区按4字节对齐,可有效减少总线上的额外等待。

ESP32还有一点和STM32不同:DMA描述符链和中断事件都集成在驱动框架里,HAL层封装度比较高。如果直接操作底层寄存器去改DMA请求时序,就要非常小心对齐和描述符的初始化顺序。我见过一位同事在ESP32上为了降低延迟去改DMA描述符链表,结果时不时丢一个字节,最终发现是描述符的回指字段没有初始化。这类问题在普通应用开发中不太会遇到,但一旦做底层优化就很容易踩到。

3.3 串口DMA测速:怎么测、测什么、坑在哪

串口DMA测速要测的不是DMA控制器的极限带宽,而是整条收发链路在目标场景下能不能稳定跑满指定波特率。我常用的方法是:发送端用固定长度的数据包循环发送,接收端在接收回调里用定时器统计单位时间收到的有效字节数,再计算出吞吐。测速基准要满足时间长、数据量大、端点清晰,单帧短促测试没有参考价值。

这里有几个容易踩的坑。第一,测速路径里不能加printf打印,printf本身极消耗CPU,打印一段log的时间足够DMA横跨好几个缓冲区了,测出来的速度完全失真。第二,上位机串口工具里如果勾选了RTS/DTR等流控相关的选项,可能在连接时让板子复位,导致测试中断,看起来像DMA有错误。第三,测速数据本身要带上帧序号或CRC,用来判断丢帧和错帧,否则只凭字节数统计很难定位数据对不上的问题。

“DMA测速下载”这个场景在串口bootloader里很常见:通过DMA接收,把固件数据高速写入Flash,相比逐字节收发的方案,下载速度能提升数倍到十几倍。做这类实现时,测速失败大多不是DMA配置的问题,而是Flash擦写时间和DMA接收速度没协调好,导致缓冲区被覆写。正确处理方式是采用双缓冲或乒乓缓冲:DMA在写一块缓冲区时,CPU在写另一块缓冲区进Flash。

4. 实战案例二:SPI DMA与高速ADC采集(以AD7606为例)

4.1 AD7606与SPI DMA的搭配思路

AD7606是ADI的8通道16位同步采样ADC,常用于电力监控、多通道振动采集。它自身带宽高,如果不用DMA,单靠CPU中断读SPI,采样率一上去马上撑不住。最典型的配置是AD7606输出端接STM32的SPI1,通过SPI DMA把八通道数据一次性搬回内存。

为什么强调同步采样?因为AD7606在转换期间,八个通道是同时采样保持的,也就是说,一帧数据里八个通道对应同一个时间点的不同物理量。读取时必须一次性把8通道16位数据读完,不能分多次触发。如果分多次读,通道之间就不再是同一时间点的采样结果,后续做相位分析时会出大问题。

AD7606的外围时序有三个关键信号:CONVST(启动转换)、BUSY(转换指示)、CS(片选)。推荐流程是:定时器周期性触发CONVST,进入转换;BUSY拉高,转换期间不做任何读取;BUSY下降沿表示数据已经锁存,此时启动SPI DMA读取,连续读16字节。这个时序配合如果做得好,CPU可以完全不管搬运过程,只在DMA完成中断后取数据。我早期在项目里偷懒,没有等BUSY下降沿就直接启动DMA,结果读回来的数据经常是上一帧的旧值,属于非常典型的时序错误。

4.2 DMA连续请求模式与缓冲区设计

DMA连续请求模式在STM32的SPI DMA配置里是一个可选项。打开之后,只要SPI的TX或RX FIFO处于非满状态,就会持续产生DMA请求,DMA也会不停搬运。对于AD7606这种外设,我不建议把连续请求设成无条件开启,因为SPI在没有正确时序时会持续产生读请求,读取到无意义数据,甚至在转换期间干扰转换结果。

更合理的方式是“事件驱动”:定时器触发CONVST,BUSY下降沿到来后,启动一次定长DMA读取。这样DMA请求只在正确的时间发生,既降低总线占用,又保证每次读取都是有效数据。完成中断里只把缓冲区写索引推进,让主循环集中处理最新一帧。

缓冲区设计上,我习惯用二维数组,形如uint16_t adc_buf[ADC_FRAME_NUM][8],每行存储一帧八通道数据。DMA读到连续内存,一帧数据刚好占用一行。配合半满中断和完成中断,可以实现双缓冲,DMA正在写其中一半时,CPU处理另外一半。在采样率不高时,一维数组加一个写指针也能跑,但在1MSPS级别的采样率下,双缓冲几乎是必须的。

顺带提一下安富莱AD7606的参考例程。安富莱的例程把定时器触发、BUSY中断、SPI DMA三者之间的先后顺序调得很清晰,适合作为起步的参照工程。看它代码时,要重点理解为什么BUSY脚要接外部中断而不是在主循环里轮询,因为轮询BUSY会占用大量CPU时间,违背了使用DMA的初衷。

5. 进阶玩法:TIM+DMA burst、多通道并发

5.1 TIM DMA burst到底用在哪

定时器触发DMA这个功能不少工程师用过,比如定时器更新事件触发DMA,把一段内存搬到DAC输出,从而生成波形。再进一步,TIM配合DMA还支持burst传输。burst的意思是“一次DMA请求,连续访问多个寄存器”,典型应用是三相电机控制中同时更新三个CCR比较寄存器。

举个例子,我用TIM1产生三路互补PWM驱动三相逆变器。通常做法是在定时器更新中断里分别写CCR1、CCR2、CCR3,但三个寄存器赋值之间存在时间间隔,高精度场合下占空比更新不同步会导致电流谐波变大。用DMA burst后,一次DMA请求就能依次把内存中的三个占空比数据写入CCR1、CCR2、CCR3,再配合预装载机制,三个通道在同一时刻更新,控制效果明显变好。

配置DMA burst时,很多芯片在定时器侧用DMABase和DMABurstLength两个字段描述。DMABase指定起始寄存器地址,比如CCR1;DMABurstLength指定连续访问的寄存器数量,比如设为3,就代表一次burst访问CCR1、CCR2、CCR3。内存侧的DMA传输长度也要和BurstLength严格一致,多一个或少一个都会读到相邻寄存器,把别的PWM配置写花。

使用burst的注意事项:寄存器必须连续地址。定时器里CCR1、CCR2、CCR3通常连续,但如果想跳着写寄存器,burst就没法直接完成。另外,burst触发源要选对,一般用更新事件或比较事件触发DMA请求,保证寄存器更新发生在PWM周期的确定时刻。

5.2 多通道DMA并发与总线仲裁

多路DMA同时运行的场景越来越多。我调试过一个系统:串口1做RS485通信,SPI1以2MHz速率采AD信号,定时器又用DMA burst更新PWM,三个任务都抢DMA控制器。初期没做优先级规划,结果PWM更新偶发延迟,实测电流波形出现毛刺,检查后才发现SPI DMA的优先级高于TIM DMA。

DMA控制器的仲裁逻辑基本只有两级:通道优先级和请求优先级。高优先级请求优先执行,同优先级则按通道编号或FIFO轮询顺序决定。所以驱动里可做的不多,核心是把各DMA请求的实时性要求排序:

  • 高实时性:PWM更新、电机电流采样、关键传感器读取,需要最低延迟且不能丢。
  • 中实时性:音频数据流、通信数据收发,希望尽量不丢,但可以容忍一定抖动。
  • 低实时性:后台日志、配置存储,偶尔延迟不影响功能。

我在软件里加了一个统计字段,记录每个DMA通道发生过多少次忙等待或延迟传输,压力测试几小时后查看统计结果,就知道哪些请求在实际运行中受到影响,再针对性调整优先级或改走另一个DMA控制器。这种量化手段比凭感觉调优先级靠谱得多。

多DMA并发还有一个隐性坑:不同外设可能共用同一条RAM总线,大量DMA搬运和CPU同时访问内存时,总线冲突会导致DMA延迟。这类问题在逻辑上不容易看穿,但通过长时间压测可以暴露。最好在项目早期就安排数据流压力测试,别到集成阶段才发现带宽不足。

6. 常见问题与排障速查表

6.1 DMA测速失败代码与典型案例

我把实际项目中遇到的高频DMA问题整理成一张速查表,方便对照排查。

现象常见原因检查点
读到的数据全零外设请求没产生,DMA没有实际搬运检查外设是否开启对应DMA请求,初始化顺序是否合理
数据错位、高低字节混乱数据宽度配置错误核对外设寄存器位宽,内存缓冲区类型与DMA宽度一致
传输完成中断迟迟不来传输长度和实际数据量不匹配检查DMA传输长度是否与外设产生数据的节奏一致
缓冲区被覆盖但无报错循环模式边界处理错误检查数据消费速度,必要时加大缓冲区或使用双缓冲
突发模式开启后不稳定内存缓冲区未对齐用对齐属性申请缓冲区,先关突发模式验证再逐步开启
带Cache的MCU数据不一致MPU未配置为Non-cacheable配置对应内存区域属性,使用cache clean/invalidate操作
调用HAL函数返回HAL_BUSY上一次DMA传输还没结束启动前判断DMA状态,必要时先停止并清标志位

“DMA测速失败代码”不一定来自DMA配置本身。比如我在一个项目里遇到HAL_BUSY,追查半天发现是DMA完成中断回调里做的工作太多,导致下一次DMA传输请求到来时前一次还没收尾。解决办法是让回调更轻量,把耗时操作放到任务队列。

还有一类失败是测试手法导致的:测速工具本身设置了流控,导致数据停止。对固件侧排查时,我习惯先单独置一个GPIO在DMA完成中断里翻转,用示波器或逻辑分析仪看翻转频率,再用数据内容反推吞吐。这样能迅速把问题分为“DMA没搬”和“数据搬了但应用层没消费”两大类别,排查路径清晰很多。

6.2 DMA驱动安装与调试工具

“DMA驱动安装”在嵌入式语境里,跟Windows装驱动是两码事。驱动开发者关心的其实是DMA驱动的初始化流程:使能DMA控制器时钟、复位DMA外设、配置通道/数据流、绑定外设请求、注册中断回调、分配硬件DMA缓冲区、使能全局DMA中断。一套规范的DMA驱动通常封装成init、start、stop、register_callback四个接口,上层应用不直接操作寄存器,后续换MCU平台时也容易复用。

我把DMA驱动模块单独抽象成一层之后,在多个项目里直接复用,效果不错。关键点是接口设计要留足自由度,比如start时要能指定内存地址和长度,因为不同外设的缓冲区位置和长度经常不同。

调试DMA问题,我的工具优先级是:逻辑分析仪、示波器、串口log、调试器watch窗口、寄存器dump。逻辑分析仪排在第一位,它能看到串口或SPI时序是否正常,CS片选信号有没有提前拉高,DMA读取期间有没有被其它外设干扰。示波器用来测量模拟信号和PWM波形抖动,判断DMA优先级配置是否合理。

最后分享一个笨但有效的小技巧:在系统启动时把DMA所有参数打印一次,包括外设地址、内存地址、传输长度、数据宽度、优先级、循环模式开关。跑出错时把这行日志对照参考手册逐项核对,往往比自己盯着寄存器猜快得多。DMA大部分“玄学”问题的根源无非是映射表查错、宽度不匹配、对齐不对三条,打印日志能快速缩小范围。

我在实际项目中养成的一个习惯,是在决定要不要用DMA之前,先把数据从产生到消费的完整路径画一遍,想清楚谁在生产、谁在消费、缓冲区多大、谁负责边界。DMA救不了设计混乱的系统,但能在设计清晰的前提下把系统性能提升一大截。调试DMA问题也要尽量靠数据说话,波形、计数器、寄存器状态比“我觉得它没运行”可靠得多。系列接下来会继续写串口、SPI、FOC电机控制相关的内容,如果你也有DMA的案例,欢迎在评论区交流,一起把这片区域的坑填平。