一、问题现象与背景

在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时:

  1. HAL_Delay读取uwTick作为起点;
  2. 进入while循环,等待uwTick增加;
  3. SysTick中断到来,但优先级15低于当前执行的EXTI(优先级2),无法抢占;
  4. uwTick永远不增加,while条件永远成立;
  5. 程序永久停留在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/定时器)做短延时,要么把延时逻辑移出中断,用状态机在主循环中完成。养成“中断只做标记、不做阻塞”的习惯,是从根源上避免此类问题的关键。