最近调一套基于 STM32 的环境监测终端,USART2 走中断接收,115200 波特率,上位机每 100ms 发一帧 32 字节的报文。功能验证阶段一切正常,结果一到持续跑联调测试,串口就出幺蛾子:板子接收大概两三分钟后突然“装死”,上位机显示数据一直在发,但板子就是不回应,也不执行任何收到数据的动作。重启又能恢复正常,过一会儿又死。调过 STM32 串口的人应该都有这种条件反射,第一反应就是硬件问题,但我换了线、换了 USB 转串口、甚至把波特率降到 9600,故障模式一模一样。直到我扒开 HAL 库源码,才明白问题根本不在串口外设本身,而在 HAL_UART_ErrorCallback 回调里的一个“锁”上。这篇就把这个坑完整复盘一遍,包括现象、根因、两种修法和一个最容易被忽视的溢出错误机制。
1. 现象与现场还原:串口接收卡死的那一刻发生了什么
先交代一下现场配置,后面所有分析都基于这套环境。
| 项目 | 配置 |
|---|---|
| MCU | STM32F407VET6 |
| 串口 | USART2,PA2/PA3,115200-8-N-1 |
| 接收方式 | HAL_UART_Receive_IT 多字节接收 |
| 发送端 | 串口助手,周期发送 |
| 开发环境 | STM32CubeIDE + CubeMX 生成 |
1.1 三种必现场景
这个故障不是随机发生的,我后来复现了很多次,基本可以归纳成三类场景:
- 高频连续发送:上位机以 50ms 一帧的速率连续发,大概 2~3 分钟后必然卡死。
- 一次性粘大量数据:比如把 4KB 十六进制数据一次性粘进串口助手发送框,点发送后 1 秒内接收就会停。
- 发送端上电瞬间:USB 转串口模块刚插入电脑、发送端初始化的那几百毫秒,偶发卡死,概率不高但真实存在。
三种场景的共同点是:串口 RX 线上都有过“瞬时高频数据”或“异常电平”,都可能在 HAL 库里触发错误标志。
1.2 硬件和数据链路排查后剩下的疑点
遇到这种问题,常规排查必须是先外后内。我第一步用示波器抓了 TX 和 RX 波形,数据完全正常,电平也干净,没有毛刺;第二步换了一块同型号的新板子,故障依旧;第三步把接收方式从中断接收临时改成 Polling 轮询接收,同样的发送数据跑一晚上都没死。到这里基本可以排除硬件链路问题,问题收敛在中断接收路径上。
这时候很容易怀疑是不是自己的接收缓冲处理太慢导致数据覆盖,但看了代码发现接收逻辑很简单,回调里只是把数据拷进协议解析缓冲区,然后重新调用 HAL_UART_Receive_IT,理论上不会耗时太长。那问题就只剩一种可能:HAL 库自身的中断接收机制在某些错误场景下没有被正确恢复。
2. 解剖 HAL 库:错误中断里 lock 到底锁到什么时候
要理解这个坑,必须仔细看 HAL 库中断接收的完整处理链路,尤其是错误分支的执行顺序。我一开始也没想到问题会藏在锁机制里,直到把源码一行行读完才彻底明白。
2.1 HAL_UART_IRQHandler 的错误分支执行顺序
STM32 的串口中断统一入口是 HAL_UART_IRQHandler,正常接收和错误接收都会进这个函数。简化后的执行逻辑是这样的:
void HAL_UART_IRQHandler(UART_HandleTypeDef *huart) { uint32_t isrflags = READ_REG(huart->Instance->SR); uint32_t cr1its = READ_REG(huart->Instance->CR1); uint32_t errorflags = 0x00U; /* 正常接收:RXNE 置位且有接收中断使能 */ if (((isrflags & USART_SR_RXNE) != 0U) && ((cr1its & USART_CR1_RXNEIE) != 0U)) { UART_Receive_IT(huart); return; } /* 错误分支:PE/FE/NE/ORE 任何一个置位都会进来 */ errorflags = (isrflags & (uint32_t)(USART_SR_PE | USART_SR_FE | USART_SR_ORE | USART_SR_NE)); if (errorflags != 0U) { huart->ErrorCode |= errorflags; UART_Error_IT(huart); } }注意这个顺序:正常接收优先,错误判断在后。如果发生了溢出错误(ORE),下一次中断进来时 SR 的 RXNE 仍然可能是 0,但 ORE 置 1,就会进入错误分支。问题就出在这个 UART_Error_IT 内部。
2.2 UART_Error_IT 的 LOCK 与 UNLOCK 不对称
看 UART_Error_IT 的源码,这是整个坑的核心:
static void UART_Error_IT(UART_HandleTypeDef *huart) { /* 进入错误处理时,先给句柄上锁 */ __HAL_LOCK(huart); /* 停止当前接收传输,关闭接收相关中断 */ UART_EndRxTransfer(huart); /* 记录错误码 */ huart->ErrorCode |= HAL_UART_ERROR_...; /* 调用用户回调 */ HAL_UART_ErrorCallback(huart); /* 回调返回之后,才释放锁 */ __HAL_UNLOCK(huart); }关键点在于:__HAL_LOCK 是在 HAL_UART_ErrorCallback 回调之前执行的,而 __HAL_UNLOCK 是在回调返回之后才执行的。也就是说,当你的用户回调被调用时,串口句柄仍然处于锁定状态。
再看 UART_EndRxTransfer 做了什么:
static void UART_EndRxTransfer(UART_HandleTypeDef *huart) { /* 关闭 RXNE、PE、ERR 中断 */ CLEAR_BIT(huart->Instance->CR1, (USART_CR1_RXNEIE | USART_CR1_PEIE | USART_CR1_ERR_IE)); /* 接收状态恢复为 READY */ huart->RxState = HAL_UART_STATE_READY; }这才是“中断接收废了”的直接原因:HAL 库在检测到错误后主动关闭了 RXNE 中断。如果错误回调里不重新调用 HAL_UART_Receive_IT 把中断打开,接收就永远停了。
2.3 为什么直接调用 HAL_UART_Receive_IT 会碰壁
既然知道错误回调里要重新调用 HAL_UART_Receive_IT,那直接写进回调不就行了吗?很多人的第一版代码都是这样写的:
void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) { HAL_UART_Receive_IT(huart, rx_buf, RX_BUF_SIZE); }但实测会发现,这个调用根本没有效果。原因就在 __HAL_LOCK 机制上。
__HAL_LOCK 这个宏在新版 HAL 库里的实现大概是:
#define __HAL_LOCK(__HANDLE__) \ do { \ if ((__HANDLE__)->Lock == HAL_LOCKED) { \ return HAL_BUSY; \ } else { \ (__HANDLE__)->Lock = HAL_LOCKED; \ } \ } while (0)对应的 __HAL_UNLOCK:
#define __HAL_UNLOCK(__HANDLE__) \ do { \ (__HANDLE__)->Lock = HAL_UNLOCKED; \ } while (0)而 HAL_UART_Receive_IT 这个 API 函数体里第一步就会做 Lock 检查,大概是:
HAL_StatusTypeDef HAL_UART_Receive_IT(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size) { /* 如果句柄被锁,直接返回 BUSY,不执行任何操作 */ if (huart->Lock == HAL_LOCKED) { return HAL_BUSY; } if (huart->RxState == HAL_UART_STATE_READY) { /* 配置接收缓冲区、接收长度、开启 RXNE 中断 */ ... } }所以当你在 HAL_UART_ErrorCallback 里调用 HAL_UART_Receive_IT 时,外层 UART_Error_IT 已经把锁拿住了,API 检测到 Lock 不是 HAL_UNLOCKED,直接返回 HAL_BUSY,接收缓冲配置、RXNE 中断使能全部没有执行。等回调返回后,HAL 库虽然释放了锁,但重新开接收的最佳时机已经错过了。
这里要补充一句:不同 HAL 库版本对 Lock 的处理有差异。早期 STM32F1 的 HAL 库版本里 HAL_UART_Receive_IT 不一定检查 Lock,所以在老库里这种写法可能“碰巧能跑”。但现在 CubeMX 默认拉取的新版 HAL 库(H7、G0、F4 更新版本)基本都加了 Lock 保护,这就是为什么网上搜这个问题,有人说不解锁能跑,有人说不能,两边吵得不可开交——因为两边用的库版本根本不一样。
3. 复现与定位:我是怎么一步步确认是这个坑的
这一节把完整的排查链路写出来,不是为了凑篇幅,而是因为这种问题如果不按步骤走,很容易被表面现象带偏,浪费时间在错误方向。
3.1 调试器挂上后看到了什么
板卡出现“装死”后不要急着重启,直接把调试器连上,查看 huart 句柄的内部状态。我这边看到的关键数据是:
| 参数 | 数值 | 含义 |
|---|---|---|
| huart->ErrorCode | 0x08 | HAL_UART_ERROR_ORE,溢出错误 |
| huart->RxState | HAL_UART_STATE_READY | 接收状态已经回到就绪 |
| huart->Lock | HAL_LOCKED | 句柄处于锁定状态 |
| USART2->CR1 的 RXNEIE | 0 | RXNE 中断被关闭 |
这几条信息放在一起,基本可以锁定问题链路:发生了溢出错误,HAL 库进入了 UART_Error_IT,关闭了 RXNE 中断,回调里尝试重开接收但被 Lock 挡住,最终 RXNE 中断保持关闭状态,接收彻底停摆。
3.2 单步进入 UART_Error_IT 内部确认
为了确认不是自己的误判,我当时在 HAL_UART_ErrorCallback 入口打了断点,等故障复现后断下来,然后 F11 单步进 HAL_UART_Receive_IT,发现函数第一行就判断 Lock == HAL_LOCKED,直接返回 HAL_BUSY。继续单步执行,函数立即返回,没有执行任何接收配置代码。这个现象和我上面分析的完全一致。
另外我还验证了一件事:如果把 HAL_UART_Receive_IT 的调用从回调里挪到主循环的某个按键事件里,按下按键时接收立刻恢复。这说明外设本身没有损坏,只要正确调用 HAL_UART_Receive_IT,硬件还是能正常工作的,问题纯粹出在调用时机上。
3.3 最容易误导人的两个假象
排查过程中有两个假象浪费了我不少时间,这里单独提一下。
第一个假象是“RxState 已经是 READY,为什么还不能重开接收”。很多人看到 RxState 是 HAL_UART_STATE_READY,就以为接收状态已经复位,直接调用 HAL_UART_Receive_IT 应该没问题。但实际上阻止调用的是 Lock,不是 RxState。这是两个独立的状态变量,RxState 管“接收状态机”,Lock 管“API 互斥访问”,不能混为一谈。
第二个假象是“错误回调应该会反复执行,为什么只执行一次”。我一开始以为回调里重开接收失败后,错误中断还会反复进来,结果发现根本不是。因为 UART_EndRxTransfer 把 RXNEIE 和 ERR 中断都关了,错误中断只触发一次,回调也只执行一次。错过这一次机会,后面就彻底静默了。
4. 最直接的解法:先解锁再重启接收
如果是裸机项目,没有复杂的多任务并发,最快的止血方案就是在 HAL_UART_ErrorCallback 里先调用 __HAL_UNLOCK 解锁,再调用 HAL_UART_Receive_IT 重启接收。这个方案我实测有效,而且代码量最小。
4.1 带 ErrorCode 清理的最小可运行写法
uint8_t rx1_buf[32]; void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART2) { /* 清掉错误标志,避免重启后瞬间再次触发错误中断 */ __HAL_UART_CLEAR_OREFLAG(huart); __HAL_UART_CLEAR_FEFLAG(huart); __HAL_UART_CLEAR_NEFLAG(huart); huart->ErrorCode = HAL_UART_ERROR_NONE; /* 关键:先解除 HAL 库在错误中断里持有的句柄锁 */ __HAL_UNLOCK(huart); /* 重新启动中断接收 */ if (HAL_UART_Receive_IT(huart, rx1_buf, sizeof(rx1_buf)) != HAL_OK) { /* 重启失败要记录下来,方便后续定位 */ restart_fail_cnt++; } } }这里有几个细节要解释清楚。
第一,为什么要清 ORE 标志。STM32 的 ORE 标志不会因为关闭中断而自动消失,如果在重新使能 RXNE 中断之前不清掉它,中断标志位还是会一直挂着,导致中断响应之后又立刻进入错误分支,形成死循环式的错误触发。HAL_UART_Receive_IT 内部有些版本也会清 ORE,但双保险总比踩坑好。
第二,为什么先清 ErrorCode。HAL_UART_Receive_IT 执行成功后会把 ErrorCode 复位为 NONE,但如果我们不手动清一次,在重启之前如果有什么逻辑在读 ErrorCode,可能会误判上一次的错误仍然生效。养成回调里手动清的习惯,状态更干净。
第三,__HAL_UNLOCK 放在 _HAL_UART_CLEAR* 之后、HAL_UART_Receive_IT 之前,顺序不要反过来。如果先调 HAL_UART_Receive_IT 再解锁,那 API 依然会被 Lock 挡住,前功尽弃。
4.2 多串口场景下回调怎么区分处理
很多板子不止一个串口,HAL_UART_ErrorCallback 是所有串口共用的回调原型,需要自己区分是哪个串口触发的。推荐用 huart->Instance 判断,而不是直接比较 huart 指针:
void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART2) { __HAL_UNLOCK(huart); HAL_UART_Receive_IT(huart, rx2_buf, RX2_LEN); } else if (huart->Instance == USART3) { __HAL_UNLOCK(huart); HAL_UART_Receive_IT(huart, rx3_buf, RX3_LEN); } }用 Instance 判断的好处是即使你的代码里 huart 句柄因为某些移植原因换了变量名,Instance 字段始终不会变。如果用指针比较,一旦你在别的文件里重新定义了一个等价的句柄,或者把句柄拷贝了一份,地址对不上,就会出现“回调被调用但什么都没执行”的隐藏问题。
4.3 直接解锁的风险边界
先解锁这个方法虽然快,但不是什么场景都能用。我总结了几条边界条件:
只解自己的锁。在某个串口的错误回调里,只解锁这个串口自己的句柄,不要顺手去解别的串口的锁。每个串口的 Lock 是独立的,跨串口解锁会导致另一个串口的 API 在不知情的情况下被重入,很难排查。
RTOS 环境要特别小心。如果项目里跑了 FreeRTOS 之类,串口操作可能同时被多个任务调用,__HAL_LOCK 本身就是互斥访问的防线。这种场景下强行在中断里解锁,可能导致另一个任务的 HAL_UART_Receive_IT 在错误状态下进入临界区。RTOS 项目我建议直接用下一节的标志位方案,不要用先解锁方案。
不要在错误回调里调用阻塞函数。比如用同一个串口执行 printf 或者 HAL_UART_Transmit_IT 发送长数据,在 Lock 未释放前,这些 API 同样会被锁挡住,或者因为等待发送完成而卡死中断,导致整个系统响应崩掉。
5. 更稳的做法:回调只挂标记,主循环和定时器里重启
如果你觉得在中断回调里做解锁操作太粗糙,或者项目里跑着 RTOS,我更推荐第二种方案:错误回调里只设置一个标志位,所有的恢复逻辑放到主循环或定时器任务里执行。这个方案看起来多了一步,但解耦效果很好,也是我后来在正式项目里一直沿用的写法。
5.1 标志位方案代码
volatile uint8_t uart2_err_flag = 0; void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART2) { uart2_err_flag = 1; huart->ErrorCode = HAL_UART_ERROR_NONE; /* 注意:这里不做任何 HAL_API 调用,让中断赶紧返回 */ } } void UART2_RecoveryProcess(void) { if (uart2_err_flag) { uart2_err_flag = 0; /* 错误中断此时已经返回,HAL 库已经释放锁 */ /* 这里调用 API 是安全的 */ if (HAL_UART_Receive_IT(&huart2, rx2_buf, RX2_LEN) != HAL_OK) { /* 重启失败,记录错误次数 */ uart2_restart_fail_cnt++; } } }主循环里调用:
while (1) { UART2_RecoveryProcess(); // 其他任务 }这个方案最核心的思路是:不在中断上下文里做任何多余操作,只把需求登记下来,回到主循环后再执行。HAL_UART_ErrorCallback 里哪怕只调一个 __HAL_UNLOCK,严格来说也是在中断上下文执行宏操作,虽然很快,但终究不如直接返回干净。
5.2 为什么推荐这个方案用于正式项目
我后来把环境监测终端的代码改成标志位方案后,连续跑了 72 小时压力测试,中间人为制造了十几次溢出错误,每次都能在下一个主循环周期内恢复接收,协议解析完全没有乱掉。这个方案的优点体现在几个方面:
第一,中断上下文的操作量被压到最小。HAL_UART_ErrorCallback 只做置标志和清错误码两件事,不会因为 HAL_API 内部的临界区保护逻辑引发额外问题。
第二,恢复时机可控。主循环里的调用是在整个中断流程结束后执行的,对 Lock 的状态判断绝对准确,不存在“锁到底释没释放”的模糊地带。
第三,方便接状态机。如果项目里有协议状态机,可以把错误恢复当成一个事件抛给状态机处理,让状态机决定是直接重启接收还是先重新初始化整条串口链路,灵活性高很多。
5.3 DMA 模式的错误恢复差异
再补充一个 DMA 接收的错误恢复写法。这种场景比中断接收复杂,因为 DMA 通道的状态也要处理。
volatile uint8_t uart2_dma_err_flag = 0; void HAL_UART_ErrorCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART2) { uart2_dma_err_flag = 1; } } void UART2_DMA_RecoveryProcess(void) { if (uart2_dma_err_flag) { uart2_dma_err_flag = 0; /* 先停掉 DMA,再重新启动 DMA 接收 */ __HAL_DMA_DISABLE(huart2.hdmarx); HAL_UART_DMAStop(&huart2); /* 重新启动时,缓冲区从头开始,长度重新设置 */ HAL_UART_Receive_DMA(&huart2, rx2_dma_buf, RX2_DMA_LEN); } }这里有个坑:HAL_UART_DMAStop 和 HAL_UART_Receive_DMA 内部都会有 Lock 检查,还有 DMA 通道状态检查和互斥操作,所以这两步一定不要放在错误回调里直接执行,否则同样会被锁挡住。放在主循环里执行则完全没这个问题。另外 __HAL_DMA_DISABLE 要放在 HAL_UART_DMAStop 之前,先把 DMA 通道停下来,避免数据还在搬运过程中被重复配置。
6. 别让 ORE 反复咬你:溢出错误的源头与预防
最后说回溢出错本身。很多时候我们关注怎么恢复接收,但更重要的问题是怎么让它不发生。ORE(Overrun Error,溢出错误)是这几个错误里最常见的,搞清楚它的硬件机理,才能从根本上减少踩坑频率。
6.1 ORE 的硬件机制
STM32 的串口接收硬件链路是这样的:数据由 RX 引脚进入移位寄存器,逐位接收完成后,整字节被搬到数据寄存器 RDR,硬件同时把 RXNE 标志置 1。软件需要读 RDR 来清掉 RXNE。如果 RXNE 还是 1 的时候,下一帧数据又完成了移位接收,硬件没有地方放新数据,就会把 ORE 标志置 1,同时新数据被丢弃。
翻译成人话就是:串口硬件没有缓冲队列,软件来不及取走数据,下一字节就直接把上一字节顶掉,并产生一个溢出错误。中断接收模式下这个问题更容易出现,因为每一字节都需要中断介入,如果中断响应不及时,数据就在不知不觉中丢了。
6.2 实际项目里最常触发 ORE 的四个诱因
根据我自己的调试经历,触发 ORE 的场景通常逃不出下面这几种:
串口中断被更高优先级的中断长时间抢占。比如项目里有个定时器中断在做高频率的 ADC 采样,处理函数耗时几百微秒,如果串口中断优先级低于它,串口数据就只能干等。
接收回调里做了重活。很多新手喜欢在 HAL_UART_RxCpltCallback 里直接调用 printf 打印日志,或者做协议解析、Flash 写入等耗时操作。115200 波特率下,一个字节的接收间隔大概 69 微秒,回调里稍一耽搁,下一字节就溢出。
在线调试时打断点卡住了现场。这个因素很容易误判,你断点停下来看寄存器时,程序其实已经停止响应了,串口数据源源不断进来,自然会溢出。所以在线调试看到 ORE 时要冷静,先确认是不是因为断点导致的假象。
单字节中断收发高波特率数据。如果主频不高,比如 72MHz 甚至更低,跑 460800 以上的高波特率,每秒中断次数几十万次,加上中断函数本身的上下文切换开销,很容易应接不暇。
6.3 把异常率降到最低的实操建议
| 手段 | 说明 |
|---|---|
| 串口中断优先级调高 | NVIC 里把 USART 中断抢占优先级设置为最高等级,至少比其他非关键中断高 |
| 接收回调轻量化 | 回调里只做数据搬运和置标志,协议解析扔给主循环或低优先级任务 |
| 优先用 DMA + IDLE 接收 | DMA 搬运数据不消耗 CPU 时间,空闲中断检测帧结束,能大幅降低溢出概率 |
| 保证 MCU 主频余量 | 115200 波特率建议主频不低于 64MHz,否则中断处理时间占比太高 |
| 在线调试时适度关断数据流 | 调试中断点前先暂停发送端,或者用等待断点触发的方式代替全速运行打断点 |
关于 DMA 接收多说两句。DMA + IDLE 是我现在做串口接收的标准姿势,尤其适合不定长帧协议。DMA 把数据从 RDR 搬到内存缓冲区,完全不需要 CPU 逐字节介入,即使 CPU 正在处理其他中断,数据也能完整搬运,ORE 的发生概率会指数级下降。但 DMA 模式下也会有溢出错误的可能,处理思路和上面代码一样,错误回调置标志,主循环统一恢复。
最后分享一个排查技巧:以后再遇到“串口中断接收跑着跑着就没了”的问题,先看 huart 句柄里的 ErrorCode 和 Lock,再去改回调逻辑。ErrorCode 能告诉你硬件层发生了什么,Lock 能告诉你程序层卡在了哪里。先解锁再重启接收是止血方案,标志位延迟重启是根治方案,两者结合才是长期可靠的做法。希望这篇复盘能帮你少走我当初那一整天的弯路。