资讯动态

STM32L4 UART DMA偶发数据错乱与卡死:根因分析及解决方案

发布时间:2026/8/30 13:00:15 来源:尧图企业网站定制
搞嵌入式这些年在处理 STM32L4 项目时只要涉及串口通信我几乎无脑上 DMA尤其 UART 收发。L4 这颗 MCU 的 CPU 频率不算高如果用中断一个字节一个字节地搬数据既费 CPU 又容易丢帧DMA 几乎是唯一合理的选择。但用 DMA 不等于一劳永逸我前段时间就踩了一个非常隐蔽的坑UART DMA 在运行一两个小时后偶发 collision导致数据错乱甚至整个串口接收通道直接“假死”。这个问题的排查过程让我把 STM32L4 的 UART 与 DMA 协作机制翻了个底朝天也让我重新梳理了一套更稳的驱动设计。这篇就把完整的现象、原理、排查链路和最终方案写下来给正在用 STM32L4 做 UART DMA 的朋友一个参考。1. 故障现象设备能跑但随机抽风这个问题的麻烦之处在于它不是一上来就崩而是要在特定条件下才会出现。我先把项目背景交代一下方便大家对号入座。1.1 项目背景与硬件组成当时做的是一块电池供电的传感器采集节点主控是 STM32L431有两个串口在工作。USART1 以 115200 波特率跟外部上位机通信负责接收配置命令和上报数据USART2 以 9600 波特率连接一个外置传感器接收它的周期性测量结果。两个串口接收侧都用了 DMA配合 UART 的空闲中断来断帧发送侧也走 DMA但发送相对简单因为数据是我们主动发出去的DMA 搬完就停不太容易出问题。CubeMX 里我配置了两个 DMA 通道USART1_RX 挂在 DMA1 的 Channel5USART2_RX 挂在 DMA1 的 Channel6都开启了传输完成中断。接收缓冲区大小分别为 512 字节和 128 字节DMA 工作模式选的是 Normal非循环。这套配置看着很常规没有任何花活所以当问题出现时我一度怀疑是硬件设计问题。1.2 从偶发到频繁卡死设备最初测试时一切正常但连续运行大概一两个小时之后USART1 的接收就开始出现偶发异常。上位机发过来的命令帧偶尔会丢一个字节或者数据内容错位。这时候只要重新初始化一下串口驱动设备又能正常一阵子。更讨厌的是另一个现象当某次错误触发后USART1 的 DMA 接收会彻底卡住之后不管上位机发多少数据MCU 这边都不再进入接收完成回调整个通道像死了一样。只有断电重启或者手动调用一次串口重新初始化才能恢复。USART2 倒是没出过这种问题这可能跟它的数据量小、波特率低有关系。1.3 初步排查先排除硬件和配置我一开始以为是硬件问题。拿示波器量了 RX/TX 引脚的波形电平、毛刺、波特率误差都在正常范围内又换了 USB 转串口工具问题依旧。然后我把 USART1 的 DMA 接收临时改回普通中断接收也就是一个字节进一次中断结果跑了一整晚都没再复现。到这里基本可以确定问题出在 DMA 与 UART 的协同流程上而不是外部干扰或者电气问题。接下来我仔细查了 CubeMX 生成的初始化代码DMA 通道、外设请求映射、中断优先级都看了一遍从静态配置上找不到毛病。于是我把目光转向运行时的状态尤其是 DMA 通道的控制寄存器和计数寄存器。2. 底层机制L4 的 UART 和 DMA 是怎么协作的在说排查细节之前我觉得有必要把 STM32L4 上 UART 和 DMA 的协作机制讲清楚。很多所谓“碰撞”问题本质上是对这套机制的一个细节理解不到位。2.1 一个字节的旅程当 UART 收到一个字节时硬件会把数据从接收移位寄存器放到 USART_DR 寄存器中同时将 RXNEReceive data register not empty位置 1。如果此时 USART_CR3 寄存器的 DMAR 位已经被软件置 1UART 外设就会向 DMA 控制器发送一个接收请求DMA 收到请求后从 USART_DR 读出数据写到内存缓冲区中然后 DMA 通道的 NDTR 计数器减一内存地址指针加一。这里有个关键点STM32L4 的 USART 没有硬件 FIFO只有一个数据寄存器。也就是说如果 RXNE 已经置位而 DMA 还没有来得及把数据搬走下一个字节又到了硬件就会把新数据覆盖掉老数据并置位 OREOverrun error标志。一旦发生 OverrunUART 会停止接收后续数据若不清标志DMA 再努力也白搭整个接收通道就像被堵住了一样。我后来回头看那个“彻底卡死”的故障多半就是 ORE 没有被及时清理导致的。但 ORE 只是最终表现真正要追的是它为什么会在运行途中冒出来。2.2 DMA 的三种标志和它们的行为DMA 通道有几种中断标志最常用的是 TCIF传输完成、HTIF半传输和 TEIF传输错误。TCIF 的置位时机取决于工作模式Normal 模式NDTR 递减到 0DMA 传输完成TCIF 置位同时硬件将 DMA 通道的 EN 位清 0通道自动禁用。Circular 模式NDTR 递减到 0 后自动重载为初始值TCIF 置位但通道的 EN 位始终保持为 1DMA 会继续搬运后续数据。很多人容易忽略一个细节在 Normal 模式下DMA 通道虽然被硬件禁用了但 UART 的 DMAR 位仍然是 1。也就是说UART 继续认为“DMA 还在为我服务”。如果此时 UART 又收到一个字节RXNE 会置位可 DMA 通道已经停了没有谁来搬这个字节RXNE 就一直挂在那。如果这个字节不被及时处理下一个字节一到就产生 Overrun。2.3 DMAMUX请求线映射带来的“动态冲突”STM32L4 和老的 F1/F4 还有一个不太一样的地方它内部多了一个 DMAMUX 模块专门负责把外设的 DMA 请求映射到具体的 DMA 通道上。外设请求不再像 F4 那样固定绑死在某个通道上而是可以灵活选择。这个设计把灵活性拉满了但也带来一类非常隐蔽的静态冲突如果两个外设请求被配置到同一个 DMA 通道而 DMAMUX 的请求选择寄存器只能指向其中一个那么另一个外设的 DMA 请求就等于被“屏蔽”了。这种问题通常出现在手工修改代码或者从旧项目移植驱动时DMA 请求映射被覆盖导致某一路外设 DMA 完全不工作。它也算一种 collision虽然和运行时竞态不是一回事但排查起来同样让人头疼。3. 完整排查链路从寄存器到波形排除了硬件和静态配置后我开始在故障现场“抓现行”。这一步非常关键建议所有遇到类似问题的朋友都按照这个思路来先复现再取证。3.1 在调试器里冻结现场读 DMA 状态当设备再次进入卡死状态时我没有急着重启而是立刻用调试器暂停程序查看 hdma_usart1_rx 相关的寄存器。重点看几个值DMA 通道的 NDTR、CR 的 EN 位、UART 的 ISR 寄存器以及 HAL 句柄里的 State 和 ErrorCode。结果发现卡死时 UART1 对应的 DMA 通道 EN 位已经是 0也就是通道已经被硬件禁用了NDTR 为 0TCIF 置位说明 DMA 正常完成了一次传输。但再一看 USART_CR3DMAR 位还是 1USART_ISR 里 RXNE 也为 1。现场完全符合我前面说的那种情况DMA 已经停了UART 却还认为它有 DMA 后端在帮忙接收于是有字节进来后 RXNE 一直悬挂直到 ORE 置位通道彻底堵死。如果你在调试器里也看到 DMA 的 EN0、NDTR0但 UART 的 DMAR1、RXNE1那基本可以断定问题出在“DMA 传输完成后外设侧没有正确复位”这个环节。3.2 用逻辑分析仪复现最后几步光看寄存器还不够我想知道这个状态是在什么场景下被触发的。于是我临时把 DMA 传输完成中断服务函数里的一条 GPIO 翻转语句加进去用逻辑分析仪同时抓 USART1_RX 引脚和这个 GPIO。实际抓到的波形很有意思上位机在一瞬间连续发来了两帧数据第一帧长度恰好等于 DMA 缓冲区大小 512 字节。DMA 搬完第 512 个字节后TCIF 置位进入传输完成中断紧接着第二帧的第一个字节就到了UART 收到它并置位 RXNE。但由于 CPU 正在执行 DMA 中断服务这个新字节的 DMA 请求没有得到及时响应RXNE 就一直挂着。等 DMA 中断服务执行完代码在回调里重新调用 HAL_UART_Receive_DMA 时HAL 库会去做一系列状态检查和恢复操作但它并没有去清掉 UART 侧已经挂起的 RXNE 标志。于是新启动的 DMA 通道会立刻收到一个“陈旧”的接收请求把那个残留字节搬到缓冲区头部。这个字节若被当成正常命令解析命令就错乱了。3.3 关键证据NDTR 的“回跳”现象除了卡死现场还存在另一种更隐蔽的错位情况。我在数据量不大的时候通过在空闲中断里打印当前 DMA 的 NDTR 值发现了一个“回跳”现象。假设 DMA 缓冲区大小为 128 字节正常接收时NDTR 会从 128 递减到 0。但某一次当一帧数据刚好收满 128 字节、DMA 完成传输并进入 Normal 模式的停止状态后如果代码再次启动 DMA而 UART 侧还残留着一个 RXNE 标志那么 DMA 会立刻搬运这个残留字节NDTR 变成 127。接下来如果你按“缓冲区大小减 NDTR”来计算本轮收到的数据长度就会得出 1 这个结果但实际上这一帧数据已经因为 DMA 停止而丢失了。这个现象的关键在于上一轮 DMA 的传输完成状态和下一轮 DMA 的启动状态之间没有做好“交接”。严格来说是 DMA 通道的 EN0、UART 的 DMAR1、RXNE 悬挂这三者凑在一起形成了一次数据错位。4. 冲突根因中断竞态与缓冲区双写找到现场证据后根因其实已经清楚了一大半。我再把这个冲突的触发链路拆开讲因为只有理解了它才能真正避免它。4.1 IDLE 中断和 DMA 完成中断的交叉我在前文提到USART1 接收侧同时用了 DMA 传输完成中断和 UART 空闲中断。这两个中断源对应不同的中断入口但都会操作同一个 DMA 通道和同一份接收缓冲区。正常流程是这样DMA 在 Normal 模式下搬完一帧数据TCIF 触发代码在传输完成回调里处理数据然后再次调用 HAL_UART_Receive_DMA 准备接收下一帧同时UART 在检测到总线上出现空闲时IDLE 标志置位也会触发一次中断。问题就出在这里。当上位机发送的帧长恰好等于 DMA 缓冲区大小时同一个时刻可能同时发生两件事DMA 完成最后一字节传输TCIF 置位随后总线上出现空闲IDLE 置位。两个中断挤在一起如果它们的优先级有差异CPU 必然先处理一个再处理另一个。在这个时间窗口内如果代码在其中一个中断里调用了 HAL_UART_DMAStop 或者 HAL_UART_Receive_DMA而另一个中断又在尝试读取 NDTR 或者操作 DMA 通道就会出现竞态。更隐蔽的是DMA 的 TCIF 置位后通道 EN 被硬件清零但代码如果先去响应 IDLE 中断然后把 DMA 重新使能并启动了新一轮接收那么之前挂起的 TCIF 可能被莫名其妙地清除或被错误地当成新一轮传输的完成标志导致应用层逻辑发生误判。4.2 缓冲区双写DMA 和 CPU 同时动一块内存除了中断交叉缓冲区双写也是 UART DMA 场景下最经典的 collision 类型。Circular 模式下尤其常见。比如你配置了 512 字节的 Circular DMA 接收缓冲区DMA 在后台不停地往缓冲区写数据。空闲中断到来时CPU 从 DMA 获取当前 NDTR算出新数据位置然后直接对那段内存做拷贝或者解析。可就在 CPU 拷贝的过程中DMA 完全可能已经搬运了新的字节到同一片区域。如果这两套逻辑没有做同步CPU 读到的一半是旧数据一半是新数据看起来就是完整的一帧实际上却是两个数据帧的“缝合怪”。这类问题最迷惑人的地方在于它不是每次都会发生往往要在特定波特率、特定帧长和特定系统负载下才会复现。我后来重新设计驱动时干脆放弃了“在中断里直接拷贝解析”的做法改为只记录位置、由主循环统一处理。4.3 另一个同类问题DMA 通道映射错位除了运行时的竞态还有一类静态映射上的冲突也值得提一下。我在另一个项目里就遇到过USART2_TX 之前被映射到 DMA1_Channel4后来有一次代码重构有人把新的外设请求配置到了同一个 DMA 通道的 DMAMUX 请求选择寄存器上导致 USART2_TX 的请求被覆盖。表面上看两个外设不会同时使用同一个通道但 DMAMUX 选择器只能指向一个请求源后配置的会把先配置的顶掉。这种问题在 CubeMX 的图形界面里不容易出现因为它会自动避让但如果你手工写寄存器或者从旧工程复制 DMA 初始化代码就可能踩中。排查方法是检查每个 DMA 通道的 DMAMUX_CxCR 寄存器值确认它到底指向哪个外设请求。5. 解决方案一套尽量不踩坑的 UART DMA 驱动写法排查过程花了差不多三天而最终的解决方案其实并不复杂。核心思路就是不要让两个中断源在同一个时间点去操作同一个 DMA 通道更不要在中断上下文里反复 stop/start DMA。5.1 首选方案Circular 模式 IDLE 中断永不停止 DMA我现在在 STM32L4 上写 UART 接收驱动的首选方案是DMA 使用 Circular 模式初始化时启动一次之后在整个运行周期内都不停止 DMAUART 使能 IDLE 中断利用空闲来断帧在 IDLE 中断里只记录当前 DMA 写到的位置把数据拷贝和解析交给主循环。先看初始化部分#define RX1_BUF_SIZE 512 static uint8_t uart1_rx_buf[RX1_BUF_SIZE]; static volatile uint32_t uart1_rx_write_pos 0; // DMA 当前写入位置 static volatile uint32_t uart1_rx_read_pos 0; // 应用层已消费位置 static volatile uint8_t uart1_rx_frame_pending 0; void MX_USART1_UART_Init(void) { // ... 标准 UART 初始化波特率 1152008N1 ... // 启动 Circular DMA 接收缓冲区长度固定 HAL_UART_Receive_DMA(huart1, uart1_rx_buf, RX1_BUF_SIZE); // 使能空闲中断 __HAL_UART_ENABLE_IT(huart1, UART_IT_IDLE); }在 IDLE 中断中我只做一件事读取当前 DMA 的 NDTR计算出 DMA 已经写入的位置void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(huart1); // 循环读取 NDTR防止 DMA 正好在搬运一字节时产生误差 uint32_t cnt1, cnt2; do { cnt1 __HAL_DMA_GET_COUNTER(hdma_usart1_rx); cnt2 __HAL_DMA_GET_COUNTER(hdma_usart1_rx); } while (cnt1 ! cnt2); uart1_rx_write_pos RX1_BUF_SIZE - cnt1; // 只是标记一下不在中断里做解析 uart1_rx_frame_pending 1; } HAL_UART_IRQHandler(huart1); }主循环里再根据uart1_rx_frame_pending去解析数据解析完更新uart1_rx_read_pos。这样 DMA 通道始终处于工作状态不存在“DMA 已停止但 UART 还挂着 DMAR”的中间状态也就从根上消除了我前面分析的那条故障链。5.2 如果必须 stop/start请严格按这个顺序来有些场景确实没办法一直开着 DMA比如低功耗模式下需要彻底关闭外设或者要临时切换 DMA 缓冲区。这种情况下stop/start 的顺序就非常重要了反了必出事。我的建议顺序是先清掉 UART 的 DMAR 位断开外设请求。等 DMA 通道当前传输完成确认 EN 位已经为 0。清掉 DMA 通道的 TCIF、HTIF、TEIF 标志。清掉 UART 的 ORE、IDLE 等残留标志。可能的话读一次 DR把残留数据取出。重新配置 DMA 的 NDTR 和内存地址。使能 DMA 通道。重新置位 UART 的 DMAR 位。写成代码大概是这个样子void UART1_RxDMA_Restart(void) { // 1. 先断开外设请求 CLEAR_BIT(huart1.Instance-CR3, USART_CR3_DMAR); // 2. 停 DMA等待当前传输彻底结束 __HAL_DMA_DISABLE(hdma_usart1_rx); // 3. 清 DMA 标志 SET_BIT(hdma_usart1_rx.DMA_Channel-IFCR, DMA_IFCR_CTCIF | DMA_IFCR_CHTIF | DMA_IFCR_CTEIF); // 4. 清 UART 标志 __HAL_UART_CLEAR_OREFLAG(huart1); __HAL_UART_CLEAR_IDLEFLAG(huart1); // 5. 丢弃可能的残留字节 (void)huart1.Instance-DR; // 6. 重新设置缓冲区 hdma_usart1_rx.Instance-NDTR RX1_BUF_SIZE; hdma_usart1_rx.Instance-CMAR (uint32_t)uart1_rx_buf; // 7. 使能 DMA __HAL_DMA_ENABLE(hdma_usart1_rx); // 8. 恢复外设请求 SET_BIT(huart1.Instance-CR3, USART_CR3_DMAR); }注意第 2 步的__HAL_DMA_DISABLE在 HAL 库中会等待 DMA 通道的 EN 位清零如果 DMA 正在传输要等当前这一拍搬完才停。所以不要在定时器中断这种时间敏感型代码里调用它除非你能容忍几个微秒的阻塞。5.3 中断优先级和临界区处理如果你用的还是 Normal 模式并且必须依赖 DMA 传输完成中断来启动下一轮接收那么中断优先级的设置就有讲究。我个人的建议是让 DMA 完成中断和 UART 空闲中断使用相同优先级并且在这两个中断里都只做“记录”和“标记”绝不做 stop/start更不做耗时的数据解析。这样即使两个中断挤在一起也只是先后被响应不会互相打断对方的关键操作。如果实在没办法必须在中断上下文里操作 DMA 状态那就用临界区保护。最简单的方式是在操作前关中断操作完再开__disable_irq(); // 操作 DMA 通道寄存器重新配置 NDTR清标志 __enable_irq();这招够粗暴但有效。唯一要提醒的是临界区的代码必须足够短不能阻塞超过几个微秒否则实时性会被拖垮。5.4 验证效果72 小时压力测试方案改完以后我做了一个相对严格的压力测试。上位机每 20ms 发一包随机长度的数据长度范围从 1 字节到 300 字节板端收到后原样回传。测试持续 72 小时期间统计丢包、错包、通道卡死次数。对比数据如下测试项修改前Normal TC重启修改后Circular IDLE测试时长2 小时左右必现异常72 小时无异常丢包率最高约 3%0错位帧次数多次0通道卡死次数2 次0CPU 占用率偏高明显降低主循环分批处理这个结果也验证了我对根因的判断问题不在 DMA 本身而在于 DMA 通道与外设状态机之间的交接不干净。6. 复盘与一些经验总结最后聊点个人体会。这类问题的难点在于现象不规律初期很容易被误判为硬件干扰或者代码编译优化问题。但只要掌握了 UART 和 DMA 的硬件协作细节再结合“暂停现场读寄存器”的手段基本都能定位到具体环节。我现在做新项目时的习惯是STM32L4 的 UART DMA 接收一律走 Circular IDLE 中断这套方案主循环负责解析DMA 通道从初始化到停机永远不被人为打断DMA 请求映射通过 DMAMUX 做显式分配并用表格记录每个通道对应的外设请求防止配置冲突中断里绝不调用 HAL_UART_DMAStop。如果你正被类似问题折磨希望这篇能给你省下几天排查时间。遇到 DMA 相关的诡异现象记住一句话先看 DMA 通道的 EN 位和 NDTR再看外设侧对应的 DMA 请求使能位两边的状态必须对齐否则一定会在某个时刻撞出你意想不到的 bug。

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

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

免费获取报价