一、问题引入:中断里调用HAL_Delay为何会死锁?

许多开发者习惯在中断服务函数(ISR)中调用HAL_Delay()实现简单延时,却发现程序卡死。例如:

void EXTI0_IRQHandler(void)
{
    HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_0);
    HAL_Delay(10);  // 危险!可能导致死锁
    // ... 后续逻辑
}

现象是程序停在HAL_Delay内部循环中无法退出。这并非偶然,而是由HAL库的延时机制与中断优先级共同决定的必然结果。

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

2.1 HAL_Delay的实现

HAL库中HAL_Delay基于全局变量uwTick实现,该变量由SysTick中断每1ms递增一次:

__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中:

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

2.2 死锁形成条件

死锁需要同时满足两个条件:

  • 在中断上下文中调用HAL_Delay:当前执行流处于某个ISR中。
  • 该中断的优先级高于或等于SysTick中断优先级:Cortex-M内核规定,高优先级(数值小)中断可以抢占低优先级中断,而同优先级中断不能互相抢占。

默认情况下,HAL库将SysTick优先级设置为最低(TICK_INT_PRIORITY = 0x0F,即15)。若用户中断优先级数值小于15(如EXTI优先级为5),则SysTick中断无法抢占当前ISR。此时uwTick不再递增,HAL_Delay的while循环永远等不到tick变化,形成死锁。

注意:即使SysTick优先级高于用户中断,在ISR中调用HAL_Delay也会阻塞其他中断响应,破坏实时性,因此应绝对避免。

三、替代延时方案

3.1 方案一:DWT周期计数器实现微秒级延时(推荐)

Cortex-M内核自带DWT(Data Watchpoint and Trace)单元,其CYCCNT寄存器记录内核时钟周期数,可实现高精度非阻塞延时。

配置步骤:

  1. 使能DWT和CYCCNT:
void DWT_Init(void)
{
    CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk;
    DWT->CYCCNT = 0;
    DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk;
}
  1. 编写微秒延时函数(假设系统时钟为72MHz):
void DWT_DelayUs(uint32_t us)
{
    uint32_t start = DWT->CYCCNT;
    uint32_t cycles = us * (SystemCoreClock / 1000000U);
    while ((DWT->CYCCNT - start) < cycles) { }
}
  1. 在中断中安全调用:
void EXTI0_IRQHandler(void)
{
    HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_0);
    DWT_DelayUs(100);  // 延时100us,无死锁风险
}

优点:不依赖中断,精度高,代码简单。 注意:需确保DWT初始化在系统启动后完成;不同内核(M0/M0+)可能不支持DWT。

3.2 方案二:硬件定时器非阻塞延时

利用一个基本定时器(如TIM6)产生单次更新中断,在中断中设置标志位,主循环或状态机轮询标志。此方案适合需要较长延时且不阻塞CPU的场景。

配置步骤(CubeMX):

  • 启用TIM6,预分频器设为SystemCoreClock/1000000 - 1,计数周期设为us - 1,开启更新中断。
  • 在中断回调中置位标志:
volatile uint8_t tim6_done = 0;
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
    if (htim->Instance == TIM6) tim6_done = 1;
}
  • 启动单次延时:
void TimerDelayUs(uint32_t us)
{
    tim6_done = 0;
    __HAL_TIM_SET_AUTORELOAD(&htim6, us - 1);
    HAL_TIM_Base_Start_IT(&htim6);
}
  • 在需要的地方轮询tim6_done,或结合状态机使用。

优点:完全非阻塞,适合低功耗场景。 注意:需管理定时器资源,避免冲突。

3.3 方案三:空指令循环粗略延时

对于精度要求不高的短延时,可使用__NOP()循环,但需根据编译器优化和主频校准。

void NopDelay(volatile uint32_t count)
{
    while (count--) {
        __NOP();
    }
}

注意:延时时间不精确,受优化等级影响,仅作应急使用。

四、完整示例:DWT延时在外部中断中的应用

#include "stm32f1xx_hal.h"

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

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

void EXTI0_IRQHandler(void)
{
    HAL_GPIO_EXTI_IRQHandler(GPIO_PIN_0);
    DWT_DelayUs(50);  // 安全延时50us
    HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_5);
}

int main(void)
{
    HAL_Init();
    SystemClock_Config();
    DWT_Init();
    // 外设初始化...
    while (1) { }
}

五、注意事项与最佳实践

  • 绝对禁止在ISR中调用HAL_Delay,无论SysTick优先级如何配置。
  • 若必须使用HAL_Delay,确保调用发生在非中断上下文(如主循环、任务中)。
  • 中断服务程序应尽可能短小,延时需求应通过状态机或定时器回调实现。
  • 使用DWT延时需确认内核支持,并在初始化时使能TRC。
  • 多中断环境下,注意共享资源的互斥与优先级配置,避免优先级反转。
  • 对于时间敏感任务,建议使用RTOS的阻塞延时(如osDelay),但同样不能在ISR中调用。

通过理解HAL_Delay的底层机制并采用合适的替代方案,可彻底避免中断上下文中的死锁问题,提升系统可靠性与实时性。