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

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

搞嵌入式这些年,在处理 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 的 Channel5,USART2_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 寄存器中,同时将 RXNE(Receive data register not empty)位置 1。如果此时 USART_CR3 寄存器的 DMAR 位已经被软件置 1,UART 外设就会向 DMA 控制器发送一个接收请求;DMA 收到请求后,从 USART_DR 读出数据,写到内存缓冲区中,然后 DMA 通道的 NDTR 计数器减一,内存地址指针加一。

这里有个关键点:STM32L4 的 USART 没有硬件 FIFO,只有一个数据寄存器。也就是说,如果 RXNE 已经置位,而 DMA 还没有来得及把数据搬走,下一个字节又到了,硬件就会把新数据覆盖掉老数据,并置位 ORE(Overrun error)标志。一旦发生 Overrun,UART 会停止接收后续数据,若不清标志,DMA 再努力也白搭,整个接收通道就像被堵住了一样。

我后来回头看,那个“彻底卡死”的故障,多半就是 ORE 没有被及时清理导致的。但 ORE 只是最终表现,真正要追的是它为什么会在运行途中冒出来。

2.2 DMA 的三种标志和它们的行为

DMA 通道有几种中断标志,最常用的是 TCIF(传输完成)、HTIF(半传输)和 TEIF(传输错误)。TCIF 的置位时机取决于工作模式:

  • Normal 模式:NDTR 递减到 0,DMA 传输完成,TCIF 置位,同时硬件将 DMA 通道的 EN 位清 0,通道自动禁用。
  • Circular 模式:NDTR 递减到 0 后自动重载为初始值,TCIF 置位,但通道的 EN 位始终保持为 1,DMA 会继续搬运后续数据。

很多人容易忽略一个细节:在 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 为 0,TCIF 置位,说明 DMA 正常完成了一次传输。但再一看 USART_CR3,DMAR 位还是 1,USART_ISR 里 RXNE 也为 1。现场完全符合我前面说的那种情况:DMA 已经停了,UART 却还认为它有 DMA 后端在帮忙接收,于是有字节进来后 RXNE 一直悬挂,直到 ORE 置位,通道彻底堵死。

如果你在调试器里也看到 DMA 的 EN=0、NDTR=0,但 UART 的 DMAR=1、RXNE=1,那基本可以断定问题出在“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 通道的 EN=0、UART 的 DMAR=1、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 模式,初始化时启动一次,之后在整个运行周期内都不停止 DMA;UART 使能 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 初始化,波特率 115200,8N1 ... // 启动 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 的顺序就非常重要了,反了必出事。

我的建议顺序是:

  1. 先清掉 UART 的 DMAR 位,断开外设请求。
  2. 等 DMA 通道当前传输完成,确认 EN 位已经为 0。
  3. 清掉 DMA 通道的 TCIF、HTIF、TEIF 标志。
  4. 清掉 UART 的 ORE、IDLE 等残留标志。
  5. 可能的话,读一次 DR,把残留数据取出。
  6. 重新配置 DMA 的 NDTR 和内存地址。
  7. 使能 DMA 通道。
  8. 重新置位 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 次0
CPU 占用率偏高明显降低,主循环分批处理

这个结果也验证了我对根因的判断:问题不在 DMA 本身,而在于 DMA 通道与外设状态机之间的交接不干净。

6. 复盘与一些经验总结

最后聊点个人体会。这类问题的难点在于,现象不规律,初期很容易被误判为硬件干扰或者代码编译优化问题。但只要掌握了 UART 和 DMA 的硬件协作细节,再结合“暂停现场读寄存器”的手段,基本都能定位到具体环节。

我现在做新项目时的习惯是:STM32L4 的 UART DMA 接收一律走 Circular + IDLE 中断这套方案,主循环负责解析,DMA 通道从初始化到停机永远不被人为打断;DMA 请求映射通过 DMAMUX 做显式分配,并用表格记录每个通道对应的外设请求,防止配置冲突;中断里绝不调用 HAL_UART_DMAStop。

如果你正被类似问题折磨,希望这篇能给你省下几天排查时间。遇到 DMA 相关的诡异现象,记住一句话:先看 DMA 通道的 EN 位和 NDTR,再看外设侧对应的 DMA 请求使能位,两边的状态必须对齐,否则一定会在某个时刻撞出你意想不到的 bug。

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

相关文章:

  • Nginx如何成为智能电网与可再生能源能效优化的秘密武器?
  • 具身智能学习路线:从机械臂到机器狗的ROS2全栈实战指南
  • STM32+KSZ8863调试实录:RMII接口Link不上的排查与解决
  • 数据中心电池容量计算与造价清单:避免项目延期取消的关键
  • 基于SpringBoot的问卷调查管理系统(毕设源码+文档)
  • 虚实共生态势推演:实现野外驻训从被动观测到主动预判的技术升级
  • Thomas Wolf警示AI权力集中,开源模型本地部署如何破局?
  • Eclipse JEE版文件名解析与JVM启动配置指南
  • 系统化架构设计:从个人经验到可复用的技能闭环
  • AI风险治理实战:从安全评测到可信落地,守护技术价值
  • 五月前端面试复盘:Vue3原理、性能优化与系统设计题全解析
  • 水质砷超标133倍背后:检测标准、形态分析与质控全解读
  • STM32与CC1125低功耗组合:GPIO引脚状态导致漏电的排查与解决
  • Debian与LLM:许可证争议、打包规则与AI工具链实践
  • 银行信用卡风险评估模型设计与落地实践
  • 基于Python的面试题解析源码:从文本清洗到考点提取全实现
  • LangGraph核心模型与实战:从条件路由到并行分支的Agent状态机设计
  • 壹品慧优选品控到底怎么样?从选品、供应链到售后,深度拆解这个厨房专家的品控体系
  • 2026大模型商业化加速:从API选型到Agent架构的技术应对
  • Cursor Review 深度实测:AI 代码审查能否阻止劣质化
  • 德州空调维修正规服务怎么选?欧米到家全区域及代码故障检修
  • PyTorch入门:从张量计算到模型部署的完整链路
  • 基于RAG与知识图谱的AI医疗问诊平台系统搭建指南
  • 无屏AI硬件重构交互入口:从语音交互到端侧部署,开发者如何提前卡位
  • DeepSeek V4 Flash 接入 Codex CLI 完整配置教程
  • STM32MP257 SPI3从机NSS引脚claim失败排查与设备树配置
  • React面试八股文:组件化、Hooks与渲染机制核心解析
  • 企业文件管理进阶:自动化任务与版本同步实战
  • 百度2016研发工程师笔试题复盘:覆盖算法、OS与C++核心考点
  • STM32CubeIDE工程转VS Code:启动文件.s缺失导致链接失败的排查与修复