☰
STM32 HAL库延时与计时三种方案:阻塞、超时与DWT微秒级实现
2026/10/19 7:53:07 网站建设 项目流程

简介:面向STM32 HAL库学习与项目开发的工程师,这套资源以延时和计时功能为切入口,提供了从CubeMX图形化配置到代码实现的一整套参考例程。资源包共959个文件,压缩后约21.61MB,核心代码以C源码(557个)和头文件(241个)为主,同时包含汇编启动文件、链接脚本、IOC工程配置、编译输出文件等,完整还原了从芯片初始化到定时器中断触发的开发链路。例程中既演示了基于SysTick的HAL_Delay毫秒级延时调用,也给出了TIM定时器实现精确定时的完整配置方法,包括预分频、自动重载值、中断回调函数等关键步骤,并附有工程说明,便于理解HAL库事件回调机制和定时器工作原理,可直接将相关代码移植到实际项目中复用。已有2853人学习,适合需要快速掌握定时器应用、减少底层配置踩坑的开发者直接参考。

1. HAL库实现STM32延时与计时:三个层次解决你的时间控制痛点

写单片机程序,时间控制永远是绕不开的坎。按键消抖、传感器采样间隔、通信超时判断、PWM周期控制,桩桩件件都得跟延时和计时打交道。用标准库的人习惯了Delay(ms)一把梭,切到HAL库之后很多人第一反应是:HAL_Delay()怎么不准?怎么中断里一调用系统就卡死?网上搜到的微秒延时代码又五花八门,抄过来不是进 HardFault 就是时间差得离谱。

这个项目就是围绕 HAL 库的延时与计时做的一套完整例程,核心解决三件事:用HAL_Delay()做毫秒级阻塞延时、用HAL_GetTick()做非阻塞超时计时、用 DWT 外设做微秒级精确延时。三种方案覆盖了从入门到进阶的全部需求,配合 CubeMX 生成的工程模板,开箱即用。适合正在从标准库过渡到 HAL 库的开发者,也适合产品上需要精确时序控制但又不想引入 RTOS 的裸机开发场景。

2. 工程架构:CubeMX生成的基础设施与文件结构

这套例程不是手工从零敲出来的,而是基于 CubeMX 生成的 HAL 工程再做裁剪和二次封装。这种做法的好处是启动文件、时钟树、外设初始化这些"地基"部分由工具保证正确,开发者只需要关注延时与计时相关的逻辑层。

2.1 CubeMX 生成工程的目录结构

一个标准的 CubeMX 生成工程,目录结构大概是这样的:

project/ ├── Core/ │ ├── Inc/ │ │ ├── main.h │ │ ├── stm32f1xx_hal_conf.h │ │ └── stm32f1xx_it.h │ └── Src/ │ ├── main.c │ ├── stm32f1xx_hal_msp.c │ ├── stm32f1xx_it.c │ └── system_stm32f1xx.c ├── Drivers/ │ ├── CMSIS/ │ │ ├── Device/ │ │ └── Include/ │ └── STM32F1xx_HAL_Driver/ │ ├── Inc/ │ └── Src/ ├── MDK-ARM/ └── project.ioc

Drivers目录下是完整的 HAL 驱动库和 CMSIS 核心文件,这套文件是整个工程的基础。Core/Src/main.c是用户主逻辑的入口,stm32f1xx_it.c是中断服务函数的集合地,SysTick 的中断回调SysTick_Handler()默认就在这个文件里。

project.ioc是 CubeMX 的图形化配置文件,双击它就能重新打开图形界面修改引脚配置。这意味着如果你要换开发板或者改外设,不需要手动改代码,在 CubeMX 里重新生成即可。

2.2 时基初始化:SysTick 怎么在 HAL 里"转"起来

HAL 库的时基系统依赖一个核心全局变量uwTick,它在 SysTick 中断里每 1ms 加一次。HAL_Init()函数内部会调用HAL_InitTick(),这个函数最终通过SysTick_Config(SystemCoreClock / 1000)来配置 SysTick 中断,将中断周期设为 1ms。

// 在 stm32f1xx_hal.c 中 __weak HAL_StatusTypeDef HAL_InitTick(uint32_t TickPriority) { /* 配置 SysTick 中断周期为 1ms */ if (SysTick_Config(SystemCoreClock / 1000U) > 0U) { return HAL_ERROR; } /* 设置 SysTick 中断优先级 */ HAL_NVIC_SetPriority(SysTick_IRQn, TickPriority, 0U); /* 返回成功 */ return HAL_OK; }

这里的关键参数是SystemCoreClock / 1000U。SystemCoreClock是系统时钟频率,假设你的芯片跑在 72MHz,那72MHz / 1000 = 72000,即 SysTick 计数器从 72000 倒数到 0 正好是 1ms。如果你改了系统时钟频率,但没有同步更新SystemCoreClock这个全局变量的值,那么所有基于HAL_GetTick()的延时和超时都会线性出错。

TickPriority参数决定 SysTick 中断的优先级优先级,CubeMX 默认配置为TICK_INT_PRIORITY,数值通常是0x0F。这个值在 STM32 的 NVIC 中优先级数值越大优先级越低,所以默认情况下 SysTick 是低优先级中断,这也就为后面说的"中断里调用HAL_Delay()导致卡死"埋下了伏笔。

2.3 中断服务函数:uwTick的累加链路

SysTick 中断触发后,中断服务函数会调用 HAL 库的回调函数来完成uwTick的自增。在stm32f1xx_it.c中,你能看到这样的实现:

// stm32f1xx_it.c void SysTick_Handler(void) { HAL_IncTick(); }

而HAL_IncTick()的实现就更简单了,在stm32f1xx_hal.c中:

void HAL_IncTick(void) { uwTick += 1UL; }

这行代码就是整个 HAL 延时系统的"心脏"。每次 SysTick 中断触发,uwTick加一,HAL_GetTick()返回当前值,HAL_Delay()通过不断比较当前值和目标值来实现阻塞延时。

__weak void HAL_Delay(uint32_t Delay) { uint32_t tickstart = HAL_GetTick(); uint32_t wait = Delay; /* 等待直到达到目标时间 */ while ((HAL_GetTick() - tickstart) < wait) { } }

这个减法写法(HAL_GetTick() - tickstart) < wait其实是精心设计的,它天然处理了uwTick回绕的问题。就算HAL_GetTick()计数溢出回到 0,只要差值达到目标值,循环依然会退出。这是值得学习的健壮写法。

3. 核心模块拆解:三种延时与计时方案的完整实现

这一章是整个例程的重头戏。项目提供了三套可独立使用的延时/计时方案,分别面向不同的应用场景。先从大家最常用的阻塞延时讲起,再深入到微秒级延时和基于状态机的非阻塞计时思路。

3.1 阻塞延时:基于HAL_Delay()的按键消抖

按键消抖是嵌入式最常见的场景。机械按键按下和释放的瞬间会存在 5ms~20ms 的电平抖动,典型处理方式就是延时 + 二次确认。项目中用HAL_Delay()实现了一个标准的消抖读取函数:

// 按键消抖:按下返回 1,未按下返回 0 uint8_t Key_Scan(void) { static uint8_t key_up = 1; // 按键释放标志 if (key_up && HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin) == GPIO_PIN_RESET) { HAL_Delay(20); // 延时 20ms 跳过抖动区间 key_up = 0; // 标记按键已按下 if (HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin) == GPIO_PIN_RESET) { return 1; // 确认按下 } } else if (HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin) == GPIO_PIN_SET) { key_up = 1; // 按键释放 } return 0; }

这段代码的逻辑分两个阶段:第一次检测到低电平时不急着确认,先HAL_Delay(20)跳过抖动窗口,然后再读一次引脚状态;如果还是低电平,说明按键确实被按下,返回有效值。key_up是关键边沿检测标志,它保证一次按下只触发一次动作,防止按住不松手时重复触发。

参数上需要注意两点:20这个消抖延时不是固定的,有的按键抖动时间长,可能需要加大到 30ms 甚至 50ms;而如果按键接了 RC 滤波电路,抖动已经被硬件吃掉,延时可以缩到 5ms 以下。消抖延时的本质是等待信号稳定,不是越快越好。

这种阻塞消抖的缺点是显而易见的:20ms 内 CPU 被HAL_Delay占住,无法响应其他任务。所以在带了屏幕显示、通信处理等复杂逻辑的项目里,我更倾向于把消抖改成非阻塞状态机,这个后面在改造技巧章节再展开。

3.2 非阻塞计时:用HAL_GetTick()做超时判断

通信协议里的"等待应答超时"是HAL_GetTick()最经典的用武之地。阻塞式HAL_Delay()做不了超时判断,因为它必须等够指定的时间才返回,而实际场景往往是"在 100ms 内等到应答就继续,等不到就超时重发"。

项目里封装了一个通用的非阻塞等待接口:

// 非阻塞等待:在超时时间内检测标志位 uint8_t Wait_Flag_With_Timeout(volatile uint8_t *flag, uint32_t timeout_ms) { uint32_t tickstart = HAL_GetTick(); /* 循环等待标志位被置位,同时检查超时 */ while (*flag == 0) { if ((HAL_GetTick() - tickstart) >= timeout_ms) { return 0; // 超时返回 0 } } return 1; // 在超时时间内标志被置位 }

调用方式很直接:在发送完一帧数据后,把接收完成标志位清零,然后调用这个等待函数。100ms 内收到了数据,函数返回 1,继续处理应答;超时了返回 0,走重发逻辑。

这里有两个容易被忽略的点。一是flag参数必须声明为volatile,因为这个标志位是在中断服务函数里被修改的,如果不加volatile,编译器可能会把*flag优化到寄存器里,主循环永远读不到最新值。这是很多人写超时等待翻车的直接原因。二是tickstart局部变量保存的是进入函数时刻的 tick 值,每次循环都重新计算差值,而不是累加计数,这种写法避免了长时间运行后HAL_GetTick()回绕带来的判断错误。

3.3 微秒级延时:DWT 外设实现精确脉冲控制

HAL_Delay()的精度上限是 1ms,对于 DHT11、DS18B20 这类需要微秒级时序配合的传感器就无能为力了。项目里用 ARM Cortex-M 内核自带的 DWT(Data Watchpoint and Trace)单元实现了微秒级延时,不占用额外定时器。

// 使用 DWT 实现微秒延时 void delay_us(uint32_t us) { /* 检查 DWT CYCCNT 是否使能,未使能则初始化 */ if (!(DWT->CTRL & DWT_CTRL_CYCCNTENA_Msk)) { CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CYCCNT = 0; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; } uint32_t start = DWT->CYCCNT; uint32_t ticks = us * (SystemCoreClock / 1000000U); // 计算周期数 /* 等待计数器达到目标 */ while ((DWT->CYCCNT - start) < ticks) { } }

DWT 的原理是它在每个 CPU 时钟周期自动加一,DWT->CYCCNT就是当前已执行的时钟周期数。要延时us微秒,只需要计算us * (CPU频率 / 1000000)得到需要等待的周期数,然后轮询比较当前计数值和起始值的差值即可。

关键参数说明:

  • CoreDebug_DEMCR_TRCENA_Msk:必须置位,这是 DWT 功能的总开关,不打开这个位 DWT 寄存器是禁用的
  • DWT_CTRL_CYCCNTENA_Msk:CYCCNT 计数器的使能位
  • SystemCoreClock / 1000000U:每微秒对应的时钟周期数

这套方案在 72MHz 主频下精度能达到亚微秒级别,而且被多个知名开源库(例如某些传感器驱动库)广泛采用,可靠性经过验证。唯一代价是占用了一个调试外设,好在 DWT 在绝大多数型号的 Cortex-M3/M4/M7 内核上都是标配,不占用 TIM 资源。如果你用的芯片比较特殊,DWT相关寄存器可能在头文件里没有定义,那就得退回用 TIM 做微秒延时的方案了。

3.4 计时逻辑的实际应用:测量一段代码的执行耗时

延时的反面是计时——测量某段代码跑了多长时间,这在性能调优和协议时序分析时非常常用。项目里提供了一个极简的执行耗时测量函数:

// 测量代码块执行耗时(单位:us) uint32_t measure_exec_time(void (*func)(void)) { /* 初始化 DWT */ CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CYCCNT = 0; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; uint32_t start = DWT->CYCCNT; func(); // 执行被测函数 uint32_t cost = DWT->CYCCNT - start; return cost / (SystemCoreClock / 1000000U); // 转换为微秒 }

这种测量方式和硬件仿真器里的clock功能原理一样,区别在于这是运行时的实时测量,不打断程序流程。我在调优一段浮点运算代码时靠它发现,不同编译优化等级下同一段代码的执行时间能差出 3 倍以上,这种差异靠肉眼读仿真器是看不出来的。

4. 避坑指南:HAL 延时与计时的常见问题排查

这个项目里踩过的坑比写出来的代码多得多。把最常见的问题整理成清单,基本覆盖了 HAL 延时体系从入门到进阶的所有翻车现场。

4.1 现象:中断服务函数里调用HAL_Delay()导致系统卡死

现象:在某个外设的中断回调里写了HAL_Delay(10)做时序控制,程序运行到这一刻直接死机,表现为主循环不再执行,调试器暂停后发现程序停在HAL_Delay()的 while 循环里。

原因:HAL_Delay()的实现是忙等待,它依赖 SysTick 中断来递增uwTick。如果当前中断的优先级高于 SysTick,那么在中断服务函数执行期间,SysTick 中断无法抢占,HAL_GetTick()永远返回旧值,while循环条件永远不满足,程序就在中断里空转。

解决:这是优先级设计问题,不是 HAL 库的 bug。最好是在中断里只置标志位,把延时的逻辑挪到主循环处理。如果实在需要在中断里延时,那就要故意把 SysTick 中断优先级设得比当前中断高,但这会带来系统时基抖动,一般不推荐。

4.2 现象:HAL_Delay(1000)实际耗时远超 1 秒

现象:明明延时参数传的是 1000,用示波器量引脚电平翻转周期,发现实际间隔是 1.5 秒甚至更久。

原因:最常见的是时钟树配置变了但SystemCoreClock变量没同步。比如在 CubeMX 里把系统时钟从 72MHz 改到了 48MHz,但代码里的SystemCoreClock还是 72MHz 的值。SysTick_Config(SystemCoreClock / 1000)算出来的重装载值偏大了,SysTick 中断周期变成 1.5ms,所有时间量都线性放大。

解决:检查SystemCoreClock的值是否和实际运行的时钟频率一致。在 CubeMX 生成的工程里,SystemClock_Config()函数执行完后应该调用SystemCoreClockUpdate()来刷新这个全局变量。如果手动改过时钟配置,必须确保这两个地方一致。

4.3 现象:编译优化开启后延时紊乱、时间全乱

现象:Debug 模式下一切正常,切到 Release 模式开了-O2优化后,某些延时突然变得极短或者干脆失效。

原因:典型的volatile缺失问题。如果有变量在中断和主循环之间共享(比如超时标志位),编译器在优化模式下会把它缓存到寄存器里,导致循环内每次都读到旧值。

解决:共享变量一律加volatile修饰。我一般会刻意养成"凡是中断里修改、主循环里读取的变量,定义时直接加volatile"的习惯,省得后面排查这类玄学问题。

4.4 现象:HAL_GetTick()返回值溢出导致超时提前触发

现象:系统连续运行几天后,某个通信模块开始频繁超时重传,复位后恢复正常,过几天又出现。

原因:uwTick是一个 32 位变量,在 1ms 递增的情况下,约 49.7 天后会回绕到 0。如果超时判断写成了HAL_GetTick() < tickstart + timeout,那么溢出瞬间这个判断会提前成立。

解决:使用差值判断(HAL_GetTick() - tickstart) < timeout,这个写法在溢出前后都能正确处理。项目中所有超时判断都统一采用了差值方式,这个习惯是从第一次踩了溢出坑之后养成的。

4.5 现象:移植到 RTOS 工程后调度异常,任务不切换

现象:把这段代码移植到 FreeRTOS 工程里,创建了多个任务,但运行一段时间后任务调度停止,低优先级任务饿死。

原因:HAL 库的 SysTick 时基和 FreeRTOS 的 SysTick 时基冲突了。两个组件都在抢占 SysTick 中断,导致中断处理错乱,时间片轮转失效。

解决:在 CubeMX 的 SYS 配置里把 Timebase Source 从SysTick改成一个基本定时器(比如 TIM6 或 TIM7),让 HAL 库用定时器做时基,SysTick 专门留给 FreeRTOS 做调度。这也是 CubeMX 图形界面里专门留了一个 Timebase 选项的原因,很多人忽略了这个配置直接生成工程,后面就出了上面这个问题。

5. 二次开发技巧:把阻塞延时改造成非阻塞调度的完整思路

拿到这套例程之后,如果只是抄走delay_us()函数,那格局就小了。真正有价值的是理解它封装的思路,把它用在自己的项目架构里。这里给三个可以直接落地的改造方向。

5.1 从HAL_Delay到状态机式非阻塞延时

产品代码里最忌讳的就是主循环里到处是HAL_Delay(),一个 200ms 的阻塞延时会卡掉后面所有任务的执行窗口。改造的思路是用一个"定时器节点"结构体管理多个延时任务。

以 LED 闪烁为例,阻塞版的写法是:

while (1) { HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin); HAL_Delay(500); }

改成非阻塞版本:

typedef struct { uint32_t start_tick; uint32_t interval; uint8_t active; } timer_node_t; // 定义两个独立的延时任务 timer_node_t led_timer = {0, 500, 1}; timer_node_t key_timer = {0, 20, 0}; // 非阻塞延时函数:到达时间返回 1 uint8_t timer_expired(timer_node_t *node) { if (!node->active) return 0; if ((HAL_GetTick() - node->start_tick) >= node->interval) { node->start_tick = HAL_GetTick(); // 重新计时 return 1; } return 0; } // 主循环中应用 while (1) { if (timer_expired(&led_timer)) { HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin); } if (timer_expired(&key_timer)) { Key_Scan_NonBlocking(); } }

改造之后,HAL_Delay彻底从主循环消失,每个任务都在检查自己的"定时器节点"是否到期,到期就执行,没到期就立即返回。主循环的单次迭代耗时从几百毫秒缩短到几微秒,等于给所有任务让出了 CPU 时间。对于带屏幕刷新、按键扫描、通信轮询的复杂裸机程序,这种结构是必须的。

5.2 扩展微秒延时为周期脉冲发生器

delay_us()的一个重要变体是产生特定频率的脉冲信号,这在驱动步进电机或者红外发射管时非常常用。把阻塞延时改成周期控制:

// 输出指定频率的方波,period_us 为半周期 void pulse_generate(uint32_t half_period_us, uint32_t pulse_count) { for (uint32_t i = 0; i < pulse_count; i++) { HAL_GPIO_WritePin(PULSE_GPIO_Port, PULSE_Pin, GPIO_PIN_SET); delay_us(half_period_us); HAL_GPIO_WritePin(PULSE_GPIO_Port, PULSE_Pin, GPIO_PIN_RESET); delay_us(half_period_us); } }

需要注意delay_us()本身是忙等待,生成脉冲期间 CPU 不能做其他事。但步进电机控制通常要求时序精确、不容打断,忙等待反而是优点。如果脉冲频率非常高(半周期只有几微秒),建议把delay_us()改成关中断保护,避免被 SysTick 中断插入导致脉冲宽度抖动。

5.3 正确地把这套代码嵌入自己的工程

最后说下工程级的整合实操。如果你要把这套延时/计时代码塞进一个已经存在的 HAL 工程里,有几步是必须做的:确保 CubeMX 里 SYS 的 Timebase Source 没有被配置成禁用状态,否则HAL_InitTick()会失败导致HAL_Init()直接返回错误。把delay_us()和Wait_Flag_With_Timeout这类函数统一放在一个新的delay.c文件里,加上头文件声明,不要堆在main.c里。验证时先测一个 LED 翻转,确认基本延时正常,再用示波器验证微秒延时。

那次我把一套基于这套思路的延时模块从 F1 系列移植到 L0 系列低功耗芯片,直接在main.c里复制粘贴,结果delay_us()里依赖的 DWT 寄存器地址在两个系列间不兼容,代码跑起来直接进 HardFault。从那以后,我每次移植到新芯片型号,都会强制走一遍"先查内核型号、再确认 DWT 支持、最后写测试用例验证"的流程,从根源上杜绝这类低级错误。

希望这套例程和这些改造思路能帮你在 HAL 库的时间控制上少走弯路,把更多精力花在实际业务逻辑上。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询