一、问题引入:一个看似无害的调用

在STM32开发中,HAL_Delay()是最常用的延时函数。很多开发者习惯在中断服务函数(ISR)中直接调用它,例如:

void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
    HAL_Delay(10);  // 等待按键消抖
    // ... 处理逻辑
}

这段代码在多数情况下“看起来能用”,但在特定条件下会导致系统卡死。本文将从原理出发,复现该问题并给出规避方案。

二、原理剖析:HAL_Delay为什么不能在中断里用

2.1 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中断中的HAL_IncTick()递增:

void SysTick_Handler(void)
{
  HAL_IncTick();
}

2.2 死锁的形成条件

关键点在于:SysTick中断的优先级通常是最低的(CubeMX默认生成HAL_InitTick(TICK_INT_PRIORITY),其值为15,即最低优先级)。

当某个外设中断(如EXTI、USART)的抢占优先级高于SysTick时:

  1. 外设ISR执行,调用HAL_Delay进入忙等;
  2. 此时SysTick中断无法抢占当前ISR(优先级更低);
  3. uwTick停止递增,HAL_GetTick()永远返回同一值;
  4. while循环永不退出,系统死锁。

这就是“隐性”所在——代码编译无警告,单步调试时若SysTick恰好已更新也可能“碰巧”通过,但在高优先级中断中必然死锁。

三、复现步骤与完整代码

3.1 环境配置

  • STM32F407 + STM32CubeMX + HAL库
  • 配置一个EXTI中断(按键PA0),抢占优先级设为2
  • SysTick保持默认优先级15

3.2 复现代码

/* main.c */
volatile uint32_t isr_enter_cnt = 0;

void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
    if (GPIO_Pin == GPIO_PIN_0)
    {
        isr_enter_cnt++;
        HAL_Delay(10);          // 死锁点
        HAL_GPIO_TogglePin(GPIOD, GPIO_PIN_12);
    }
}

int main(void)
{
    HAL_Init();
    SystemClock_Config();
    MX_GPIO_Init();

    while (1)
    {
        HAL_GPIO_TogglePin(GPIOD, GPIO_PIN_13);
        HAL_Delay(500);
    }
}

3.3 现象验证

  • 按下按键后,LED13停止闪烁,LED12不再翻转;
  • 调试器暂停,PC停在HAL_Delay的while循环内;
  • 查看uwTick,值不再变化,确认死锁。

四、规避方案

方案一:中断中只置标志,主循环处理(推荐)

volatile uint8_t key_flag = 0;

void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
    if (GPIO_Pin == GPIO_PIN_0)
        key_flag = 1;
}

/* 主循环 */
while (1)
{
    if (key_flag)
    {
        key_flag = 0;
        HAL_Delay(10);              // 主上下文安全
        HAL_GPIO_TogglePin(GPIOD, GPIO_PIN_12);
    }
}

这是最符合“中断快进快出”原则的做法,也避免了在ISR中做耗时操作。

方案二:使用DWT周期计数器做忙等延时

若确实需要在ISR中短延时,可用内核的DWT(Data Watchpoint and Trace)周期计数器,它不依赖任何中断:

void DWT_Delay_Init(void)
{
    CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk;
    DWT->CYCCNT = 0;
    DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk;
}

static inline void DWT_Delay_us(uint32_t us)
{
    uint32_t start = DWT->CYCCNT;
    uint32_t ticks = us * (SystemCoreClock / 1000000U);
    while ((DWT->CYCCNT - start) < ticks) { }
}

注意:DWT延时是纯忙等,会占用CPU,仅适合微秒级短延时,且需确认芯片支持DWT。

方案三:基于定时器的非阻塞状态机

对于需要“等待一段时间再执行”的逻辑,用硬件定时器+状态机替代阻塞延时:

typedef enum { IDLE, WAIT_DEBOUNCE, DONE } key_state_t;
static key_state_t state = IDLE;
static uint32_t t_start;

/* 1ms定时器中断中调用 */
void Key_Process(void)
{
    switch (state)
    {
    case IDLE:
        if (key_pressed) { t_start = HAL_GetTick(); state = WAIT_DEBOUNCE; }
        break;
    case WAIT_DEBOUNCE:
        if (HAL_GetTick() - t_start >= 10)
        {
            HAL_GPIO_TogglePin(GPIOD, GPIO_PIN_12);
            state = DONE;
        }
        break;
    default: break;
    }
}

五、注意事项

  • 优先级检查:若必须在ISR中使用HAL_Delay,可将SysTick优先级设为最高(0),但这会破坏RTOS的时基设计,不推荐。
  • RTOS场景:在FreeRTOS中,HAL_Delay同样不可在中断中使用,应改用vTaskDelay(任务上下文)或xTimer机制。
  • 代码审查:建议在团队规范中明确“ISR内禁止调用任何阻塞型HAL函数”,包括HAL_Delay、HAL_UART_Transmit(阻塞模式)等。
  • 调试技巧:遇到系统卡死时,先查看uwTick是否停止,可快速定位此类问题。

六、总结

HAL_Delay在中断上下文的死锁源于其对SysTick中断的隐式依赖与中断优先级的冲突。理解uwTick的更新机制是排查关键。工程实践中应遵循“中断只做标记、耗时逻辑放主循环”的原则,必要时用DWT或定时器状态机替代阻塞延时,从根源上规避此类隐性缺陷。