一、引言

在STM32开发中,HAL_Delay() 是HAL库最常用的延时函数之一。它看似简单,却隐藏着一个与SysTick中断优先级相关的致命陷阱:在中断服务函数(ISR)中调用 HAL_Delay(),若该中断的优先级高于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() 中递增(通过 HAL_IncTick())。

2.2 SysTick中断与优先级

SysTick是Cortex-M内核的一个系统定时器,通常配置为1ms中断一次。其中断优先级由 HAL_InitTick() 设置,默认使用最低优先级(TICK_INT_PRIORITY,通常为15)。

关键点:HAL_Delay() 依赖SysTick中断来更新 uwTick。如果SysTick中断无法执行,uwTick 停止递增,HAL_Delay() 将永远等待,形成死锁。

三、死锁复现

3.1 硬件与软件环境

  • STM32F407 Discovery板
  • STM32CubeMX生成HAL库工程
  • 系统时钟168MHz,SysTick优先级默认15

3.2 配置一个高优先级外部中断

使用按键EXTI中断,将其优先级设置为高于SysTick(例如优先级5,数值越小优先级越高)。在CubeMX中配置PA0为EXTI0,并使能中断。

3.3 在中断中调用HAL_Delay

在 stm32f4xx_it.c 的EXTI0中断服务函数中调用 HAL_Delay(100):

void EXTI0_IRQHandler(void)
{
  HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_0);
}

void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
  if (GPIO_Pin == GPIO_PIN_0)
  {
    HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin);
    HAL_Delay(100);  // 死锁发生点
    HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin);
  }
}

3.4 现象

按下按键后,LED第一次翻转,然后程序卡死,LED不再变化。调试器暂停后发现程序停留在 HAL_Delay() 的while循环中,uwTick 不再变化。

四、根本原因分析

Cortex-M内核的中断优先级规则:高优先级中断可以打断低优先级中断,但低优先级中断不能打断高优先级中断。

  • EXTI0中断优先级为5,SysTick优先级为15(数值越大优先级越低)。
  • 当EXTI0中断执行时,SysTick中断被挂起(pending),无法抢占EXTI0。
  • HAL_Delay() 等待 uwTick 变化,但 uwTick 只能在SysTick中断中更新。
  • 由于SysTick无法执行,uwTick 恒定不变,HAL_Delay() 陷入无限循环。

结论:在任何优先级高于SysTick的中断服务函数中调用 HAL_Delay(),都会导致死锁。

五、修复方案

方案一:调整中断优先级(不推荐)

将EXTI0中断优先级设置为低于SysTick(例如优先级16,但Cortex-M4优先级范围通常为0-15,需确保SysTick优先级高于所有调用HAL_Delay的中断)。但这种方法限制了中断响应实时性,且容易遗漏。

方案二:在中断中使用非阻塞延时(推荐)

利用状态机或定时器实现非阻塞延时,避免在ISR中等待。例如使用一个软件定时器或记录时间戳,在主循环中处理:

volatile uint32_t ledToggleTime = 0;
volatile uint8_t  ledState = 0;

void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)
{
  if (GPIO_Pin == GPIO_PIN_0)
  {
    ledToggleTime = HAL_GetTick();
    ledState = 1;
  }
}

// 主循环中
while (1)
{
  if (ledState && (HAL_GetTick() - ledToggleTime >= 100))
  {
    HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin);
    ledState = 0;
  }
}

方案三:重写HAL_Delay为不依赖中断的延时(适用于简单场景)

使用DWT周期计数器或简单的循环延时,但精度和功耗较差。不推荐在低功耗应用中使用。

void Delay_NoInterrupt(uint32_t ms)
{
  uint32_t start = DWT->CYCCNT;
  uint32_t cycles = ms * (SystemCoreClock / 1000);
  while ((DWT->CYCCNT - start) < cycles);
}

需先使能DWT:CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk;

六、最佳实践与注意事项

  • 永远不要在中断服务函数中调用 HAL_Delay(),除非你确定该中断优先级低于SysTick。
  • 使用CubeMX配置时,检查 TICK_INT_PRIORITY 宏定义(在 stm32f4xx_hal_conf.h 中),确保SysTick优先级高于所有可能调用延时的中断。
  • 在中断中尽量只做标记,将耗时操作放到主循环。
  • 若必须使用延时,考虑使用硬件定时器或DWT。
  • 调试死锁时,检查 uwTick 是否停止递增,可快速定位。

七、总结

HAL_Delay() 的SysTick优先级陷阱是STM32 HAL开发中的经典问题。理解其原理后,通过合理的架构设计(中断中避免阻塞)和优先级配置,可以彻底避免死锁。希望本文能帮助你在嵌入式开发中少走弯路。