串口DMA接收在嵌入式驱动开发里,属于那种看着人畜无害、上手能折腾你一整天的功能。第6期我把DMA相关的实战经验集中写一下,包括串口DMA接收首帧丢失、SPI DMA配合外部ADC、TIM DMA Burst、DMA测速以及通道复用冲突。文章不适合纯零基础,但也不用太慌,每个环节我会把“为什么这么配”讲清楚,你可以先照着抄完,再回头理解也不迟。
先给个定心丸:DMA其实不是难点,难点在于外设请求源和DMA通道、传输模式之间的配合。做驱动这么多年,我发现只要把这个配合关系吃透,串口DMA、SPI DMA、TIM DMA本质上都是一回事。
1. 先把DMA的底层逻辑捋清楚,再谈驱动开发
1.1 没有DMA之前,CPU到底在忙什么
很多人上来就问“DMA是不是比我自己的memcpy还快”,这是个挺常见的误解。DMA设计之初并不是为了让你拷贝内存跑分,它是要解决“CPU等待”和“CPU被数据搬运拖死”的问题。嵌入式驱动开发里选DMA,真正的出发点是让CPU从重复、机械的搬运工作里解脱出来。
如果不用DMA,最朴素的做法就是轮询。拿SPI读取外置ADC举例,每读一个16位数据,CPU都要等SPI的RX不为空标志,期间它只能傻等。AD7606这类多通道同步采样ADC一旦以几十千赫兹的频率输出数据,轮询模式基本就把CPU绑死了。中断方式好一些,但每来一个字节进一次中断,高频场景下中断的切入切出开销也会反噬系统,数据量一大甚至可以引发中断风暴。
DMA的模式说白了就是:外设发出DMA请求,DMA控制器拿到总线仲裁后,把外设数据寄存器里的内容搬到内存,或者把内存里的内容搬到外设寄存器,全程不需要CPU动手。类比一下,相当于你雇了一个专职仓管,搬箱子这种事不用你亲自下场,每趟搬完他按铃告诉你一声就行。
1.2 DMA一次传输的完整生命周期
实操中可以把一次DMA传输拆成五步:第一步是CPU配置DMA寄存器,包括源地址、目标地址、数据宽度、传输个数、传输方向、传输模式、优先级。第二步是外设产生DMA请求,比如串口收到一个字节置起RXNE,同时向DMA发出请求;SPI的RX缓冲非空也会触发请求。第三步是DMA控制器仲裁和应答,多个请求同时来的时候按优先级排队。第四步是真正的数据传输,每搬运一个数据单元,DMA内部计数器就自动减一,源地址和目标地址按配置递增或保持不变。第五步是完成中断,计数器归零后DMA置传输完成标志,非循环模式需要重新初始化才能再使用,循环模式则会自动装回初值继续跑。
这里最关键的往往不是DMA寄存器本身,而是“外设请求”这一环。新手在排查DMA不工作时最容易忽略的就是外设侧没有开放DMA请求。以STM32的USART接收为例,不但要在DMA侧把通道配置好,还必须在USART_CR3寄存器的DMAR位写1,允许USART向DMA发出请求。少写了这一步,DMA配置得再完美也没用。
1.3 DMA并不是所有场景的最优解
我这些年做嵌入式驱动开发,见到不少把DMA用过头导致系统更复杂的案例。总结一下,有四类场景不建议硬上DMA。
第一,数据量很少的时候,比如几十字节的命令帧,中断处理和DMA配置的复杂度差不多,DMA反而多了一套初始化代码。第二,低延迟要求不高的时候,DMA搬运本身需要时间,而且完成之后还要通过中断通知CPU,这个链路比直接读寄存器要长。第三,多个外设争抢同一个DMA通道的场景,切换配置的成本很可能比中断处理还高。第四,某些外设的频率特别高,DMA通道的总线优先级不够高时,传输可能一直等不到仲裁,表现就是数据丢帧。
我自己有个朴素的判断标准:如果这个搬运动作是“为了等数据然后继续跑流程”的,就看数据量大不大,大到中断扛不住、轮询太浪费,就果断用DMA;如果只是一次两次的零散操作,直接读写寄存器或者中断处理反而更省心。
2. 串口DMA接收实战:从首帧丢失到空闲中断方案
2.1 HAL库下首帧丢失,问题出在时序而非DMA本身
热词里“stm32f103使用hal库串口dma接收首帧丢失”是很真实的痛点,我自己也踩过。F103内存少,很多人习惯直接调用HAL_UART_Receive_DMA,但这里有个启动时序的窗口:从CPU配置串口到DMA真正接管数据之间,如果上位机恰好在这个间隙发来第一帧数据,那么USART可能已经收到字节,但DMA还没准备好,导致第一个字节就丢在了数据寄存器里,之后的字节DMA又一切正常。表现就是稳定丢首帧。
实测避免这个问题的办法,是不要只依赖HAL库的封装,要把串口和DMA底层寄存器初始化顺序理清:优先配置DMA通道,使能DMA并让它处于待命状态,再使能USART的接收功能,最后才允许USART发出DMA请求。这样硬件上先保证DMA具备接数据的能力,首帧就能顺利进入缓冲区。
另外还有个细节:F103在开启DMA接收后,如果第一次传输完成中断标志没清就再次启动,会导致第二次接收失败。我每次重新开始接收前都会做一次“复位DMA通道并清标志”的操作,习惯代码是这样的:
__HAL_DMA_DISABLE(huart->hdmarx); __HAL_DMA_CLEAR_FLAG(huart->hdmarx, DMA_FLAG_TC | DMA_FLAG_TE | DMA_FLAG_HT | DMA_FLAG_FE); HAL_UART_Receive_DMA(&huart, rx_buf, RX_BUF_LEN);这个清标志动作别看简单,缺了它就会出现偶发性丢数据,而且是很难复现的那种。
2.2 不定长接收:IDLE空闲中断加DMA计数器才是最稳的组合
定长数据帧直接用DMA传输完成中断最简单,但现实工程项目里更多是带协议头的不定长数据。这时候最常见的方案就是串口空闲中断加DMA接收。
STM32系列串口有一个IDLE标志,当一帧数据发完、总线上出现空闲时,这个标志会自动置位。我通常开串口的IDLE中断,在中断里读DMA计数寄存器,算出当前这一帧实际收了多少字节:
void HAL_UART_IDLineCpltCallback(UART_HandleTypeDef *huart) { if (huart == &huart1) { DMA_HandleTypeDef *hdma = huart->hdmarx; uint16_t remain = __HAL_DMA_GET_COUNTER(hdma); receive_len = RX_BUF_LEN - remain; // 把这一帧数据交给上层处理,然后重新开始接收 } }这里面有个高频坑:不能在IDLE中断里直接调用HAL_UART_Receive_DMA重新开启接收。因为当前DMA还没有被真正停掉,NDTR计数器也没有恢复初值,直接重新开启会让新旧数据混在一起。我习惯的流程是:先关闭DMA接收并且禁用USART的DMA请求,拷贝并解析已经收到的数据,清掉DMA标志,再重新设置接收缓冲区,最后重新使能USART的DMA请求。
如果缓冲区和处理速度跟不上连续发送的数据量,可以在DMA的循环模式下配合半满和全满中断,把数据切成两段轮流处理。这样发送端可以连续发,接收端也不会丢。
2.3 ESP32串口DMA接收时,发送端要改发送方式吗
热词里“esp32 dma接收rx数据发送端需要改变发送方式吗”是个概念混淆。DMA接收是接收端的事,发送端不需要更换接口、协议或者波特率。双方按约定好的串口参数正常收发就行。
很多人之所以问这个问题,背后的现象是ESP32用DMA接收时收到不完整帧或乱码,于是怀疑是不是发送端需要改成“分批发送”。其实根源在接收端缓冲配置:如果缓冲区长度连最大一帧数据都放不下,或者空闲判定阈值跟实际数据流不匹配,那无论发送端怎么发都会出问题。解决办法是加大接收缓冲区,或者改成循环DMA接收并在半满、全满中继续处理数据,不要指望一个数据包正好落在缓冲区边缘。
我实测过ESP32的UART DMA接收,只要缓冲区大于最大帧长,波特率正确、时序匹配,发送端无论是连续流还是单帧发送都能正常工作。
3. SPI DMA与TIM DMA Burst:两种高频外设场景
3.1 SPI DMA搭配AD7606连续采样,连续请求才是关键
热词里“spi dma”和“安富莱ad7606”一起出现很有代表性。AD7606是16位、多通道同步采样的外部ADC,一旦数据输出率上到几十千赫兹,CPU用轮询或者中断基本没活路。SPI DMA的标准配置并不复杂:SPI主模式,接收方向配置DMA到数组,传输模式选循环。
但SPI加ADC的组合有几个别处见不到的坑。第一是数据宽度,AD7606是16位数据,SPI要配置成16位数据帧,DMA的数据宽度也得是16位,接收数组用uint16_t。如果SPI用8位帧、DMA按8位搬运,读回来的数据高低字节必然错乱。第二是“DMA连续请求”,如果你用ADC的连续转换模式,SPI会不断产生新数据,DMA就必须开着循环模式并允许连续请求,否则搬完一轮就停,第二次转换结果没人接。我在手动移植时经常发现漏了这个配置,数据采着采着就断了。第三是片选信号别乱拉,CS低电平期间连续读够样本再拉高,让ADC在CS高电平期间安安静静做转换,读到的数据才会稳定。
3.2 TIM DMA Burst:一次更新事件改写多个寄存器,波形不再撕裂
搜索词“stm32的tim dma burst应用”是我特别想展开讲的部分。做步进电机或者电源控制时,经常需要动态调整PWM的周期和占空比,传统做法是在中断里软件写ARR和CCR。但是两个寄存器写入之间有硬件执行时间差,ARR先更新、CCR后更新,示波器上就能看到一段不期望的中间波形,这就是所谓的波形撕裂。
TIM DMA Burst的解法是把需要同时更新的寄存器列表放到一块内存数组里,每次TIM更新事件发生时,DMA通过TIMx_DMAR自动把整张表连续写入多个TIM寄存器。配置时最关键的两个字段是DMABase和DMABurstLength,基地址决定从哪个寄存器开始算,突发长度决定一次更新写入几个寄存器。
以F1系列为例,核心配置思路是这样的:
uint32_t pwm_data[3]; // pwm_data[0] -> ARR, pwm_data[1] -> CCR1, pwm_data[2] -> CCR2 TIM_DMAConfig_TypeDef timDmaCfg; timDmaCfg.DMABase = TIM_DMABASE_ARR; // 从ARR开始 timDmaCfg.DMABurstLength = 3; // 连续写3个寄存器 HAL_TIM_DMAConfig_Handle(&htim, &timDmaCfg); HAL_TIM_PWM_Start_DMA(&htim, TIM_CHANNEL_2, (uint32_t *)pwm_data, 3);实际运行中有三个注意点:数组里的数据顺序必须跟DMABase指定的寄存器顺序一致;DMA一定要用循环模式,这样才能跟随每个PWM周期无间断更新;TIM更新中断记得开,但中断里尽量少做事,因为一次突发写入本身走的是DMA总线,如果高优先级中断长时间卡住,PWM波形照样会变得很难看。
3.3 DMA连续请求在不同外设下的含义不一样
搜索“dma continuous requests”时要注意,这个词在ADC、SPI、TIM上下文里的含义并不完全相同。ADC里的连续请求指ADC持续转换并持续请求DMA;SPI里的连续请求指SPI在片选有效的连续通信期间可以不断触发DMA;TIM里则对应更新事件触发突发传输。所以不要看到关键词就直接套代码,先确认自己是在哪个外设上下文里。这是嵌入式驱动开发里最典型的“复制别人代码但场景不匹配”问题。
4. DMA测速与驱动调试:用数据说话
4.1 怎么测DMA速度才不会误导自己
“dma测速”这个热词经常出现在社区提问里,我的看法是DMA测速这个动作在嵌入式平台上意义有限,但如果非测不可,就不要用“memcpy对比”这种伪实验。DMA搬运和CPU搬运走的不是同一条路,DMA要走系统总线,跟CPU访问外设、执行指令共享带宽。
正确的测法是拿GPIO管脚当标记线:开始传输时拉高GPIO,DMA完成中断里拉低GPIO,用示波器或逻辑分析仪量出高电平宽度,再结合传输字节数算出有效带宽。代码思路大概是:
gpio_set_level(GPIO_TEST, 1); HAL_UART_Transmit_DMA(&huart, big_buf, BIG_LEN); // 在DMA完成中断里执行 void HAL_UART_TxCpltCallback(UART_HandleTypeDef *huart) { gpio_set_level(GPIO_TEST, 0); }测速数据忽高忽低时,优先查缓存一致性和总线仲裁竞争。CPU同时在跑别的程序、其他DMA通道在抢总线,都会明显影响结果。不要拿这个数字跟另一块芯片手册里的理论值硬比,没有意义。
4.2 DMA测速失败代码的几类原因
热词“dma测速失败代码”背后的实际情况大多是配置环境问题。我整理了一张排查顺序表,遇到问题先照着查:
| 现象 | 主要原因 | 检查点 |
|---|---|---|
| DMA完全不启动 | 外设DMA请求未使能或DMA时钟没开 | 检查外设控制寄存器DMA使能位,检查RCC时钟树 |
| 传输完成但数据全零 | 传输方向反向,或源地址指向了错误寄存器 | 核对DMA方向字段,确认外设只有一个数据寄存器 |
| 传了但只搬了一部分数据 | 传输个数与数据宽度不匹配 | NDTR计数单位是数据单元,不是字节 |
| 第二次传输失败 | 完成中断标志未清除 | 重新启动前必须清TC标志 |
| 地址对齐异常 | 部分MCU要求地址按数据宽度对齐 | 检查数组指针和缓存的地址对齐 |
其中NDTR按“数据单元”计数这个点最容易踩。32位传输模式下,填10代表10个32位数据,也就是40字节。很多人当成10字节用,结果只搬了四分之一的数据。
4.3 中断优先级才是DMA驱动的隐形杀手
DMA配置对了、传输也正常,但偶发丢数据的情况大家多少都遇到过。我排查时发现,相当一部分原因出在中断优先级上:串口DMA接收的“空闲中断”或“传输完成中断”优先级设得比某个高频定时器中断低,导致数据到了之后上层来不及处理,缓冲区被下一次DMA写入覆盖。
一个比较稳的做法是把DMA传输完成中断或者IDLE中断的抢占优先级设得足够高,但在中断里只做“更新头指针、标记事件”这类微小操作,真正的数据解析放到主循环或者任务里。千万不要为了省事在中断里直接做字符串处理,那样中断阻塞时间一长,其他中断和DMA都会受到影响。
4.4 STM32F103同通道复用,这是不少工程师绕不开的坑
搜索“stm32f103c8t6 dma 同道”的热度不低。F103的DMA1只有7个通道,DMA2也只有5个,而且外设请求被物理固定到指定通道。比如USART1_TX只能走DMA1_Channel4,USART1_RX只能走DMA1_Channel5。两个外设一旦映射到同一个DMA通道,就无法同时独立工作。
冲突的典型表现是先初始化的外设正常,后初始化的不工作,或者轮换启用时第一次能用、第二次失效。解决办法是查芯片的DMA请求映射表,看看能不能挪到DMA2上。如果确实只有一个可用通道,就只能分时复用:先关闭一个外设的DMA请求,再配置另一个,切换过程务必清NDTR和标志位。最彻底的办法是调整驱动架构,把其中一路改成中断接收,或者把两路数据合并成同一个搬运策略。
我在CubeMX里做驱动开发时,习惯先把外设DMA请求映射表截图打印出来贴在调试台前,凡是涉及DMA通道,先查映射再动手,能省不少弯路。
4.5 驱动调试时的几个保命小习惯
最后说几个实际调DMA驱动时比较管用的习惯。
第一,用一个状态变量保存DMA的当前状态,开启、完成、错误都置位,调试时直接在主循环里打印,不要每次进调试器翻寄存器。第二,给DMA加超时保护,某些硬件异常下DMA不会产生完成中断,驱动会一直等下去。我在外设中断或者定时器中断里设计了看门狗式判断,比如2秒没有完成传输就复位DMA通道。第三,配置顺序统一为“关DMA、复位通道、清标志、配置参数、再使能”,不要图省事。第四,数据宽度不是8位的时候注意内存对齐,数组地址按数据宽度对齐,否则DMA读取可能出异常。
我个人做嵌入式驱动开发这些年,最深的体会是DMA本身不复杂,复杂的是外设请求、总线仲裁和中断优先级这三者跟DMA通道的配合。第一次上手就老老实实把芯片手册里的DMA请求映射表对照系统框图过一遍,比刷十篇教程都管用。下一次再遇到搜“dma 驱动”的新人,我会建议他先把这两张图看明白再动手。