这问题我有发言权。早前帮一个项目调 STM32H7B3I-EVAL 和 STM32H573I-DK 之间的 UART 通信,程序跑到HAL_Delay()就卡死,打开调试器一看,uwTick纹丝不动地躺在 0 上。一开始我也以为是延时函数本身的问题,搞了半天才发现坑埋在更深的地方。如果你也遇到HAL_Delay()无限阻塞,或者uwTick始终为 0,这篇排查记录应该能帮你省下好几个小时。
{% note warning %} 先说结论:HAL_Delay()卡死的根因基本不是HAL_Delay()本身,而是SysTick中断没有按预期工作,或者在中断上下文里让SysTick被更高的优先等级压住了。这类问题在串口通信场景里尤其容易踩中,因为HAL_UART_Transmit/HAL_UART_Receive的阻塞超时也依赖同一个uwTick。 {% endnote %}
1. 现象描述:程序像被点了暂停键
我这里复现的硬件环境很简单:STM32H7B3I-EVAL(板载 STM32H7B3LI 芯片)作为发送端,STM32H573I-DK(板载 STM32H573I 芯片)作为接收端,两边用 UART 直连,波特率 115200,8N1 格式。发送端的代码逻辑大概是发送前先调用HAL_Delay(10)做节奏控制,接收端收到数据后调用HAL_Delay(5)处理一下业务。
结果就是:程序在第一个HAL_Delay()调用处就再也没有返回,断点停在while循环里,HAL_GetTick()永远返回 0,uwTick也一直是 0。
1.1 先别急着怀疑 UART 配置
遇到这种问题,第一反应往往是查串口配置:波特率、引脚复用、时钟使能,挨个看一遍,发现都没问题。这时候更要冷静,因为问题很可能不在 UART 本身,而在“时间基准”。
我当时在HAL_Delay的while循环里打了一个断点,单步执行几次,发现HAL_GetTick()返回值没有变化。这个现象说明uwTick没有被更新,也就是SysTick_Handler要么没被调用,要么HAL_IncTick()没有执行。
1.2 先分清两种“卡死”
- 卡死在
HAL_UART_Transmit内部的等待标志位循环里,此时uwTick可能正常递增,只是发送没有完成。 - 卡死在
HAL_Delay的while循环里,且uwTick不变,这种情况几乎可以锁定是时间基准出了问题。
这两种卡死虽然表现相似,但排查方向完全不同。我的案例属于第二种,所以我把重心放到了SysTick和中断优先级上。
2. HAL_Delay 和 uwTick 到底是怎么协作的
在 HAL 库里,HAL_Delay并不是凭空计时,它依赖一个毫秒计数器uwTick。这个计数器默认由SysTick中断负责累加,而HAL_Delay只是不断读取这个计数器的值,判断是否到时。
__weak void HAL_Delay(uint32_t Delay) { uint32_t tickstart = HAL_GetTick(); uint32_t wait = Delay; if (wait < HAL_MAX_DELAY) { wait += (uint32_t)(uwTickFreq); } while ((HAL_GetTick() - tickstart) < wait) { } }HAL_GetTick()返回的就是全局变量uwTick。如果SysTick的中断没有触发,uwTick就会一直保持不变,HAL_Delay就会永远在while里死转。
2.1 SysTick 和 HAL 的关系
HAL_Init()内部会调用HAL_InitTick(),把SysTick配置成 1ms 中断一次,并注册中断服务函数。正常情况下,SysTick_Handler会被定义为:
void SysTick_Handler(void) { HAL_IncTick(); }每次中断把uwTick加 1。看起来很简单,但有一个关键点常被忽略:SysTick的中断优先级由HAL_InitTick设置,默认是TICK_INT_PRIORITY,这个值在 STM32H7 系列上通常在 CubeMX 生成的代码里被定义为0x0F,也就是系统里比较低的优先级。
2.2 为什么 uwTick 为 0 会引发连锁问题
uwTick不只是给HAL_Delay用,很多 HAL 阻塞函数的超时判断也靠它。比如HAL_UART_Transmit的超时参数Timeout,实际是HAL_GetTick()计算的。一旦uwTick停住,这些超时也全部失效,程序就可能永久阻塞在等待标志位的循环里。
更麻烦的是,这种问题有时候不会马上暴露。如果你在初始化阶段调用HAL_Delay,恰好SysTick还没配置好,可能一开机就卡死;如果系统跑了几分钟后才卡,那就更倾向于优先级抢占或者中断服务函数被覆盖。
2.3 优先级是绕不开的坎
Cortex-M 内核的中断优先级是数值越小优先级越高。SysTick的优先级如果比其他中断低,它就不能打断正在执行的其他中断。假设你在 UART 接收中断里调用HAL_Delay,而SysTick优先级比 UART 中断低,那么SysTick中断无法抢占当前 UART 中断,uwTick就不会增加,HAL_Delay就在中断里死等。
这个场景非常经典,也是 UART 通信里最常见的 HAL_Delay 卡死原因之一。很多新人在 UART 回调函数里加延时,一加就出问题。
3. 逐层排查:为什么 uwTick 始终是 0
当时为了让发送端节奏稳定,我在发送循环里用了HAL_Delay,代码结构大概是这样的:
while (1) { uint8_t data = 0xAA; HAL_UART_Transmit(&huart1, &data, 1, 1000); HAL_Delay(10); }发现卡死之后,我先做了几件常规事情,按顺序排除,这里分享给同样遇到问题的朋友。
3.1 第一步:检查 SysTick_Handler 是否存在
打开工程,确认启动文件或者中断向量表里有没有SysTick_Handler。如果你用的是 CubeMX 生成的标准工程,这个函数一般会在stm32h7xx_it.c里。但要小心,如果你手动添加过同名的函数,或者把SysTick_Handler写进了别的文件,两个符号可能冲突,导致实际生效的是空实现。
确认SysTick_Handler里的HAL_IncTick()有没有被注释掉。我曾经接手过一个工程,上一任工程师为了调试方便,在SysTick_Handler里加了喂狗操作,结果把HAL_IncTick()给挡住了,代码编译没问题,运行到延时函数就卡死。
3.2 第二步:确认 HAL_Init 和 SystemClock_Config 执行顺序
HAL_Init()必须在任何 HAL 外设初始化之前执行,否则HAL_InitTick没有运行,uwTick自然永远为 0。虽然 CubeMX 生成的main()里默认是先调HAL_Init(),再调SystemClock_Config(),但如果你手动修改过代码顺序,可能会导致时钟配置覆盖了SysTick设置。
我遇到过一次比较隐蔽的情况:SystemClock_Config()里重新配置了SysTick的时钟源,导致SysTick不再以 1ms 为周期中断,uwTick增长异常缓慢,看起来就接近卡死。如果SysTick_CTRL的 CLKSOURCE 位被设置成外部参考时钟,而外部时钟并未正确提供,也会让中断频率异常。
3.3 第三步:用调试器看寄存器现场
用 ST-LINK 连接调试器,暂停程序,查看当前 PC 指针停在哪个函数。如果停在HAL_Delay的while循环,再查看:
SysTick->CTRL的值,确认 ENABLE 位是否为 1。SysTick->LOAD和SysTick->VAL,确认定时器是否在走。uwTick的地址和值。SysTick_Handler是否被编译链接进固件。
也可以在调试器里手动给uwTick赋值,比如改成 10000,再看HAL_Delay是否立即返回。如果立刻返回,说明卡死原因就是uwTick不更新。这个操作很粗暴,但能快速确认方向。
3.4 第四步:在 UART 通信代码中打点
如果你怀疑是 UART 中断把SysTick压住了,可以在HAL_UART_RxCpltCallback或HAL_UART_TxCpltCallback里加一个 GPIO 翻转,用来观察 UART 中断的实际频率和持续时间。若中断里长时间运行代码,SysTick可能长时间得不到执行。
还可以在HAL_UART_Transmit调用之前和之后分别抓取uwTick的值,看看发送过程中uwTick变化是否正常。如果发送期间uwTick卡住,说明问题出在串口中断或 DMA 中断的优先级配置上。
4. 修复方案:从根上解决,而不是绕开
在我这次案例里,最终确认的原因是:接收端在 UART 接收中断回调函数里调用了HAL_Delay(5),而SysTick优先级配置得比 UART 中断低,导致中断嵌套时SysTick无法抢占,uwTick停止更新,继而卡死。发送端其实也受到波及,因为两个板子通信时,接收端不处理数据,发送端发送超时,整个业务链路被迫停滞。
4.1 直接修复:调整优先级和初始化顺序
如果确实需要在中断里调用HAL_Delay,最简单的方法是把SysTick优先级提高,至少比所有会调用延时或阻塞函数的其他中断优先级高。在 CubeMX 里,可以在NVIC配置中把SysTick的抢占优先级设置成比 UART 中断更小的数值。
如果没有特殊需求,我更推荐的做法是:不要在中断里调用HAL_Delay。中断服务函数应该尽量短,把耗时操作放到主循环或者任务中。你完全可以用一个标志位,在回调里置位,主循环检测到标志位后再做延时处理。
4.2 给 HAL_Delay 加一个保护壳
如果你无法避免在中断里使用延时,可以考虑自己实现一个基于 DWT 或普通定时器的延时函数,不依赖SysTick。比如用TIM做微秒级延时,或者用DWT->CYCCNT做高精度延时。
但这里有个前提:不同外设的优先级和中断行为不一样,盲改可能会导致其他问题。最好的办法还是重构代码逻辑,让中断函数里的时间开销可控。
4.3 UART 阻塞发送超时的处理
HAL_UART_Transmit的最后一个参数是超时时间,内部实现也依赖uwTick。如果uwTick不更新,传多大的超时都没用。所以先确保uwTick正常工作,再讨论超时参数。另外,阻塞发送期间如果又发生接收中断,需要合理配置 NVIC 抢占关系,避免 UART 中断互锁。
我建议在做双板通信时,发送和接收尽量使用分开的串口外设和分开的中断服务函数,这样可以降低相互干扰的复杂度。如果只有一个串口,就要仔细想清楚发送、接收、错误中断的优先级。
4.4 双板通信的最终代码骨架
发送端(STM32H7B3I-EVAL)简化代码:
int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART1_UART_Init(); uint8_t txData[4] = {0x01, 0x02, 0x03, 0x04}; while (1) { HAL_UART_Transmit(&huart1, txData, sizeof(txData), 100); HAL_Delay(100); } }接收端(STM32H573I-DK)简化代码:
uint8_t rxData[4]; volatile uint8_t rxComplete = 0; int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART1_UART_Init(); HAL_UART_Receive_IT(&huart1, rxData, sizeof(rxData)); while (1) { if (rxComplete) { rxComplete = 0; HAL_Delay(10); HAL_UART_Receive_IT(&huart1, rxData, sizeof(rxData)); } } } void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart->Instance == USART1) { rxComplete = 1; } }这样HAL_Delay全部放在主循环,中断里只做标志位置位,可以最大化避免优先级死锁。
5. 常见问题与排查经验速查
下面这个表是我在实际调试中总结出来的常见原因,你可以对照排查:
| 症状 | 可能原因 | 快速验证方法 |
|---|---|---|
uwTick一直为 0 | SysTick_Handler未执行或HAL_IncTick()被屏蔽 | 在SysTick_Handler打断点,看是否进入 |
uwTick有值但不增长 | SysTick时钟源配置异常 | 检查SysTick->CTRL和CLKSOURCE |
某个中断发生后卡在HAL_Delay | SysTick优先级低于当前中断 | 查看 NVIC 优先级,临时调高SysTick优先级 |
HAL_UART_Transmit报超时 | uwTick停止或波特率/引脚异常 | 单独测试串口回环,去掉HAL_Delay |
| 两个板子通信时一方卡死 | 接收中断处理过长,主循环无法及时喂HAL_Delay | 用 GPIO 翻转测量中断耗时 |
| 初始化阶段卡死 | HAL_Init没有最先调用 | 查看main()初始化顺序 |
| 开启调试优化后卡死 | 编译器优化了相关变量,未加 volatile | 把uwTick相关变量声明为 volatile |
5.1 我在现场踩过的具体坑
第一个坑:我一度以为是 STM32H573I-DK 的电源问题,因为只在接收板卡上出现卡死。后来把接收板卡单独跑一个 LED 闪烁程序,发现一切正常,才把注意力转回通信代码。
第二个坑:我试图用HAL_UART_Transmit的返回值判断卡死原因,但发送端卡住时,返回值一直是HAL_BUSY。这是因为发送队列里已经有未完成的数据,但uwTick停住,导致状态机无法跳转。HAL_BUSY这个返回值会误导人,让你误以为是串口故障。
第三个坑:修改SysTick优先级时,我把优先级组NVIC_PRIORITYGROUP_4改成了NVIC_PRIORITYGROUP_2,结果所有中断的抢占关系都变了,系统时序完全乱掉。改优先级时,务必先确认优先级分组,不要轻易动全局配置。
5.2 如何避免下一次踩坑
在双板 UART 通信项目里,我建议从一开始就建立一套自检机制:
一是开启一个调试串口专门打印关键变量的值,比如uwTick、发送状态、接收状态。不需要 OLED 屏幕,一个串口加一个 USB 转 TTL 就很够了。
二是用 LED 指示主循环是否在运行。主循环每跑一圈翻转一次 LED,如果 LED 不闪,说明程序卡在某处,能缩小范围。
三是在启用中断之前,先把基础外设跑通。比如先只跑HAL_Delay的点灯程序,确认时间基准正常,再叠加 UART 发送,最后再叠加接收中断。每加一层功能就测试一次,能最大程度避免一次性引入多个问题。
四是用volatile修饰在中断和主循环之间共享的变量。uwTick在 HAL 库里已经是全局变量,内部有自己的处理,但如果自己设计标志位,一定要记得加volatile,否则优化器可能会把变量缓存到寄存器里,导致主循环看不到中断里的修改。
6. 从 UART 双板联调到系统级稳定性
这次问题的表面是HAL_Delay卡死,深挖下去其实暴露的是整个系统的中断优先级设计和时序设计问题。单独一个板卡上,SysTick优先级低可能没什么感觉,因为不会频繁发生长时间中断;但一旦两板通信,UART 中断、DMA 中断、可能的定时器中断全叠加在一起,优先级设计不合理就会变成定时炸弹。
如果你还想让系统更稳定,可以考虑把短延时功能从SysTick迁移到其他硬件定时器。比如用 TIM6 做基本定时器,配合HAL_TIM_Base_Start_IT实现属于自己的uwTick,这样可以完全避免和 UART 中断产生优先级冲突。不过这样做的代价是你需要自己维护延时接口,工作量会大一些。
我个人的习惯是:除非项目里对时序有极高要求,否则不会主动更换时间基准。保持SysTick作为 HAL 库的默认时间基准是最省心的选择,但与此同时,我会严格遵守“中断里不调用耗时函数”的原则,把所有业务逻辑都搬到主循环或 RTOS 任务里。
说到底,HAL_Delay卡死不是玄学,uwTick不递增一定有原因。按从简单到复杂的顺序排查,最快找到根因。这次双板联调让我印象最深的一点是:问题往往不出在报错的那一行代码,而是出在它背后的中断时序和优先级设计上。希望这篇记录能帮到被同样问题折磨的你。