STM32CubeMX 生成代码中 HAL_Delay 被 SysTick 抢占导致时序漂移的排查与替代方案
👁 1 阅读 · 2026-08-27 · 嵌入式
在 STM32 嵌入式开发中,HAL_Delay 是常用的毫秒级延时函数,但许多开发者发现其实际延时并不精确,甚至出现明显漂移。本文深入剖析 HAL_Delay 依赖 SysTick 中断实现的原理,指出当 SysTick 被更高优先级中断抢占时,延时计数会丢失,导致时序漂移。文章提供系统性的排查方法,并给出基于 TIM 定时器、DWT 或裸机忙等待的三种替代方案,附完整代码示例,帮助开发者彻底解决延时精度问题。
# 引言
在 STM32 开发中,`HAL_Delay` 因其简单易用被广泛采用,但不少工程师在调试通信协议或控制时序时发现,延时时间与预期不符,甚至随系统负载变化而漂移。这背后的根源往往不是硬件问题,而是 `HAL_Delay` 的实现机制与中断抢占的相互作用。本文将从原理出发,结合 STM32CubeMX 生成的代码,给出排查步骤和多种替代方案。
# HAL_Delay 的工作原理与缺陷
## 原理回顾
STM32CubeMX 生成的 HAL 库中,`HAL_Delay` 的实现依赖于 SysTick 定时器。SysTick 被配置为 1ms 中断一次,每次中断中 `uwTick` 变量加 1。`HAL_Delay` 的典型代码如下:
```c
__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) {
}
}
```
它通过不断读取 `uwTick` 并计算差值来实现延时。
## 缺陷所在
- `uwTick` 的更新依赖 SysTick 中断,如果 SysTick 中断被更高优先级的中断(如外部中断、定时器中断)长时间抢占,`uwTick` 就无法及时增加,导致 `HAL_Delay` 的实际等待时间变长。
- 即使 SysTick 中断未被完全阻塞,但若中断频繁,`HAL_Delay` 的 while 循环可能错过多次 `uwTick` 更新,造成延时偏大。
- 在临界区(如关闭中断)内调用 `HAL_Delay`,会导致死锁,因为 `uwTick` 永远不更新。
# 排查方法
## 1. 检查中断优先级分组
使用 STM32CubeMX 时,默认将 SysTick 中断优先级设为最低(数值最大)。如果其他中断优先级更高且执行时间较长,就会抢占 SysTick。检查 NVIC 配置:
```c
HAL_NVIC_SetPriority(SysTick_IRQn, 15, 0); // 默认最低优先级
```
## 2. 使用逻辑分析仪或示波器
在延时前后翻转一个 GPIO,观察实际波形。如果高电平时间明显大于预期,且波动明显,基本可判定为 SysTick 被抢占。
## 3. 统计中断耗时
在中断服务函数中记录进入和退出时间,计算总耗时。若某中断耗时超过 1ms,则必然影响 `HAL_Delay`。
## 4. 测试临界区调用
在 `__disable_irq()` 后调用 `HAL_Delay(1)`,若程序卡死,则证实了其依赖中断的本质。
# 替代方案
## 方案一:使用硬件定时器(TIM)
TIM 定时器独立于 CPU,不依赖中断,延时精度高。配置一个基本定时器,如 TIM6,在 STM32CubeMX 中设置预分频和自动重载值,使其溢出周期为 1ms,然后通过查询标志位实现延时。
### 配置步骤
1. 在 STM32CubeMX 中启用 TIM6,时钟源选择 Internal Clock。
2. 设置 Prescaler 为 72-1(假设系统时钟 72MHz),Period 为 1000-1,得到 1ms 中断周期。
3. 生成代码后,编写延时函数:
```c
void TIM_Delay_us(uint16_t us) {
__HAL_TIM_SET_COUNTER(&htim6, 0);
HAL_TIM_Base_Start(&htim6);
while (__HAL_TIM_GET_COUNTER(&htim6) < us);
HAL_TIM_Base_Stop(&htim6);
}
void TIM_Delay_ms(uint16_t ms) {
for (uint16_t i = 0; i < ms; i++) {
TIM_Delay_us(1000);
}
}
```
注意:此方法在延时时占用 CPU,但不受中断影响,适合短延时。
## 方案二:使用 DWT 内核定时器
Cortex-M 内核自带 DWT(Data Watchpoint and Trace)单元,其中的 CYCCNT 计数器可以精确记录 CPU 周期数,且不受中断影响。
### 初始化代码
```c
void DWT_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 ticks = us * (SystemCoreClock / 1000000);
while ((DWT->CYCCNT - start) < ticks);
}
```
- 优点:精度高,不依赖中断,代码简洁。
- 缺点:需要确保 DWT 单元已使能,且 SystemCoreClock 正确。
## 方案三:裸机忙等待(NOP 循环)
对于极短延时(微秒级),可以使用简单的 NOP 循环,但需注意编译器优化。
```c
void NOP_Delay_us(uint32_t us) {
uint32_t n = us * (SystemCoreClock / 1000000) / 4; // 粗略估算
while (n--) {
__NOP();
}
}
```
- 优点:零依赖,任何环境可用。
- 缺点:精度受编译器优化影响,不推荐用于精确延时。
# 完整示例:基于 TIM6 的精确延时
以下是一个完整的示例,展示如何在 STM32F103 上使用 TIM6 实现毫秒级延时,并验证其稳定性。
```c
#include "main.h"
TIM_HandleTypeDef htim6;
void TIM6_Init(void) {
__HAL_RCC_TIM6_CLK_ENABLE();
htim6.Instance = TIM6;
htim6.Init.Prescaler = 72 - 1; // 72MHz/72 = 1MHz
htim6.Init.CounterMode = TIM_COUNTERMODE_UP;
htim6.Init.Period = 1000 - 1; // 1ms
htim6.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1;
HAL_TIM_Base_Init(&htim6);
}
void TIM_Delay_ms(uint16_t ms) {
for (uint16_t i = 0; i < ms; i++) {
__HAL_TIM_SET_COUNTER(&htim6, 0);
HAL_TIM_Base_Start(&htim6);
while (__HAL_TIM_GET_COUNTER(&htim6) < 1000);
HAL_TIM_Base_Stop(&htim6);
}
}
int main(void) {
HAL_Init();
SystemClock_Config();
TIM6_Init();
GPIO_Init(); // 配置LED引脚
while (1) {
HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin);
TIM_Delay_ms(500); // 精确延时500ms
}
}
```
# 注意事项
- 使用 TIM 延时函数时,需确保 TIM 时钟已使能,且预分频值根据实际时钟频率计算。
- DWT 延时在低功耗模式下可能失效,因为 CYCCNT 可能停止计数。
- 在中断服务函数中,尽量避免使用阻塞延时,可改用状态机或定时器回调。
- 如果必须使用 `HAL_Delay`,建议将 SysTick 优先级提升至最高,并确保其他中断执行时间远小于 1ms。
# 总结
`HAL_Delay` 的时序漂移源于其对 SysTick 中断的依赖。通过理解其原理,我们可以根据应用场景选择合适的替代方案:TIM 定时器适合中等精度延时,DWT 适合高精度微秒延时,NOP 循环适合简单场景。在实际项目中,建议优先使用硬件定时器,并避免在中断中调用阻塞延时。希望本文能帮助你彻底解决延时精度问题,提升嵌入式开发的可靠性。