一、问题现象:中断里调用HAL_Delay为何会死锁

许多开发者在使用STM32 HAL库时,习惯在中断服务函数(如EXTI、UART接收中断)中调用HAL_Delay()实现简单延时,却发现程序卡死。例如:

void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
    if (GPIO_Pin == KEY_Pin) {
        HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin);
        HAL_Delay(500);  // 死锁!
        HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin);
    }
}

现象是LED只翻转一次,程序不再响应任何中断。这并非HAL库的bug,而是SysTick优先级配置与中断嵌套机制共同作用的结果。

二、原理剖析:HAL_Delay与SysTick的依赖关系

2.1 HAL_Delay的实现

HAL库中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中断服务函数SysTick_Handler()中递增:

void SysTick_Handler(void)
{
    HAL_IncTick();  // uwTick++
}

2.2 死锁的根源

Cortex-M内核的中断嵌套规则:高优先级中断可以打断低优先级中断,反之不行。HAL库默认将SysTick优先级配置为最低(TICK_INT_PRIORITY = 0x0F,即15)。当程序运行在某个外设中断(优先级高于15)中调用HAL_Delay时:

  • 当前中断优先级高于SysTick,SysTick中断被挂起,无法执行;
  • uwTick停止递增,HAL_GetTick()返回值恒定;
  • while循环条件永远为真,形成死锁。

若该外设中断优先级低于SysTick,则SysTick可正常抢占,HAL_Delay反而能工作——但这依赖优先级配置,极不可靠。

三、三种修复方案与对比

方案一:提升SysTick中断优先级

修改HAL_InitTick()中的优先级参数,使SysTick优先级高于所有可能调用HAL_Delay的中断。

/* 在 stm32f1xx_hal_conf.h 中修改 */
#define TICK_INT_PRIORITY  0U  // 最高优先级

或在HAL_InitTick中直接指定:

HAL_NVIC_SetPriority(SysTick_IRQn, 0, 0);

优点:改动最小,一行宏定义即可。
缺点:SysTick成为最高优先级,会抢占所有中断,增加系统抖动;若其他中断也调用HAL_Delay且优先级高于SysTick,问题依旧。不推荐用于复杂系统。

方案二:使用硬件定时器实现阻塞延时

配置一个基本定时器(如TIM6),提供独立于SysTick的延时服务。

/* tim_delay.c */
#include "stm32f1xx_hal.h"

extern TIM_HandleTypeDef htim6;

void Delay_us(uint32_t us)
{
    __HAL_TIM_SET_COUNTER(&htim6, 0);
    HAL_TIM_Base_Start(&htim6);
    while (__HAL_TIM_GET_COUNTER(&htim6) < us);
    HAL_TIM_Base_Stop(&htim6);
}

void Delay_ms(uint32_t ms)
{
    while (ms--) Delay_us(1000);
}

初始化TIM6为1MHz计数频率(预分频72-1,ARR=0xFFFF):

htim6.Instance = TIM6;
htim6.Init.Prescaler = 72 - 1;
htim6.Init.Period = 0xFFFF;
HAL_TIM_Base_Init(&htim6);

优点:延时精度高,不依赖中断,可在任意中断中安全调用。
缺点:占用一个硬件定时器资源;阻塞期间CPU空转。

方案三:改用轮询计数器(非阻塞延时)

在中断中避免阻塞,改用状态机+时间戳的非阻塞方式:

static uint32_t last_tick = 0;
static uint8_t  step = 0;

void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
    if (GPIO_Pin == KEY_Pin && step == 0) {
        HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin);
        last_tick = HAL_GetTick();
        step = 1;
    }
}

/* 在主循环中轮询 */
void App_Loop(void)
{
    if (step == 1 && (HAL_GetTick() - last_tick) >= 500) {
        HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin);
        step = 0;
    }
}

优点:不阻塞CPU,实时性最佳,符合嵌入式设计规范。
缺点:需重构业务逻辑,代码量稍大。

方案对比表

| 维度 | 方案一(提优先级) | 方案二(硬件定时器) | 方案三(非阻塞) | |------|-------------------|---------------------|-----------------| | 实时性影响 | 大(SysTick抢占) | 无 | 无 | | 代码侵入性 | 极小 | 中(需定时器) | 大(重构逻辑) | | 可靠性 | 低(依赖优先级) | 高 | 最高 | | 资源占用 | 无 | 一个定时器 | 无 | | 适用场景 | 简单项目 | 需精确延时 | 复杂系统 |

四、注意事项

  • 禁止在中断中调用HAL_Delay:这是HAL库的已知限制,应作为编码规范强制执行。
  • 检查所有中断优先级:使用HAL_NVIC_SetPriority时确保SysTick优先级不低于调用延时函数的中断。
  • FreeRTOS用户注意:若使用FreeRTOS,HAL_Delay可能被重定向为osDelay,在中断中调用同样非法,应使用vTaskDelayFromISR或非阻塞方案。
  • 调试技巧:若怀疑死锁,可在SysTick_Handler中翻转GPIO,用示波器观察是否停止翻转。
  • 推荐方案三:在资源允许的情况下,中断中只做标记,延时逻辑放到主循环,这是最稳健的嵌入式设计模式。

五、总结

HAL_Delay死锁的本质是SysTick中断被更高优先级中断屏蔽,导致uwTick停止更新。三种修复方案各有取舍:提优先级治标不治本,硬件定时器延时灵活可靠,非阻塞设计则是根治之道。理解Cortex-M中断嵌套机制,才能从根源上避免此类陷阱。