“又卡住了,而且卡在 HAL_Delay() 里。”
这句话对用 STM32H7 的朋友来说应该不陌生。尤其是调试 H745XI 这种双核芯片时,明明代码逻辑看起来没问题,全速运行却怎么也跑不过 HAL_Init(),走到 HAL_Delay(1) 就再也出不来。排除电源和硬件连接之后,剩下的坑基本都藏在“调试器怎么复位芯片”和“双核共享 RCC”这两件事里。
我早期第一次在 H745XI 上遇到这个现象时,第一反应是 HAL 库有问题,甚至怀疑芯片坏了。后来花了一整天把启动流程、SysTick 配置、时钟树全都翻了一遍,才明白这其实是“SysTick 作为 HAL 时基”与“调试器干预”之间的一场典型冲突。这篇博文就按当时的排障路径来写,先拆解 HAL_Init() 和 HAL_Delay() 的底层执行逻辑,再给出从现象到根因的四步排查方法,最后针对调试器复位策略、SysTick 配置缺失、双核运行冲突三类典型场景给出能直接抄的解决办法。正在用 H7 系列双核开发,或者遇到类似“调试一进去就死等”问题的朋友,可以参考一下。
1. 现象复现:每一次“卡死”都不是偶然
1.1 我的现场:H745XI 双核工程调试卡在 HAL_Delay
今年年中我接手一个用 STM32H745XI 做双核采集的项目,M7 负责主流程和数据处理,M4 只做简单的 GPIO 触发读取。工具链是 Keil MDK 5.38 + ST-Link,HAL 库由 STM32CubeMX 生成,工程是默认的 M7 单核模板,M4 部分当时还没有正式固件。
第一次在板子上跑调试,我就遇到了标题里说的这个现象:编译、下载都正常,一旦进入 Debug 模式,程序要么停在 main 开头的断点上,要么全速运行后整个系统像被冻住。暂停程序,查看 Call Stack,程序正卡在 HAL_Init() 内部的 HAL_Delay(1) 那行,后面跟了一串调用关系。更迷惑的是,我以为是自己初始化顺序不对,于是把 HAL_Init() 挪到 main 最后、去掉所有外设初始化,问题依旧。
这个“什么问题都排除了还复现”的特点,是 SysTick 这类内核定时器问题最典型的信号。因为 SysTick 的配置和中断响应不依赖具体外设,很容易让排查方向跑偏。
1.2 卡住的位置与最小复现条件
先把最小复现条件固定下来:不接外部晶振,用内部 HSI 启动;不初始化 GPIO、DMA、串口;只保留 SystemInit()、HAL_Init()、SystemClock_Config() 三件套。在 Keil 里打开调试,下载完自动复位运行,然后全速跑。就这样的一个空工程,依然在 HAL_Delay(1) 里死住,那就说明和业务代码无关了。
我还用一个笨办法确认它不是“慢”而是“死”:在 HAL_Delay 的 while 循环里加一个局部计数变量,放在 Watch 窗口观察。如果这个计数变量一直不变,说明循环确实没退出来,而不是单纯跑得慢。结果计数完全不动,SysTick 中断根本没有在累积 tick,HAL_GetTick() 的值始终是同一个数。
这里要特别提醒一句:如果你是单步走进 HAL_Delay 看到 while 出不来,先别急着下结论说“卡死”。因为单步时内核暂停,SysTick 计数器也暂停,要全速运行几秒再看。我就见过同事在单步模式下纠结了半天,最后全速跑一下就直接跳过去了。
2. 源头拆解:HAL_Init 和 HAL_Delay 的执行逻辑
2.1 HAL_Init 里到底干了什么
很多朋友对 HAL_Init() 的理解就是“初始化 HAL 库”,然后就不管了。但在 H7 系列上,它内部做的事会对 SysTick 有直接依赖。打开 stm32h7xx_hal.c 的 HAL_Init() 源码,可以看到大致流程:
HAL_StatusTypeDef HAL_Init(void) { /* 1. 使能 Flash 指令缓存 / 数据缓存 */ /* 2. 设置 NVIC 优先级分组 */ HAL_NVIC_SetPriorityGrouping(NVIC_PRIORITYGROUP_4); /* 3. 用 SysTick 作为 HAL 时基,并启动 tick */ if (HAL_InitTick(TICK_INT_PRIO) != HAL_OK) { return HAL_ERROR; } /* 4. 调用用户自己的 MSP 初始化 */ HAL_MspInit(); return HAL_OK; }真正和 HAL_Delay 挂钩的是第 3 步。HAL_InitTick() 会做几件事:根据当前系统时钟频率算出 SysTick 的重装值,把 tick 周期配成 1ms;把 SysTick 中断优先级设置好;打开 SysTick 计数和中断使能。它的执行顺序是“先开中断,再等中断”。
不同版本的 HAL 库获取频率的方式略有差异,有的直接读 SystemCoreClock,有的通过 HAL_RCC_GetSysClockFreq() 动态计算。但本质都一样:SysTick 的 LOAD 值必须等于“系统时钟频率 / 1000”。如果 HAL_Init 执行时系统时钟和 SystemCoreClock 不一致,后面就会埋雷。
2.2 HAL_Delay 的死等机制与 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,而 uwTick 只能在 SysTick_Handler 中断里通过 HAL_IncTick() 增加。也就是说:如果 SysTick 中断没发生,HAL_Delay 就是一个永远都跳不出去的 while 循环,连一点超时保护都没有。
这也是为什么我会反复强调“看 SysTick 是否在跑”比“改 HAL_Delay 代码”更重要。SysTick 是内核私有定时器,它的状态不像串口、GPIO 那样一眼就能看到,必须从寄存器层面确认。一旦确认 SysTick 没有产生中断,问题就回到了三个方向:SysTick 没配置、中断没使能、或者是中断向量表根本没进来。
2.3 为什么 debug 状态下更容易触发
我觉得有三个因素叠加,才造出这种“只在调试时出现”的诡异现象。
第一个是单步与暂停。SysTick 使用的是处理器时钟源,CPU 一旦被调试器暂停,SysTick 计数器也会跟着停。如果你习惯单步调试,走进 HAL_Delay 的 while 里,每一步执行后 CPU 暂停,SysTick 不能计数,自然看不到 tick 在涨。这个问题在 Cortex-M 全系列都会遇到,只是 H7 的 HAL 时基设计特别容易踩中。
第二个是调试器的复位方式。Keil 里面 Debug 设置中的 Reset 选项可以选 SYSRESETREQ、VECTRESET、自动检测等。有些复位方式不会把调试逻辑里的一些冻结位清干净,也不会重新把时钟源恢复到上电默认状态。如果你的代码在 HAL_Init 之前还没做过复杂时钟切换,复位后 SysTick 配置时算出来的“当前时钟频率”可能和调试器实际提供给 CPU 的时钟不一致,导致重装值算错。
第三个是 H7 双核的共享外设。H745XI 是 M7 + M4 双核,两个核共享 RCC、Flash、GPIO、电源管理。调试器按“系统复位”时,两个核一起复位,M4 如果此时跑的是随机代码或者旧固件,它把共享的时钟控制器改了,M7 这边的 SysTick 就会莫名其妙“罢工”。
3. 排查实操:四步定位卡死根因
3.1 第一步:先确认不是“假死”——单步不算,全速才算
遇到卡在 HAL_Delay 的情况,我建议先做三个快速确认:
先看 Call Stack。在 Keil 的 Call Stack 窗口里,如果能看到当前 PC 停在 HAL_Delay 的 while 里面,并且调用链是 main -> HAL_Init -> HAL_InitTick -> HAL_Delay,那就说明 HAL 库的初始化流程已经走到了等待 tick 的环节。
再看 HAL_GetTick()。在 Watch 窗口添加表达式HAL_GetTick(),然后全速运行。如果这个值一直不变,说明 SysTick 中断没有在递增它;如果这个值在变化,只是程序很慢,那就不算“死”,可能是 SysTick 频率被配错,导致延时不正常。
最后用一个手动计数器辅助判断。在 while 循环里塞一个volatile uint32_t loop_cnt = 0; loop_cnt++;,看这个变量是否持续增长。它能区分“代码没执行”和“代码在执行但条件不满足”这两种情况。这个土办法在排查死等类问题时非常有效。
3.2 第二步:看 SysTick 寄存器与中断状态
确定是 SysTick 没跑之后,打开 Keil 的 Peripherals -> Core Peripherals -> SysTick 窗口,或者直接在 Watch 窗口添加以下表达式:
SysTick->CTRL SysTick->LOAD SysTick->VAL这几个值能说明大部分问题。正常情况应该是:
| 表达式 | 正常值 | 异常值含义 |
|---|---|---|
| SysTick->CTRL & 0x01 | 1 | ENABLE=0,SysTick 根本没开 |
| SysTick->CTRL & 0x02 | 1 | TICKINT=0,中断没使能 |
| SysTick->CTRL & 0x04 | 1 | CLKSOURCE=0,可能用了外部时钟,要注意来源 |
| SysTick->LOAD | 约 SystemCoreClock/1000 | 0 或异常大,说明频率计算有问题 |
| SysTick->VAL | 不断变化 | 固定不变,说明时钟没进来 |
这里最容易踩的坑是 LOAD 值看起来正常,但 TICKINT 位是 0。有些用户自己的初始化代码或者 RTOS 启动代码会重新配置 SysTick,把中断给关了。如果 SysTick_Handler 里没有调用 HAL_IncTick(),那么即使 SysTick 在计数、中断也在发生,uwTick 也永远不会增加,HAL_Delay 照样死循环。
还要确认全局中断是否打开。在 Debug 窗口里看 xPSR 的 I 位,或者调用__get_PRIMASK()观察返回值。如果 PRIMASK 被置 1,所有可屏蔽中断都无法响应,SysTick 中断自然无效。这种情况在用户某个外设库函数里用了__disable_irq(),但忘记恢复时特别常见。
3.3 第三步:查 Clock 与 RCC 是否还在预期状态
如果 SysTick 寄存器看起来正常,但 HAL_GetTick() 就是不涨,下一步要查时钟树。在 Keil 的 Watch 窗口里添加:
SystemCoreClock RCC->CR RCC->CFGR重点看 RCC->CR 里的 HSIRDY、PLLRDY 这些就绪位,以及 SystemCoreClock 变量的值。H7 系列上电默认是内部 HSI,频率通常为 64MHz,SystemCoreClock 应该和它匹配。如果在 HAL_Init 执行之前,用户代码已经做了时钟切换,但 SystemCoreClock 没有同步更新,那么 SysTick 的 LOAD 值就会按错误频率计算。
比如实际系统时钟已经切到 400MHz,而 SystemCoreClock 还停留在 64MHz,那么 SysTick 重装值是按 64MHz 算的,实际中断频率会变成 400MHz / 64000,大约 6.25kHz 而不是 1kHz。这种情况下 HAL_Delay 不会卡死,但延时时间会快 6 倍多,而且十分隐蔽。另一种更恶劣的情况是 PLL 还没锁定就切了过去,系统时钟直接丢失,SysTick 计数器得不到时钟输入,那就是真正的死等。
调试器在低功耗调试模式下,也可能影响 SysTick 的时钟路径。如果 DBGMCU 的低功耗冻结位被置位,外部定时器和部分总线时钟在低功耗模式下会被冻住,虽然 SysTick 是内核定时器,一般跟随 CPU 暂停,但在某些低功耗调试配置下仍然可能被影响。建议在调试阶段不要打开低功耗相关冻结功能。
3.4 第四步:排查双核共用的 RCC 资源
到了这一步,如果 M7 自己的时钟和 SysTick 都没问题,那我要强烈怀疑另一个核心在捣乱。
H745XI 是双核,M7 和 M4 共享大部分总线时钟和复位控制。调试器执行系统复位时,两个核都会重新从启动地址运行。如果 M4 的工程还没做好,或者 Flash 里 M4 区域本来就是随机数据,M4 复位后会执行一段不可控代码,这段代码可能把共享的 PLL、分频器、Flash 等待周期全部改乱。
怎么判断是不是这个问题?简单粗暴的办法:在 M7 调试会话里手动把 M4 内核 halt 住。如果你的调试器支持多核调试,可以在 Keil 里创建两个 target,分别连接 M7 和 M4,然后将 M4 的调试会话暂停;或者通过 ST-Link 的 SWD 接口用 STM32CubeProgrammer 先连接 M4,选择 halt 核心。然后再回到 M7 工程重新全速运行,看 HAL_Delay 是否能过。
如果 halt M4 之后问题消失,那基本就实锤了:M7 卡死是因为 M4 在启动阶段动了共享 RCC。这个原因在单核 MCU 上永远遇不到,但 H745XI 这种双核芯片上非常常见,而且越早遇到越好,因为后面双核联调的坑会更多。
4. 根因与三种正解
4.1 场景A:调试器复位策略导致执行顺序异常
我遇到的第一个真正理论上的根因,就是 Keil 的复位策略。Keil 在 Debug 模式下有几个复位相关的选项:Options -> Debug -> Settings -> Debug 页里的 Reset 选项,默认是 Auto Detect,但很多人会为了“稳定连接”改成 SYSRESETREQ 或 VECTRESET。
不同复位方式对调试逻辑的影响不一样。SYSRESETREQ 是 Cortex-M 内核里最常用的软复位方式,它会复位大部分系统逻辑,但不会重新初始化调试接口本身;VECTRESET 只复位 CPU 核心,不复位外设和总线。问题是,有些 ST-Link 固件版本执行复位时,会忽略 Flash 下载算法的复位序列,导致调试器先把目标复位了,再恢复运行,此时 RCC、SysTick 等硬件状态并没有被完整地重新初始化。
这个问题的解决思路很直接:下载完不要依赖“Reset and Run”那个自动复位,改成手动控制。
在 Keil 中,我建议这样设置:
- Debug -> Settings -> Debug 页,Reset 选择 Auto Detect,让调试器自己判断最合适的复位方式。
- Utilities -> Settings 里,不要勾选“Reset and Run”,或者先不勾选,等程序下载完成后,自己在调试工具栏手动点一下 Reset。
- Flash Download 完成后,确认程序入口地址和当前调试会话的逻辑一致,避免程序从错误的向量表位置跑起来。
如果你用 STM32CubeIDE,思路类似:在 Debug Configuration -> Startup 页里,把“Reset and halt”和“Run to main”分开设置,通常先保持“Reset and halt”,进入调试后手动全速运行。这样就绕开了调试器自动复位时序和用户代码启动时序冲突的问题。
4.2 场景B:SysTick 分频与 SystemCoreClock 不一致
第二种场景,是我在一个老工程里遇到的。工程从 F429 移植到 H745 后,没有用 CubeMX 重新生成 startup 文件,而是沿用老启动文件,SystemCoreClock 变量在启动后没有被正确初始化。
H7 的 system_stm32h7xx.c 在 SystemInit() 里会调用 SystemCoreClockUpdate() 来刷新 SystemCoreClock。但如果你自己修改了 SystemInit(),或者在 HAL_Init 之前手动切换了时钟源,却没有更新 SystemCoreClock,HAL_InitTick 就会按错误的频率去配置 SysTick。
一个比较稳妥的排查