当前位置: 首页 > news >正文

STM32L4 UART DMA碰撞问题详解:原理、配置与排查实战

做嵌入式这些年,UART DMA这个组合我调试过很多次,每次觉得稳了,总会在新的芯片型号上翻车。最近在STM32L4上做电表通讯模块,遇到了一个非常典型的UART DMA collision问题:DMA接收看起来正常,但跑一段时间后数据流里会莫名其妙跳出一两个错字节,甚至偶发整个缓冲区的数据位移。查遍寄存器才发现,这不是单纯的数据错误,而是DMA请求、UART状态位和缓冲区读写三方撞在一起。这篇文章就围绕这个collision问题,把STM32L4上UART DMA的原理、配置、排查方法完整拆一遍。说清楚了,你至少能在选型、配置、调试三个阶段各少踩一个坑。适合正用STM32L4 + DMA做串口收发的开发者,也适合从F1/F4迁到L4后发现串口行为有差异的朋友。

1. 问题背景:UART DMA为什么会在STM32L4上“撞车”

1.1 一个典型的“看起来正常但偶尔丢数据”的场景

先讲我实际遇到的场景。设备主控是STM32L431,UART2负责接收从机主动上报的数据,波特率115200,单帧长度不定,最长256字节。最初我用的是最朴素的逻辑:CubeMX里使能UART2的DMA接收,DMA模式选Circular,然后在主循环里直接读接收缓冲区。跑短时测试一切正常,可是放在老化测试台上跑半个小时后,数据开始出现一帧错位,有时是帧头找不到,有时是CRC校验失败。用示波器抓UART RX引脚波形,电平完全正常,说明问题是MCU内部处理没跟上,而不是物理链路有问题。

后来我把问题一步步缩小,发现根子在于DMA接收和CPU读缓冲区之间发生了竞争,而且DMA中断与UART空闲事件也存在处理顺序冲突。这个现象在F103时代也偶有发生,但STM32L4给我印象更深,因为L4的DMA控制器结构变了,还多了DMAMUX,很多从F1迁移过来的老代码并没有正确适配。这不是芯片质量的问题,而是对STM32L4的DMA机制理解不够导致的。

1.2 UART DMA工作的基本流程

先把流程拆开。UART每收到一个字节,硬件会先把数据从接收移位寄存器放到RDR(接收数据寄存器),同时置位RXNE标志。如果此时对应的DMA通道已经使能,RXNE会作为DMA请求信号传递给DMA控制器。DMA控制器收到请求后,从外设数据地址,也就是USART_RDR寄存器,读取一个字节,再写入你指定的内存地址。每一次搬运,DMA的NDTR计数器减1,内存地址是否递增由MINC位决定。

这里有一个容易被忽视的细节:DMA读取RDR的过程本身就会清除RXNE标志,不需要软件再去手动清零。很多人不理解为什么DMA模式不需要读RDR,就是这个原因。如果代码里同时又用中断方式去读RDR,就可能在DMA之前把数据抢走,造成两个外设都认为“自己拿到了数据”,实际只有一个能拿到,这本身就是一种collision。

1.3 三种容易混淆的collision

我在调试中把“collision”拆成了三种完全不同的情况:

  • 外设事件冲突:UART同时产生RXNE、ORE、IDLE等事件,如果处理顺序不对,DMA请求被ORE顶掉,导致接收卡死。
  • 总线访问冲突:DMA与CPU同时访问同一存储区域,AHB仲裁会优先DMA,但CPU会被插入等待周期。多个DMA通道同时工作时,UART的请求可能排队变慢。
  • 数据竞争:CPU在某个时刻读缓冲区,而DMA恰好写入相邻字节,尤其是主循环查询和DMA传输完成中断同时发生时,读到的帧是半新半旧的混血数据。

这三种情况往往同时出现,所以排查时一定要分开对待,否则很容易把原因错怪到UART配置或外部干扰上。

2. STM32L4的DMA控制器特性与配置选型

2.1 先看懂DMA1/DMA2和DMAMUX

STM32L4和F1/F4最明显的差别是DMA请求不是“写死”在通道上的。L4内部有一个DMAMUX,它允许你把外设的DMA请求映射到任意一个DMA通道上。这就带来一个理解上的跳跃:在CubeMX里配置DMA,不仅要选通道,还要明确选择Request,也就是请求源。

比如UART2_RX可以映射到DMA1的Channel5,也可以映射到DMA2的某个通道,但你必须告诉DMA“我的请求来自UART2_RX”。很多从F1迁移到L4的代码,以为只要使能DMA就完事,结果数据完全乱掉,多半就是DMAMUX的请求源没有配对。CubeMX里生成代码时,会在MX_DMA_Init之后配置对应的hdma_uart2_rx.Init.Request = DMA_REQUEST_UART2_RX,这个字段不是可有可无的。

另外,STM32L4有DMA1和DMA2两个控制器,合理的分配原则是不要让所有高速外设挤在同一个DMA控制器上。比如UART和SPI同时在跑,可以把UART放在DMA1,SPI放在DMA2,减少内部仲裁压力。

2.2 UART请求与数据寄存器访问到底发生了什么

从时序角度说,UART接收移位寄存器每收满8位,硬件会把数据放到RDR,同时RXNE置位。DMA响应该请求后,读取RDR,然后写入内存地址。表面看是一套流水线,但实际有一定时间窗口。如果RDR里的数据没有被及时取走,而下一个字节已经完整收到,UART就会置位ORE,此时RXNE请求被“压住”,DMA后续搬运就可能中断。

所以在DMA接收模式下,软件最容易犯的错误就是同时在UART中断处理函数里手动读RDR。比如有人为了防丢数据,在RXNE中断里顺手读了一下RDR,结果把数据从DMA嘴里抢走了。这是非常典型的“CPU与DMA碰撞”。

另外要留意STM32L4系列在默认情况下没有UART硬件FIFO,RDR里只有一个字节的缓冲。这意味着从RXNE置位到DMA响应之间,留给系统的裕量很小,尤其是高速率和高负载时,UART DMA通道的优先级不能太低。

2.3 例说CubeMX配置中决策点

CubeMX里配置UART DMA时,我习惯把RX和TX分别添加,然后逐个看参数:

  • Direction:RX必须选择PeripheralToMemory,TX选MemoryToPeripheral,这个一般不会选错。
  • PeriphInc:必须关闭。外设地址固定是USART_RDR,不是一组连续寄存器。
  • MemInc:必须打开。DMA要把数据连续写入用户缓冲区,如果关闭就会一直写同一地址。
  • PeriphDataAlignment和MemDataAlignment:全部选Byte。UART数据寄存器只有低8位有效,选HalfWord会一次搬16位,选Word会一次搬32位,都会造成数据错乱。
  • Mode:如果你用Normal模式,在缓冲区收满后DMA会停止;用Circular模式则自动重载。这个选择直接关系到接收策略。
  • Priority:RX通道建议High,TX可以Medium。如果UART、SPI、ADC共用同一个DMA控制器,Priority太低会导致RX请求被持续压住。

很多文档只强调数据宽度,却忽略了Priority。我实测下来,在高负载多外设场景下,UART RX优先级为Low时丢数据概率明显增加,改成High之后恢复正常。这个“撞”不是数据撞了,而是DMA通道之间互相抢时间。

2.4 数据宽度、优先级等参数对碰撞的影响

数据宽度错误是“最冤枉”的碰撞问题。UART的RDR寄存器是32位地址空间,但有效数据只有8位。如果DMA被配置成Word宽度,一次搬运会从RDR地址读出32位数据,也就是说把相邻寄存器内容一并读进了缓冲区。在STM32L4上,这个现象表现为数据每隔几个字节就多出几个0xFF或者0x00,非常难排查。

优先级的作用则可以理解为排队插队。同一个DMA控制器上,如果ADC配置了VeryHigh并持续进行扫描转换,UART的RX请求Priority是Low,那么UART RXNE事件可能需要多等好几个周期才能被DMA响应。这种响应延迟在高波特率下直接导致RDR被新数据覆盖,最终ORE置位,DMA停止。所以不要把优先级只当摆设。

3. 实测方案:Modbus帧接收场景下的DMA接收设计

3.1 选用Normal模式还是Circular模式

我在项目里收到的是类似Modbus的帧数据,每帧有起始符、长度字段、数据体和CRC。帧与帧之间有几十毫秒的空闲,长度不固定。这种场景我最终选了Normal模式加UART IDLE中断,而不是一开始想的Circular模式。

Circular模式的优点是缓冲区可以被DMA连续写入,适合音频、数据流这类不需要判断帧边界的场景。但它要求软件维护读写指针,并且要处理半传输中断和传输完成中断,否则你无法判断DMA到底写到了哪里。如果缓冲区管理和DMA写入不同步,读到的数据就可能是新旧交错的,这种错误在调试时极具迷惑性。

Normal模式则简单得多:DMA收到指定字节数后自动停止,只要在IDLE中断里判断当前DMA计数器的剩余值,就能算出实际收到多少字节,然后处理完再重新启动下一次接收。对于“帧”型协议,这比Circular模式更利于排查数据碰撞。

3.2 代码框架:IDLE中断+DMA接收

下面给出一段在我项目中实际使用的精简框架,使用STM32CubeMX生成的HAL库工程。代码里假设已经配置好UART2的DMA接收通道。

#define RX_BUF_SIZE 128 uint8_t rx_buf[RX_BUF_SIZE]; volatile uint16_t rx_len = 0; volatile uint8_t rx_complete = 0; // 初始化时启动一次接收 HAL_UARTEx_ReceiveToIdle_DMA(&huart2, rx_buf, RX_BUF_SIZE);

如果HAL库版本较新,可以在回调函数中处理帧到达事件:

void HAL_UARTEx_RxEventCallback(UART_HandleTypeDef *huart, uint16_t Size) { if (huart == &huart2) { rx_len = Size; rx_complete = 1; // 重新启动接收,注意这里要结合缓冲区使用情况决定 HAL_UARTEx_ReceiveToIdle_DMA(&huart2, rx_buf, RX_BUF_SIZE); } }

主循环中处理:

while (1) { if (rx_complete) { rx_complete = 0; process_frame(rx_buf, rx_len); } }

这里有个坑:如果你在上一帧数据还没处理完时,就重新调用HAL_UARTEx_ReceiveToIdle_DMA,DMA可能很快就会写入同一片缓冲区,把尚未处理的数据覆盖掉。要避免这种碰撞,主循环里应该在复制完有效数据之后,再重新启动DMA接收,或者用双缓冲区交替运行。

3.3 环形缓冲/乒乓缓冲与读写指针设计

如果一定要用Circular模式,建议采用乒乓缓冲或者环形缓冲,而不是在一条缓冲区上裸奔。

乒乓缓冲的思路是准备两个大小相同的缓冲区A和B。DMA先写A,半传输中断说明A写满,此时CPU处理A中数据,DMA继续写B;全传输中断说明B写满,CPU处理B,DMA循环复用A。这样CPU和DMA永远不碰同一块区域,逻辑清晰,代价是多占一份内存。

如果只能用一块缓冲区,那么环形缓冲需要知道DMA当前写到了哪里。常用手段是通过__HAL_DMA_GET_COUNTER读取DMA的NDTR寄存器:

uint32_t remain = __HAL_DMA_GET_COUNTER(huart2.hdmarx); uint32_t write_pos = (RX_BUF_SIZE - remain) & (RX_BUF_SIZE - 1);

前提是RX_BUF_SIZE为2的幂次,并且DMA从rx_buf起始地址开始写。在IDLE中断或半传输/全传输中断里读取这个值,可以得到DMA写入位置的快照。但要注意,这个值只是一个瞬间状态,不能保证和某次DMA写操作完全同步。最稳妥的方法还是只在半传输/全传输中断回调里更新写索引,不要在主循环里随时去读NDTR。

读索引和写索引必须声明为volatile,并且不要在表达式中混用非原子操作。主循环消费数据时,先算出可读长度,然后逐字节复制到临时数组,最后再更新读索引。如果在读到一半时DMA覆盖了未读区域,数据就变成“一半新一半旧”,这是典型的UART DMA collision。

3.4 启动、复位、超时处理的完整状态机

为了管理接收流程,我建议设计一个简单状态机。状态可以定义成:

  • IDLE:等待空闲事件,DMA已经启动
  • RECEIVING:DMA正在接收,可能收到一帧的一部分
  • FRAME_READY:IDLE事件发生,DMA停止或数据可读
  • PROCESSING:主循环正在处理帧数据

整个流程的核心是:IDLE中断只负责设置标志和记录长度,不直接处理数据;主循环检测到FRAME_READY后,把数据搬走,再重新启动DMA。如果接收过程中出现ORE错误,需要立刻清错误标志,并且重新启动DMA,否则后续数据永远不会进来。

如果协议允许帧与帧之间空闲时间非常短,建议使用UART的超时中断或者系统tick超时机制,防止一帧数据一直收不完导致DMA长期占用。我遇到过的情况是:从机断电瞬间只发送了半个字节,UART没有触发IDLE事件,DMA就一直挂着,直到看门狗复位。所以超时处理不能省。

4. 常见Collision问题与排查实录

4.1 问题一:DMA接收一段时间后停止,UART ORE置位

这是最常见的“假死”问题。现象是系统运行一段时间后,串口彻底停止接收数据,但程序本身还在运行。读取UART状态寄存器,ORE位为1。

原因通常是UART RDR中的数据没有被及时搬走,新数据又来了,触发溢出。在DMA模式下,最常见的原因是DMA通道优先级太低,或者主循环长时间关中断,导致RXNE请求得不到响应。

解决方法是使能UART错误中断,在错误回调里清掉ORE,同时重启DMA接收:

void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) { if (huart == &huart2) { // 避免DMA停留在错误状态 HAL_UART_AbortReceive(&huart2); __HAL_UART_CLEAR_FLAG(&huart2, UART_CLEAR_OREF); HAL_UARTEx_ReceiveToIdle_DMA(&huart2, rx_buf, RX_BUF_SIZE); } }

注意,不要只在错误回调里清标志,不重新启动DMA。那样标志清了,DMA通道还是停止状态,下一次接收依然不会来。

4.2 问题二:接收缓冲区出现错位数据或“叠字”

如果收到的数据总是“整体错位”,比如你发0x01 0x02 0x03 0x04,收到的却是0x02 0x03 0x04 0x05,那么大概率不是DMA碰撞,而是DMA配置问题。检查方向是:

  • 数据宽度是否设成了HalfWord或Word
  • DMA请求源是否选错,比如把UART2_TX请求配给了UART2_RX通道
  • UART的校验位、停止位和实际波形是否匹配

如果数据是“偶尔错位”,并且错位点在帧中间,那通常就是CPU和DMA竞争引起的。可以尝试在接收缓冲区中填充固定的模式,比如0xAA、0x55,然后长时间跑,观察错位规律。

最隐蔽的是Circular模式下读写指针更新问题:DMA一直在写,CPU读的速度跟不上,读索引追不上写索引,导致缓冲区回绕时出现一整段重复数据。遇到这种情况,优先检查是否用了半/全传输中断同步读写指针。

4.3 问题三:主循环访问缓冲区时DMA恰好写入

这个问题在单缓冲模式下特别容易出现。主循环判断到rx_complete标志后,开始读取rx_buf里的数据。如果此时DMA已经重新启动,并且新一帧数据已经写入,那么读到的一帧就是新旧数据的混合体。

解决办法有三个方向:

  • 处理完数据再启动新接收,而不是在回调里立即重启
  • 使用双缓冲,处理A时DMA写B
  • 先把rx_buf的数据memcpy到另一块临时缓冲区,再标志处理完成

从实际经验看,memcpy方案虽然多消耗一点时间,但逻辑最简单,也不容易引入指针问题。数据量不大时完全够用。

4.4 问题四:多外设DMA同时开启,UART响应变慢

如果你同时运行UART、SPI、ADC等外设DMA,UART接收对实时性的要求最高。DMA仲裁原则是优先级高的请求先响应,同优先级则通道编号小的先响应。UART RX通道Priority如果设成了Low,在高负载下很容易被其他VeryHigh通道抢占。

我实测过一次,SPI DMA搬运和UART同时开启,UART每100帧就会丢1到2字节。把UART RX DMA优先级从Low改成High后,丢字节问题消失。同时我把UART的RX请求映射到了DMA1,SPI的DMA映射到了DMA2,总线负载也明显降低。

另外,使用DMAMUX时,同一请求不能同时映射到两个通道。如果工程里重复初始化了DMA请求源,也会出现奇怪的“偶发不工作”。检查CubeMX生成的代码,确认UART的RX和TX请求没有被同一个通道抢占。

4.5 在线调试与寄存器检查技巧

遇到UART DMA碰撞问题,不要急着打一堆printf,先学会看寄存器。

在STM32CubeIDE的Live Expressions窗口里,可以添加以下变量观察:

  • huart2.hdmarx->Instance->NDTR:DMA剩余计数器
  • huart2.Instance->ISR:UART中断状态寄存器,重点看RXNE、ORE、IDLE
  • huart2.hdmarx->State:DMA状态
  • huart2.gState和huart2.RxState:HAL层状态

调试DMA问题时,断点不要打在DMA中断回调函数里,也不要打在UART中断处理函数里。因为断点会冻结DMA时钟,一旦停在中断中间,DMA状态就不再变化,你看到的其实是被“冻结”后的假象,不是真实运行现场。更好的方式是用逻辑分析仪抓UART RX引脚波形,再配合一组固定的串口测试帧,反复触发问题。

我还习惯在接收缓冲区的头部和尾部各放一个特定的魔数,比如0x5A和0xA5,每次处理数据前检查这两个位置。如果魔数被覆盖,说明DMA写入越界了;如果魔数完好但数据错位,则要优先怀疑地址递进或者索引更新逻辑。

5. 关于稳定性的一些个人体会

最后聊一点我自己的习惯。吃过几次UART DMA的亏以后,我现在上手STM32L4的串口DMA项目,第一步永远是打开参考手册的DMA请求映射表,把UART RX/TX实际用到的DMAMUX通道号写下来,而不是直接依赖CubeMX生成的代码。

第二步是明确接收策略:连续数据流用Circular加半/全传输中断,帧协议用Normal加IDLE中断。不要想着一套代码兼容所有场景,接受逻辑不同,就会少踩很多坑。

第三步是给UART DMA接收通道设置一个比主循环更可靠的中断路径。我一般会把错误中断打开,并在错误回调里简单处理好ORE,而不是等UART彻底卡死之后再去查原因。这个小改动省了我不少现场问题。

再分享一个小技巧。我写测试用例时,会特意构造0x00、0xFF、0x55、0xAA交替出现的帧,内容本身没有实际意义,但能快速暴露数据宽度、地址递进和DMA优先级带来的问题。每次改完DMA相关代码,用这份测试跑半小时,基本就能判断配置是否稳定。

UART DMA的collision问题看上去像玄学,其实背后都有清晰的硬件逻辑。把DMA请求、UART状态位、缓冲区读写顺序这三件事拆开,逐项核对,多数问题都能定位到具体那一步。希望这篇内容能帮你少走一段弯路。

http://www.cnnetsun.cn/news/4303830.html

相关文章:

  • 算法服务故障复盘应留下什么
  • 如何将 Windows 容器占用从 15GB 压到 2.5GB:Windows X Lite 轻量化部署实战
  • 3 个内置技能搭起 AI 自动化测试闭环:claude-code-best-practice 使用指南
  • 树莓派5上YOLOv8人员检测实战:基于OpenCV DNN与ONNX的轻量部署
  • Fooocus 免费 AI 绘图:3 分钟出第一张图
  • Rclone 使用指南:5 分钟从安装到自动化,搞定多云存储备份与挂载
  • Nuxt 预加载优化完整指南:5 步把首屏跳转时间压到 1.5 秒内
  • Vorssaint文本片段教程:输入一个词展开完整地址,支持日期变量
  • Magisk Android 无伤 Root 与系统级定制完整指南
  • LLM长期记忆架构实验:向量检索、摘要压缩与混合记忆方案对比
  • Vibe Coding实战:用Claude Code与Codex CLI开启AI协作开发
  • LX Music 免费桌面播放器:五大音源聚合搜索,一个窗口搞定听歌到囤歌
  • 3分钟上手 RuView:不用摄像头做 WiFi 人体姿态追踪的完整指南
  • 5步用熟OBS Studio:从第一次录屏到开播的快速指南
  • 无屏AI硬件“甜甜圈”解析:从语音交互到嵌入式工程实践
  • C++八股勘误:const指针、shared_ptr线程安全与vector扩容真相
  • 纯CPU推理引擎llambda.lisp:Common Lisp实现AVX2加速的LLM推理
  • C#开发Basler工业相机读取教程:pylon SDK图像采集与触发配置
  • 7年Java后端面试复盘:项目经验、高并发与系统设计核心考点
  • 用MATLAB有限元法分析三维光子晶体带隙
  • OpenAI Astra解读:多模态模型API调用与ChatGPT客户端报错排查指南
  • 贝叶斯AI崛起?深度学习工程师该多带一件救生衣
  • Fermat主动拉普拉斯学习:低标注成本高光谱图像分类方法
  • Java面试八股文系统整理:基础、集合、JVM、并发全覆盖
  • MATLAB管道瞬变流仿真:特征线法、边界条件与工程实践
  • 2016搜狐研发工程师笔试题解析:从算法到操作系统的校招备考指南
  • 用Python构建GitHub风格阅读热力图:从数据到自动更新
  • LiveMem:破解长时LLM推理的记忆断层与状态连续性难题
  • MiniMind 医疗 LoRA 微调实战:2 小时 3 元训出 64M 垂直医疗助手
  • 本地部署多智能体项目 my_ai_town:从搭建到批量任务实践