1. 串口DMA接收踩坑背景与问题定位
1.1 为什么串口DMA接收总在“莫名其妙”出错
STM32F103 这颗芯片在工控、传感器采集、通信网关里出镜率极高,HAL 库又把 UART 的初始化门槛压得很低,CubeMX 点几下就能生成一套“看起来能用”的串口 DMA 接收代码。但真正把它放到连续数据流、多帧拼接、波特率偏高或者线缆较长的现场环境里,很多人会碰到同一个现象:程序跑着跑着,接收缓冲里的数据开始错位,或者 DMA 传输直接停摆,调试器里一看状态寄存器,UART_FLAG_FE(Framing Error,帧错误)和UART_FLAG_NE(Noise Error,噪声错误)被置起来了。
这两个标志位不是“配置错了”这么简单。FE 表示接收到的停止位不符合预期,NE 表示采样时检测到线路上的噪声。它们一旦出现,HAL 库默认的中断处理路径会把错误标志清掉,但不会自动帮你恢复 DMA 接收链路,于是你看到的就是“串口死了”。更麻烦的是,很多人只盯着HAL_UART_Receive_DMA的返回值,却忽略了错误回调HAL_UART_ErrorCallback,导致问题被掩盖。
我先把结论摆在这里:FE 和 NE 本身是物理层和时序层面的告警,真正让系统崩溃的是错误发生后 DMA 通道与 UART 状态机没有同步复位。这篇内容就是围绕这个核心,把从现象到根因、从配置到代码、从排查到恢复的完整链路讲透。适合正在用 STM32F103 + HAL 库做串口 DMA 接收的嵌入式开发者,尤其是那些已经能跑通基础收发、但被偶发错误卡住的人。
1.2 FE 与 NE 到底在什么条件下被触发
要解决问题,先得知道这两个标志是怎么来的。STM32F103 的 USART 接收是一个逐位采样的过程,默认采用 16 倍过采样,在停止位附近进行多次采样投票。如果采样结果和预期电平不一致,硬件就会置位相应错误。
| 标志位 | 全称 | 触发条件 | 典型诱因 |
|---|---|---|---|
| FE | Framing Error | 停止位采样为无效电平 | 波特率偏差过大、时钟配置错误、对方发送格式不匹配 |
| NE | Noise Error | 采样窗口内电平抖动 | 线缆过长、共模干扰、地线环路、附近有电机或继电器 |
| ORE | Overrun Error | 数据未及时读走 | DMA 未启动、中断被长时间关闭、处理耗时过长 |
| PE | Parity Error | 校验位不匹配 | 校验配置不一致、干扰 |
这里有个容易被忽略的点:FE 和 NE 经常和 ORE 一起出现。因为一旦发生帧错误,接收移位寄存器里的数据可能被丢弃,DMA 请求次数和实际字节数对不上,后续就更容易溢出。所以排查时不能只盯一个标志,要把ISR里的错误位整体读出来看。
注意:FE 并不一定意味着你的代码有问题。现场环境中,对方设备上电瞬间、热插拔、继电器动作,都可能产生一帧畸形数据。关键是系统要能“扛住”这一帧,而不是从此罢工。
1.3 一个真实的最小复现场景
我用 STM32F103C8T6 最小系统板做过一个复现实验:USART1 配置为 115200、8N1,开启 DMA1 Channel5 循环接收,缓冲区 64 字节。对端用一块普通的 USB 转串口模块,先正常发数据,然后在发送过程中快速拔插 TX 线。结果很稳定地复现了问题:拔插瞬间产生噪声,NE 置位,HAL 库进入错误回调,DMA 通道被关闭,之后即使线路恢复,huart1.RxState也停在HAL_UART_STATE_BUSY_RX,再也收不到数据。
这个实验说明两件事:第一,FE/NE 在真实场景里几乎不可避免;第二,默认 HAL 库的错误处理不足以让 DMA 接收自愈。后面的章节就围绕“如何让它自愈”展开。
2. HAL 库串口 DMA 接收的机制拆解
2.1 HAL 库接收状态机与 DMA 的协作方式
HAL 库把 UART 的接收抽象成一个状态机,核心变量是huart->RxState。调用HAL_UART_Receive_DMA后,状态从READY变成BUSY_RX,同时启动 DMA 通道,并使能USART_IT_IDLE或USART_IT_RXNE(取决于配置)。数据由 DMA 直接搬到内存,CPU 不参与每个字节的搬运。
这个设计的优点是效率高,缺点是状态机对错误的感知是滞后的。当 FE/NE 发生时,硬件置位错误标志,如果使能了USART_IT_ERR,就会进入USART1_IRQHandler,HAL 库的HAL_UART_IRQHandler会读取SR和DR,然后调用HAL_UART_ErrorCallback。问题在于,这个回调里 HAL 库只做了“关闭接收”的动作,并没有重新配置 DMA 和状态机。
具体来说,在HAL_UART_IRQHandler中,检测到错误后会执行类似逻辑:清除错误标志、调用错误回调、把RxState置回READY、关闭相关中断。但 DMA 通道的EN位可能还开着,或者已经被 HAL 关闭,而huart->hdmarx的计数器NDTR已经乱了。下一次你再调用接收函数时,DMA 的起始地址和剩余长度都不是你期望的值。
2.2 错误回调里 HAL 库到底做了什么
很多人以为HAL_UART_ErrorCallback是个“通知”,实际上它更像一个“事后现场”。我建议你在回调里把关键信息打印出来,包括huart->ErrorCode、huart->Instance->SR、huart->Instance->DR、hdmarx->Instance->NDTR。这样你能看到错误发生瞬间的真实状态。
ErrorCode的取值是位掩码,常见组合:
HAL_UART_ERROR_FE:帧错误HAL_UART_ERROR_NE:噪声错误HAL_UART_ERROR_ORE:溢出错误HAL_UART_ERROR_DMA:DMA 传输错误
如果看到HAL_UART_ERROR_DMA,说明 DMA 本身也出了问题,比如通道被意外关闭、传输完成中断和错误中断竞争。这种情况下,单纯重启 UART 不够,还要复位 DMA 通道。
实操心得:在错误回调里不要做耗时操作,比如打印一大串日志。回调运行在中断上下文,长时间占用会导致后续数据继续丢失。我的做法是只记录一个错误计数和最后一次的
SR值,主循环里再处理。
2.3 为什么循环模式和普通模式表现不同
CubeMX 里配置 DMA 接收时,有个Mode选项:Normal 和 Circular。Normal 模式下,DMA 搬完指定长度就停止,需要重新调用接收函数;Circular 模式下,DMA 搬完会自动回到起始地址继续搬,适合不定长连续数据。
但 Circular 模式在错误场景下更危险。因为 DMA 一直在跑,错误发生后NDTR可能已经绕了一圈,你以为数据在缓冲区头部,实际上写指针已经跑到别的位置。而且 Circular 模式下 HAL 库不会自动调用接收完成回调,你得靠 IDLE 中断来判断一帧结束。
我的建议是:如果数据帧长度固定,用 Normal 模式配合接收完成回调;如果长度不定,用 Circular 模式配合 IDLE 中断,但必须自己维护读写指针,并且在错误回调里强制复位指针和 DMA。两种模式没有绝对优劣,关键是错误恢复逻辑要匹配。
3. 彻底解决 FE 和 NE 的配置与代码方案
3.1 CubeMX 里必须检查的几个关键项
在动手写恢复代码之前,先把 CubeMX 的配置过一遍。很多 FE/NE 问题其实是配置埋下的隐患。
第一,时钟树。STM32F103 的 USART1 挂在 APB2 上,USART2/3 挂在 APB1 上。如果你用的是外部晶振,确认 HSE 频率和 PLL 配置正确。波特率寄存器BRR是根据PCLK算出来的,PCLK错了,波特率就偏,FE 必然出现。我见过有人把 8MHz 晶振配成 12MHz 的 PLL,串口能出数据但错误率极高。
第二,过采样模式。默认是 16 倍过采样,OVER8=0。在 115200 及以上波特率、且PCLK较低时,可以尝试 8 倍过采样,但会降低抗噪能力。一般不建议改。
第三,DMA 优先级和中断优先级。DMA 通道优先级要高于其他非关键 DMA,UART 全局中断优先级不要设得太低,否则 IDLE 中断响应不及时,容易 ORE。但也不要设得比系统滴答还高,避免影响其他时序。
第四,GPIO 配置。RX 引脚必须配成浮空输入或上拉输入,不能配成推挽输出。TX 配成复用推挽。如果 RX 上拉了,线缆断开时不会浮空,能减少一部分 NE。
3.2 错误回调里的标准恢复流程
下面是我实际项目里用的恢复函数,核心思路是:先停 DMA,再清 UART 错误标志,再复位状态机,最后重新启动接收。顺序不能乱,否则会出现“清了标志但 DMA 还在跑”的竞态。
void UART_DMA_Recover(UART_HandleTypeDef *huart) { /* 1. 停止当前 DMA 接收,关闭 DMA 通道 */ HAL_UART_DMAStop(huart); /* 2. 清除 UART 所有错误标志 */ __HAL_UART_CLEAR_PEFLAG(huart); __HAL_UART_CLEAR_FEFLAG(huart); __HAL_UART_CLEAR_NEFLAG(huart); __HAL_UART_CLEAR_OREFLAG(huart); /* 3. 复位接收状态机 */ huart->RxState = HAL_UART_STATE_READY; huart->ErrorCode = HAL_UART_ERROR_NONE; /* 4. 重新启动 DMA 接收 */ HAL_UART_Receive_DMA(huart, rx_buffer, RX_BUFFER_SIZE); }然后在HAL_UART_ErrorCallback里调用它:
void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { error_count++; last_error_code = huart->ErrorCode; UART_DMA_Recover(huart); } }这里有个细节:HAL_UART_DMAStop会关闭 DMA 通道并等待当前传输结束,但它不会清除 UART 的错误标志。所以第 2 步必须手动清。另外,__HAL_UART_CLEAR_FEFLAG这类宏在 F1 系列里是通过读SR再读DR实现的,顺序不能反。
注意:如果你的接收缓冲区正在被主循环读取,恢复时要考虑数据一致性。我的做法是给缓冲区加一个“帧有效”标志,恢复期间主循环跳过处理。
3.3 用 IDLE 中断配合 DMA 实现不定长接收
FE/NE 问题在不定长接收场景下更突出,因为你需要判断一帧什么时候结束。STM32F103 的 USART 支持 IDLE 中断:总线空闲一个字节时间后触发。配合 DMA Circular 模式,可以做到“DMA 一直搬,IDLE 来的时候算一帧”。
配置步骤:
- DMA 配置为 Circular,外设到内存,字节对齐,缓冲区大小设为最大帧长的 2 倍。
- 使能
USART_IT_IDLE。 - 在
USART1_IRQHandler里判断 IDLE 标志,计算当前写指针位置。
计算写指针的公式:
uint16_t current_pos = RX_BUFFER_SIZE - __HAL_DMA_GET_COUNTER(huart->hdmarx);如果current_pos和上次记录的位置不同,说明有新数据。但 Circular 模式下,如果 DMA 绕圈了,current_pos可能小于上次位置,需要处理回绕。
void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); uint16_t pos = RX_BUFFER_SIZE - __HAL_DMA_GET_COUNTER(huart1.hdmarx); if (pos != last_pos) { /* 处理从 last_pos 到 pos 的数据 */ process_frame(last_pos, pos); last_pos = pos; } } HAL_UART_IRQHandler(&huart1); }这个方案的关键是:IDLE 中断里不要做耗时处理,只记录位置和置标志,主循环再解析。否则下一次 IDLE 来的时候你还在处理上一帧,数据就丢了。
3.4 硬件层面的抗干扰补充
软件恢复能解决“死了能活”,但减少 FE/NE 的发生率还得靠硬件。我在现场项目里总结了几条:
- 缩短线缆:串口线超过 1 米,NE 概率明显上升。如果必须长距离,改用 RS485 差分。
- 加共模电感:在 RX/TX 上串小磁珠,对高频噪声有效。
- 地线处理:两块板子共地不良是 NE 的大头。确保地线足够粗,或者用光耦隔离。
- 上拉电阻:RX 上加 10k 上拉到 3.3V,线缆悬空时不会浮空。
- 避开干扰源:继电器、电机驱动线不要和串口线平行走。
这些不是代码能解决的,但能显著降低错误回调的触发频率。我的经验是,硬件处理好之后,FE/NE 从“每分钟几次”降到“几小时一次”,软件恢复的压力小很多。
4. 常见问题排查与实战避坑记录
4.1 错误回调不触发是怎么回事
有人反馈说串口收不到数据,但HAL_UART_ErrorCallback根本没进。这种情况通常是USART_IT_ERR没有使能。HAL 库在HAL_UART_Receive_DMA里默认只使能了USART_IT_IDLE或RXNE,错误中断需要额外配置。
检查huart->Instance->CR3的EIE位,以及CR1的PEIE、RXNEIE。如果用的是 CubeMX 生成的代码,确认 NVIC 里 USART1 全局中断已使能。另外,HAL_UART_IRQHandler只有在SR里检测到错误标志且对应中断使能时才会调用错误回调。
实操心得:我习惯在初始化后手动加一句
__HAL_UART_ENABLE_IT(&huart1, UART_IT_ERR);,确保错误中断一定开着。这行代码在 CubeMX 重新生成后可能被覆盖,所以放在MX_USART1_UART_Init之后的自定义初始化里。
4.2 DMA 接收一段时间后停止的排查顺序
这个问题我遇到过至少五次,每次原因都不一样。整理成一个排查表:
| 现象 | 可能原因 | 排查方法 | 解决 |
|---|---|---|---|
| 接收几十字节后停 | ORE 溢出 | 看SR的 ORE 位 | 提高处理速度,用 DMA 减轻 CPU |
| 偶发停止,错误回调进 | FE/NE 后未恢复 | 看ErrorCode | 加恢复函数 |
| 完全收不到 | DMA 通道未使能 | 看DMA_CCR的 EN 位 | 检查HAL_UART_Receive_DMA返回值 |
| 收到固定长度后停 | Normal 模式未重启 | 看 DMA 模式 | 改 Circular 或回调里重启 |
| 停止后重启无效 | 状态机卡在 BUSY | 看RxState | 强制置 READY 再启动 |
排查时建议用调试器实时看huart1.RxState、huart1.ErrorCode、hdmarx->Instance->NDTR这三个值。NDTR不变说明 DMA 没在搬;RxState不是BUSY_RX说明 HAL 层认为接收已结束。
4.3 一个容易被忽略的坑:DMA 传输完成中断与错误中断竞争
当 DMA 传输完成和 UART 错误同时发生时,两个中断可能竞争。HAL 库的HAL_UART_IRQHandler和 DMA 的XferCpltCallback都会操作RxState。如果错误回调里重启了接收,而 DMA 完成回调又进来把状态改了,就会出现“重启后立刻又停”的怪象。
解决办法是加一个简单的互斥标志:
volatile uint8_t uart_recovering = 0; void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) { uart_recovering = 1; UART_DMA_Recover(huart); uart_recovering = 0; } void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (uart_recovering) return; /* 正常处理 */ }这个标志不需要原子操作,因为都在中断上下文,但要注意编译器优化,加volatile。
4.4 波特率偏差导致 FE 的计算与验证
如果 FE 是持续性的,而不是偶发,大概率是波特率偏差。STM32F103 的BRR计算:
// PCLK2 = 72MHz, 波特率 115200, OVER8=0 // USARTDIV = 72000000 / (16 * 115200) = 39.0625 // 整数部分 39 = 0x27, 小数部分 0.0625 * 16 = 1 // BRR = (39 << 4) | 1 = 0x271实际偏差 = (实际波特率 - 目标波特率) / 目标波特率。一般要求偏差小于 2%,最好小于 1%。如果PCLK不是 72MHz,比如 36MHz,USARTDIV = 19.53,小数部分取整后偏差会变大。这时候要么换晶振,要么降低波特率。
验证方法:用示波器测一个字节的位宽,或者让对方发已知数据,看接收到的字节是否稳定。如果偶尔错一个位,就是偏差或噪声。
4.5 中断优先级配置的实战建议
STM32F103 的 NVIC 有 4 位优先级,分为抢占优先级和子优先级。我的配置习惯:
- DMA 通道中断:抢占优先级 1
- USART 全局中断:抢占优先级 1
- SysTick:抢占优先级 0(最高)
- 其他外设:2 或更低
这样串口和 DMA 同级,不会互相打断,但都能被 SysTick 打断。如果串口中断里处理时间较长,可以适当提高 DMA 优先级,让数据搬运不被 UART 中断阻塞。
注意:不要把所有中断都设成最高优先级,否则会出现“高优先级中断里等低优先级中断”的死锁风险。HAL 库的
HAL_Delay依赖 SysTick,如果在高优先级中断里调用,会卡死。
5. 从最小系统到实际项目的落地经验
5.1 在 STM32F103C8T6 最小系统上的验证步骤
拿到一块最小系统板,按下面步骤验证恢复逻辑:
- 用 CubeMX 配置 USART1:115200、8N1、DMA 接收 Circular、开启 USART1 全局中断。
- 生成代码,加入
UART_DMA_Recover和错误回调。 - 主循环里每 500ms 通过 DMA 发送一串固定数据。
- 用 USB 转串口工具连接,正常收发确认通路。
- 在发送过程中拔掉 RX 线再插上,观察
error_count是否增加,以及数据是否恢复。 - 用逻辑分析仪抓 RX 线,确认拔插瞬间有噪声脉冲。
这个验证能覆盖大部分 FE/NE 场景。如果拔插后数据能自动恢复,说明恢复逻辑生效。
5.2 多串口同时使用时的资源分配
STM32F103 有 3 个 USART,如果同时用 DMA,要注意 DMA 通道冲突。USART1_RX 固定用 DMA1 Channel5,USART2_RX 用 Channel6,USART3_RX 用 Channel3。这些通道不能和其他外设共用,比如 SPI1_RX 也用 Channel2,TIM 也用 DMA 通道。
我的做法是画一张 DMA 通道分配表,把所有用到 DMA 的外设列出来,确认没有冲突。如果冲突,要么改用中断方式,要么换外设引脚。
| 外设 | 方向 | DMA 通道 |
|---|---|---|
| USART1_RX | 外设到内存 | DMA1 Channel5 |
| USART1_TX | 内存到外设 | DMA1 Channel4 |
| USART2_RX | 外设到内存 | DMA1 Channel6 |
| USART2_TX | 内存到外设 | DMA1 Channel7 |
| SPI1_RX | 外设到内存 | DMA1 Channel2 |
| SPI1_TX | 内存到外设 | DMA1 Channel3 |
如果 USART3_RX 和 SPI1_TX 都要用,就冲突了。这时候可以把 SPI1 改成中断方式,或者把 USART3 映射到其他引脚。
5.3 从 HAL 库移植到其他平台时的注意事项
热词里提到“stm32f103的hal库怎么移植到apm32上”,这其实是个常见需求。APM32 和 STM32F103 引脚兼容,但外设寄存器有差异。移植时重点检查:
- UART 的
SR和DR寄存器地址是否一致 - DMA 通道映射是否相同
- 错误标志的清除方式是否一样
我移植过一个项目,发现 APM32 的UART_FLAG_NE清除方式和 STM32 略有不同,需要多读一次DR。所以恢复函数里的清标志部分要针对平台调整。最稳妥的办法是查对应芯片的参考手册,确认标志清除序列。
5.4 长期运行下的错误统计与健康监控
在产品里,我建议加一个错误统计结构,记录 FE、NE、ORE 的次数和最后一次发生的时间戳。通过串口命令或者调试接口读出来,能判断现场环境是否恶劣。
typedef struct { uint32_t fe_count; uint32_t ne_count; uint32_t ore_count; uint32_t recover_count; uint32_t last_error_tick; } uart_health_t; uart_health_t uart1_health; void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) { if (huart->ErrorCode & HAL_UART_ERROR_FE) uart1_health.fe_count++; if (huart->ErrorCode & HAL_UART_ERROR_NE) uart1_health.ne_count++; if (huart->ErrorCode & HAL_UART_ERROR_ORE) uart1_health.ore_count++; uart1_health.recover_count++; uart1_health.last_error_tick = HAL_GetTick(); UART_DMA_Recover(huart); }如果recover_count增长很快,说明硬件环境需要改善,而不是继续优化软件。这个数据在客户现场排查时特别有用,能直接区分“代码问题”和“环境问题”。
5.5 一个完整的接收处理框架示例
最后给一个我实际项目里用的框架,把 DMA、IDLE、错误恢复、帧解析串起来:
#define RX_BUFFER_SIZE 256 uint8_t rx_buffer[RX_BUFFER_SIZE]; volatile uint16_t rx_last_pos = 0; volatile uint8_t rx_frame_ready = 0; void UART_Init_All(void) { HAL_UART_Receive_DMA(&huart1, rx_buffer, RX_BUFFER_SIZE); __HAL_UART_ENABLE_IT(&huart1, UART_IT_ERR); __HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE); } void USART1_IRQHandler(void) { if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_IDLE)) { __HAL_UART_CLEAR_IDLEFLAG(&huart1); uint16_t pos = RX_BUFFER_SIZE - __HAL_DMA_GET_COUNTER(huart1.hdmarx); if (pos != rx_last_pos) { rx_frame_ready = 1; } } HAL_UART_IRQHandler(&huart1); } void main_loop(void) { while (1) { if (rx_frame_ready) { rx_frame_ready = 0; uint16_t pos = RX_BUFFER_SIZE - __HAL_DMA_GET_COUNTER(huart1.hdmarx); /* 解析从 rx_last_pos 到 pos 的数据 */ parse_frame(rx_last_pos, pos); rx_last_pos = pos; } /* 其他任务 */ } }这个框架里,IDLE 中断只置标志,主循环解析,错误回调负责恢复。三者分工明确,不会互相阻塞。实际跑下来,115200 波特率连续接收,配合硬件抗干扰,可以做到几天不出现不可恢复的错误。
我在实际使用中发现,FE 和 NE 并不可怕,可怕的是错误发生后系统没有自愈能力。把恢复逻辑做扎实,再配合硬件上的小改进,STM32F103 的串口 DMA 接收完全可以做到工业级的稳定性。如果你正在被这两个标志位困扰,先别急着换芯片,把错误回调里的恢复流程按上面的顺序走一遍,大概率能解决问题。