资讯动态

嵌入式驱动开发实战:串口DMA接收、SPI与TIM DMA配置全解析

发布时间:2026/10/26 1:11:18 来源:尧图企业网站定制
串口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持续转换并持续请求DMASPI里的连续请求指SPI在片选有效的连续通信期间可以不断触发DMATIM里则对应更新事件触发突发传输。所以不要看到关键词就直接套代码先确认自己是在哪个外设上下文里。这是嵌入式驱动开发里最典型的“复制别人代码但场景不匹配”问题。4. DMA测速与驱动调试用数据说话4.1 怎么测DMA速度才不会误导自己“dma测速”这个热词经常出现在社区提问里我的看法是DMA测速这个动作在嵌入式平台上意义有限但如果非测不可就不要用“memcpy对比”这种伪实验。DMA搬运和CPU搬运走的不是同一条路DMA要走系统总线跟CPU访问外设、执行指令共享带宽。正确的测法是拿GPIO管脚当标记线开始传输时拉高GPIODMA完成中断里拉低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_Channel4USART1_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 驱动”的新人我会建议他先把这两张图看明白再动手。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑