一、问题现象与背景

在STM32开发中,HAL_Delay() 几乎是最常用的函数之一。但如果你在中断服务函数(ISR)里调用它,往往会遇到以下现象:

  • 程序卡死在 HAL_Delay() 内部,看门狗复位;
  • 延时时间远大于预期;
  • 系统响应变慢,其他中断被阻塞。

很多开发者第一反应是“HAL_Delay不准”,但真正的原因在于中断优先级与SysTick的交互机制。

二、HAL_Delay的实现原理

HAL库中 HAL_Delay 的典型实现(以STM32F1/F4为例)如下:

__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_Handler() 中递增;
  • HAL_Delay 本质是忙等待轮询,依赖SysTick中断持续更新 uwTick。

三、失效根因分析

3.1 中断优先级冲突

Cortex-M内核的NVIC支持中断嵌套,但同优先级中断不能互相抢占。SysTick默认优先级通常由HAL初始化设置为最低(数值最大)。假设你在某个外设中断(如USART、TIM)中调用 HAL_Delay:

  1. 当前中断正在执行,SysTick中断无法抢占它(优先级更低或相同);
  2. uwTick 停止递增;
  3. HAL_Delay 的 while 循环永远等不到tick变化;
  4. 系统卡死。

即使SysTick优先级高于当前中断,若在ISR中长时间忙等,也会阻塞其他低优先级中断,破坏实时性。

3.2 根本结论

HAL_Delay依赖中断更新时基,而中断上下文本身会屏蔽或延迟时基更新,因此HAL_Delay在ISR中不可靠,应禁止使用。

四、三种安全替代实现

4.1 方案一:DWT周期计数器(推荐,精度高、无中断依赖)

Cortex-M3/M4/M7内核自带DWT(Data Watchpoint and Trace)单元,其中的CYCCNT寄存器记录CPU周期数,可用来做精确忙等延时,完全不依赖中断。

#include "core_cm4.h"  // 根据内核型号选择

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

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

要点:

  • 初始化只需一次,可在 main 开头调用;
  • 延时精度取决于 SystemCoreClock,需确保时钟配置正确;
  • 适用于微秒级短延时,ISR中安全;
  • 注意 CYCCNT 为32位,长时间延时需考虑溢出(约数十秒@168MHz)。

4.2 方案二:硬件定时器轮询(适合毫秒级、可移植)

使用一个基本定时器(如TIM6/TIM7)自由运行,读取计数器差值实现延时,不依赖中断。

void TIM_Delay_Init(void)
{
    __HAL_RCC_TIM6_CLK_ENABLE();
    TIM6->PSC = (SystemCoreClock / 1000000U) - 1; // 1MHz计数
    TIM6->ARR = 0xFFFF;
    TIM6->CR1 |= TIM_CR1_CEN;
}

void TIM_Delay_us(uint16_t us)
{
    uint16_t start = TIM6->CNT;
    while ((uint16_t)(TIM6->CNT - start) < us)
    {
        __NOP();
    }
}

要点:

  • 定时器自由运行,不开启中断;
  • 16位计数器最大延时约65ms(1MHz),更长延时需扩展计数;
  • 适合毫秒级以内延时,ISR中安全;
  • 占用一个定时器资源。

4.3 方案三:状态机非阻塞延时(最佳实践)

在ISR中根本不应阻塞。正确做法是记录时间戳,在主循环或状态机中判断超时。

volatile uint32_t g_last_tick = 0;
volatile uint8_t  g_timeout_flag = 0;

void EXTI_IRQHandler(void)
{
    HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_0);
    g_last_tick = HAL_GetTick();   // 仅记录,不等待
    g_timeout_flag = 0;
}

void App_Task(void)
{
    if (g_last_tick != 0 &&
        (HAL_GetTick() - g_last_tick) > 100U)
    {
        g_timeout_flag = 1;
        g_last_tick = 0;
        // 执行超时后的业务逻辑
    }
}

要点:

  • ISR只做最小工作,记录时间戳;
  • 主循环轮询判断超时,不阻塞任何中断;
  • 实时性最好,是嵌入式开发的推荐范式;
  • 需注意 HAL_GetTick() 在中断关闭时可能不更新,但主循环中调用安全。

五、三种方案对比

  • DWT:微秒级,精度最高,无外设占用,适合短延时;
  • 硬件定时器:微秒到毫秒,可移植,占用一个定时器;
  • 状态机:零阻塞,实时性最佳,适合业务逻辑延时。

六、注意事项

  • 严禁在ISR中调用 HAL_Delay,包括回调函数(如 HAL_GPIO_EXTI_Callback);
  • 若必须短延时,优先选DWT方案;
  • 使用DWT前确认内核支持(M3及以上),M0/M0+无DWT;
  • 定时器方案注意计数器位宽与溢出;
  • 状态机方案中共享变量需加 volatile,必要时做临界区保护;
  • 检查SysTick优先级配置,避免与其他中断冲突。

七、总结

HAL_Delay 失效的本质是中断上下文屏蔽了时基更新。理解SysTick与NVIC的协作机制后,应根据场景选择DWT、硬件定时器或状态机方案,从根源上避免ISR阻塞,提升系统实时性与稳定性。