一、问题现象与背景

在STM32开发中,HAL_Delay() 是常用的毫秒级延时函数。但许多开发者发现,在中断服务函数(ISR)中调用 HAL_Delay() 时,程序可能卡死、延时时间严重偏长,甚至触发HardFault。例如:

void EXTI0_IRQHandler(void) {
    HAL_Delay(10);  // 期望延时10ms,实际可能卡死
    HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin);
}

这种现象并非偶然,而是由 HAL_Delay() 的实现机制与中断上下文冲突导致。

二、根因分析:HAL_Delay如何工作

2.1 HAL_Delay源码剖析

HAL库中 HAL_Delay() 的典型实现(以STM32CubeF1为例):

__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_Handler() 中递增:

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

2.2 失效根因

  • SysTick中断优先级问题:SysTick 默认优先级较低(通常为15)。若当前中断的优先级高于或等于 SysTick 优先级,SysTick 中断无法抢占,uwTick 停止递增,HAL_Delay() 中的 while 循环将永远等待,导致死锁。
  • 中断嵌套与重入:若在 SysTick 中断服务函数内调用 HAL_Delay(),uwTick 不会更新,同样死锁。
  • 实时性破坏:即使 SysTick 能抢占,HAL_Delay() 是忙等待,会阻塞当前中断,延长中断响应时间,影响系统实时性。

结论:HAL_Delay() 依赖中断更新计数,且为阻塞式轮询,在中断上下文中不可靠。

三、替代延时方案

3.1 方案一:DWT周期计数器忙等待(微秒/毫秒级)

Cortex-M内核自带DWT(Data Watchpoint and Trace)周期计数器,不依赖中断,适合短延时。

配置步骤:

  • 使能DWT:CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk;
  • 复位并启动周期计数器:DWT->CYCCNT = 0; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk;

代码示例:

#include "core_cm4.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) {
        __NOP();
    }
}

void DWT_DelayMs(uint32_t ms) {
    while (ms--) {
        DWT_DelayUs(1000);
    }
}

优点:不依赖中断,可在任意上下文使用;精度高。
缺点:忙等待,长延时会浪费CPU;需内核支持DWT(Cortex-M3/M4/M7等)。

3.2 方案二:硬件定时器单次触发延时

利用一个基本定时器(如TIM6)产生单次延时,通过轮询标志位实现。

配置步骤(以TIM6为例):

  • 使能TIM6时钟。
  • 设置预分频器(PSC)和自动重装载寄存器(ARR)以产生所需延时。
  • 启动定时器,等待更新标志位。

代码示例:

void TIM6_DelayMs(uint32_t ms) {
    // 假设系统时钟84MHz,预分频8400-1,则计数频率10kHz,1ms=10计数
    TIM6->PSC = 8400 - 1;
    TIM6->ARR = ms * 10 - 1;
    TIM6->EGR = TIM_EGR_UG;        // 生成更新事件,加载寄存器
    TIM6->SR = ~TIM_SR_UIF;        // 清除更新标志
    TIM6->CR1 |= TIM_CR1_CEN;      // 启动定时器
    while (!(TIM6->SR & TIM_SR_UIF)); // 等待更新标志
    TIM6->SR = ~TIM_SR_UIF;        // 清除标志
    TIM6->CR1 &= ~TIM_CR1_CEN;     // 关闭定时器
}

优点:不依赖SysTick中断,精度较高;适合毫秒级延时。
缺点:占用一个定时器资源;忙等待。

3.3 方案三:非阻塞状态机延时(推荐用于中断)

在中断中避免任何忙等待,采用状态机+系统滴答计数实现非阻塞延时。

代码示例:

volatile uint32_t delay_counter = 0;

// 在SysTick中断中递减
void SysTick_Handler(void) {
    HAL_IncTick();
    if (delay_counter > 0) delay_counter--;
}

// 启动非阻塞延时(单位:ms)
void StartDelay(uint32_t ms) {
    delay_counter = ms;
}

// 检查延时是否结束
bool IsDelayDone(void) {
    return (delay_counter == 0);
}

// 在中断中使用示例
void EXTI0_IRQHandler(void) {
    static bool led_on = false;
    if (IsDelayDone()) {
        led_on = !led_on;
        HAL_GPIO_WritePin(LED_GPIO_Port, LED_Pin, led_on ? GPIO_PIN_SET : GPIO_PIN_RESET);
        StartDelay(500);  // 下次触发间隔500ms
    }
}

优点:完全非阻塞,不占用CPU;适合中断和主循环。
缺点:需要SysTick中断正常工作;延时精度受中断响应影响。

四、注意事项

  • 中断优先级配置:若使用SysTick相关方案,确保SysTick优先级高于或等于调用延时的中断优先级,否则计数不更新。
  • DWT计数器溢出:DWT->CYCCNT为32位,在168MHz下约25.5秒溢出,长延时需处理溢出。
  • 定时器资源冲突:使用硬件定时器方案时,避免与其他功能共用定时器。
  • 非阻塞方案适用性:状态机延时适合周期性任务,但无法实现“精确等待”语义。
  • 调试影响:DWT在调试器暂停时可能停止计数,需注意。
  • HAL_Delay的替代:在中断中绝对避免调用HAL_Delay;主循环中可继续使用,但需知悉其阻塞特性。

五、总结

HAL_Delay() 在中断上下文失效的根因是依赖SysTick中断更新计数且为忙等待。开发者应根据场景选择替代方案:短延时用DWT,毫秒级用硬件定时器,中断中推荐非阻塞状态机。理解底层机制才能写出稳定可靠的嵌入式代码。