一、问题现象与背景
在STM32 HAL库项目中,经常能看到这样的代码:
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
HAL_Delay(10); // 等待信号稳定
// 读取传感器...
}
编译下载后,程序在第一次外部中断触发时就“卡死”了,或者主循环不再运行、串口不再输出。更隐蔽的情况是:中断里调用HAL_Delay后系统还能跑,但延时时间明显不准,或者偶尔死机。
这不是HAL库的bug,而是在中断上下文中使用阻塞式延时这一设计本身与Cortex-M的中断模型冲突。下面从原理层面拆解。
二、HAL_Delay的实现机制
HAL_Delay的源码非常简短,核心逻辑如下(以STM32F1 HAL为例):
__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中断中的HAL_IncTick()累加; -
HAL_Delay是忙等待,在等待期间不释放CPU,完全依赖SysTick中断来推进时间。
也就是说,HAL_Delay能返回的前提是:SysTick中断必须能够被响应。
三、卡死的根因分析
3.1 SysTick优先级低于当前中断
Cortex-M的NVIC支持中断嵌套,但只有高优先级中断可以打断低优先级中断,同优先级不能互相抢占。SysTick默认优先级由HAL_InitTick()设置,通常为最低(数值最大)。
假设外部中断EXTI的抢占优先级为2,SysTick为15(数值越大优先级越低)。当CPU进入EXTI中断服务函数并调用HAL_Delay时:
- HAL_Delay读取
uwTick作为起点; - 进入while循环,等待
uwTick增加; - SysTick中断到来,但优先级15低于当前执行的EXTI(优先级2),无法抢占;
-
uwTick永远不增加,while条件永远成立; - 程序永久停留在EXTI中断中,主循环和其他低优先级中断全部饿死。
这就是“卡死”的直接原因。本质是在关中断等效环境下等待一个依赖中断的事件,形成死锁。
3.2 其他诱因
- 在
SysTick_Handler内部或更高优先级中断中调用HAL_Delay,同样无法等到节拍; - 在
__disable_irq()临界区内调用HAL_Delay,全局中断关闭,必然死锁; - 使用了FreeRTOS等RTOS后,SysTick被RTOS接管,HAL_Delay的时基可能来自其他定时器,行为更不可预期。
四、替代延时方案
原则:中断上下文中禁止任何依赖中断推进的阻塞等待。需要延时时,优先考虑“不延时”或“非阻塞延时”。
方案1:DWT周期计数器(推荐,短延时)
Cortex-M3/M4/M7内置DWT(Data Watchpoint and Trace)单元的CYCCNT寄存器,按CPU时钟计数,不依赖任何中断,可在中断中安全忙等。
#include "core_cm4.h" // 按内核型号选择
static void DWT_Delay_Init(void)
{
CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk;
DWT->CYCCNT = 0;
DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk;
}
static inline void DWT_DelayUs(uint32_t us)
{
uint32_t start = DWT->CYCCNT;
uint32_t ticks = us * (SystemCoreClock / 1000000U);
while ((DWT->CYCCNT - start) < ticks)
{
__NOP();
}
}
注意:
- 需要使能TRCENA,部分芯片调试器连接时才使能,量产代码要显式初始化;
- CYCCNT为32位,SystemCoreClock=72MHz时约59.6秒回绕,单次延时不要超过该范围;
- 计算用无符号减法,天然处理回绕。
方案2:硬件定时器忙等
用任意基本定时器(如TIM6)做自由运行计数器,读取CNT差值判断延时。适合没有DWT的M0/M0+内核。
static inline void TIM_DelayUs(TIM_TypeDef *TIMx, uint32_t us)
{
uint32_t start = TIMx->CNT;
uint32_t ticks = us * (HAL_RCC_GetPCLK1Freq() / 1000000U);
while ((TIMx->CNT - start) < ticks) { }
}
需保证定时器已启动且不分频溢出周期足够长。
方案3:非阻塞状态机(最佳实践)
中断里只做标记,把延时逻辑放到主循环,用HAL_GetTick()做时间戳比较:
volatile uint32_t g_exti_tick = 0;
volatile uint8_t g_exti_flag = 0;
void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
g_exti_tick = HAL_GetTick();
g_exti_flag = 1;
}
void App_Poll(void)
{
if (g_exti_flag && (HAL_GetTick() - g_exti_tick) >= 10)
{
g_exti_flag = 0;
// 执行原本放在中断里的处理
}
}
这是RTOS和裸机都推荐的模式,中断服务时间最短,系统实时性最好。
方案4:调整SysTick优先级(治标)
若确实无法重构,可将SysTick抢占优先级设为最高(数值0),使其能抢占所有外设中断:
HAL_NVIC_SetPriority(SysTick_IRQn, 0, 0);
但这会带来副作用:SysTick频繁抢占高实时性外设中断,增加中断延迟抖动;且若在SysTick自身或同优先级中断中调用HAL_Delay仍会死锁。不推荐作为通用方案。
方案5:RTOS延时
在FreeRTOS任务中应使用vTaskDelay()而非HAL_Delay;中断中需要延时时使用vTaskDelayUntil不可行,应改用软件定时器或状态机。
五、选型建议与注意事项
- 微秒级、中断内短延时:DWT方案,开销最小;
- 毫秒级、中断内:优先重构为非阻塞状态机;
- M0/M0+无DWT:用硬件定时器或状态机;
- 禁止在临界区、SysTick_Handler、高优先级中断中调用HAL_Delay;
- 使用RTOS时确认HAL时基来源,避免与RTOS节拍冲突;
- DWT延时受编译器优化和Flash等待周期影响,精度要求高时用示波器校准。
六、总结
HAL_Delay卡死的根因不是HAL库缺陷,而是忙等待 + 依赖中断推进时间 + 中断优先级不可抢占三者叠加形成的死锁。理解Cortex-M的抢占规则后,解决方案就很清晰:中断上下文要么用不依赖中断的时基(DWT/定时器)做短延时,要么把延时逻辑移出中断,用状态机在主循环中完成。养成“中断只做标记、不做阻塞”的习惯,是从根源上避免此类问题的关键。
暂无评论,来抢沙发~