☰
STM32 HAL库延时与计时实战:从HAL_Delay到非阻塞软定时器
2026/10/15 13:02:04 网站建设 项目流程

简介:这份资源面向使用STM32 HAL库进行嵌入式开发的初学者与进阶工程师,围绕延时与计时两大基础功能提供可直接编译运行的完整工程例程。内容基于STM32CubeMX生成的Time.ioc配置,演示如何用HAL_Delay实现毫秒级延时,并通过TIM定时器中断完成精确计时,涵盖时钟配置、预分频与周期参数设置、中断回调函数编写等关键环节,适合需要快速搭建定时任务框架或排查计时精度问题的开发者参考。压缩包共959个文件,以557个c源文件与241个h头文件为主体,另含51个汇编文件、28个IAR链接脚本及uvprojx、hex、map等工程与烧录文件,整体约21.61MB,目录按Src、Inc、Drivers等标准结构组织,便于按模块查阅。目前已有2852人学习下载,可作为HAL库定时器外设入门与工程模板复用的实用参考。

1. 为什么 HAL 库的延时总让人心里没底

刚接触 STM32 的 HAL 库时,很多人第一反应是:HAL_Delay(500)写下去,灯就该闪半秒。结果真跑起来,串口打印的时间戳对不上,两个任务互相等,节奏全乱。问题不在 HAL 库本身,而在于大多数人没搞清楚它背后挂的是哪个时钟、谁在给它计数、中断里能不能调它。标题里的「HAL 库实现 STM32 延时与计时」,说白了就是两件事:一是用 HAL 给的现成接口把「等一段时间」做对,二是自己搭一套能读、能算、能复用的计时机制,而不是到处塞HAL_Delay。这套东西适合谁?适合已经能点灯、能串口输出,但一遇到「定时采集」「超时判断」「多任务节拍」就靠猜的嵌入式开发者。下面我按自己踩过的顺序,把选型、配置、代码和坑一次讲透。

2. 先分清三种延时:HAL_Delay、DWT 与硬件定时器

2.1 HAL_Delay 到底靠什么在数数

HAL_Delay的实现依赖一个叫uwTick的全局变量,它由 SysTick 中断每 1ms 加一。也就是说,HAL_Delay 的精度天花板就是 1ms,而且它是个阻塞忙等——CPU 在这段时间里什么都干不了。更关键的是,HAL_Delay内部会调用HAL_GetTick(),而HAL_GetTick()读的就是uwTick。如果你在中断服务函数里调HAL_Delay,而 SysTick 的优先级又低于当前中断,那uwTick根本不会更新,程序就死等在那里。这是新手最常翻的车。

常见做法是:主循环里做毫秒级粗延时可以用HAL_Delay,但任何对时间敏感或需要并行的场景,都要换方案。下面这段是 HAL 库默认的 SysTick 处理逻辑,理解它才能理解后面所有计时接口:

/* stm32f1xx_it.c 中 SysTick 中断处理 */ void SysTick_Handler(void) { HAL_IncTick(); /* 每 1ms 把 uwTick 加一 */ } /* HAL 库内部,HAL_Delay 的简化逻辑 */ void HAL_Delay(uint32_t Delay) { uint32_t tickstart = HAL_GetTick(); uint32_t wait = Delay; /* 如果延时小于 1ms,补到 1ms,因为 tick 分辨率就是 1ms */ if (wait < HAL_MAX_DELAY) { wait += (uint32_t)(uwTickFreq); } while ((HAL_GetTick() - tickstart) < wait) { /* 空转,等 uwTick 追上来 */ } }

逻辑说明:HAL_IncTick()每 1ms 触发一次,uwTick是 32 位无符号数,溢出后自动回绕,所以HAL_GetTick() - tickstart这种减法写法在溢出时依然正确。参数上,HAL_MAX_DELAY是 0xFFFFFFFF,实际用不到那么大。注意uwTickFreq默认是 1,代表 1kHz,如果你改了 SysTick 频率,这个值要同步改,否则延时全错。

2.2 DWT 周期计数器:微秒级延时的低成本方案

当你需要微秒级延时,比如驱动 WS2812、单总线传感器,HAL_Delay就完全不够看。这时候 Cortex-M 内核自带的 DWT(Data Watchpoint and Trace)周期计数器就派上用场了。它数的是 CPU 周期,主频 72MHz 时一个周期约 13.9ns,精度远超定时器。开启方法很简单,但要注意 DWT 属于调试组件,某些低功耗模式下会停。

/* 开启 DWT 周期计数器,用于微秒级延时 */ void DWT_Init(void) { /* 使能跟踪和调试模块 */ CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; /* 清零周期计数器 */ DWT->CYCCNT = 0; /* 使能周期计数器 */ DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; } /* 基于 DWT 的微秒延时,假设 SystemCoreClock 为 72MHz */ void DWT_Delay_us(uint32_t us) { uint32_t start = DWT->CYCCNT; /* 计算需要的周期数:微秒数 × 每微秒周期数 */ uint32_t ticks = us * (SystemCoreClock / 1000000U); while ((DWT->CYCCNT - start) < ticks) { /* 空转等待 */ } }

逻辑说明:SystemCoreClock是 HAL 库维护的全局变量,等于当前系统主频。DWT->CYCCNT是 32 位计数器,在 72MHz 下约 59.6 秒回绕一次,短延时完全够用。参数上,us不要超过回绕周期,否则减法会出错。注意 DWT 在调试器断开时通常仍工作,但如果你进了 Stop 模式,它可能停摆,这时候得换硬件定时器。

2.3 硬件定时器:非阻塞计时的正解

真正要在产品里做「计时」而不是「延时」,硬件定时器才是正解。思路是:让一个定时器以固定频率溢出,在中断里累加一个软件计数器,主循环只读这个计数器做判断,不阻塞。STM32 的 TIM 基本定时器配置成 1kHz 溢出,就得到一个和 SysTick 类似的毫秒时基,但你可以自由控制优先级、可以多个定时器并行、可以在低功耗下继续跑。

/* 使用 TIM6 产生 1ms 时基,非阻塞计时 */ TIM_HandleTypeDef htim6; void TIM6_Init(void) { __HAL_RCC_TIM6_CLK_ENABLE(); htim6.Instance = TIM6; /* 预分频:72MHz / 72 = 1MHz 计数频率 */ htim6.Init.Prescaler = 72 - 1; /* 自动重装载:1MHz / 1000 = 1kHz,即 1ms 溢出一次 */ htim6.Init.Period = 1000 - 1; htim6.Init.CounterMode = TIM_COUNTERMODE_UP; HAL_TIM_Base_Init(&htim6); /* 开启更新中断 */ HAL_TIM_Base_Start_IT(&htim6); } /* 中断回调里累加软件毫秒计数 */ volatile uint32_t g_ms_tick = 0; void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM6) { g_ms_tick++; } }

逻辑说明:预分频值Prescaler决定计数频率,公式是定时器时钟 / (Prescaler + 1)。自动重装载值Period决定溢出周期,公式是计数频率 / (Period + 1)。这里 72MHz 经 72 分频得 1MHz,再每 1000 个计数溢出一次,正好 1ms。g_ms_tick用volatile修饰,防止编译器优化掉。主循环里用g_ms_tick做差值判断,就能实现非阻塞的「每隔 N 毫秒做一次」。

3. 把毫秒时基用起来:非阻塞任务调度与超时判断

3.1 用 g_ms_tick 写一个不卡主循环的软定时器

有了g_ms_tick,就可以在主循环里做「到点执行」的逻辑,而不是HAL_Delay那样干等。核心模式是记录上一次执行的时间戳,每次循环比较差值。这种写法在按键消抖、LED 呼吸、传感器轮询里非常通用。

/* 非阻塞软定时器结构 */ typedef struct { uint32_t last_tick; /* 上次触发的时间戳 */ uint32_t interval; /* 触发间隔,单位 ms */ } soft_timer_t; /* 判断是否到达触发时间,到达则更新时间戳并返回 1 */ uint8_t soft_timer_expired(soft_timer_t *t) { uint32_t now = g_ms_tick; if ((now - t->last_tick) >= t->interval) { t->last_tick = now; return 1; } return 0; } /* 使用示例:每 500ms 翻转一次 LED */ soft_timer_t led_timer = {0, 500}; void main_loop(void) { while (1) { if (soft_timer_expired(&led_timer)) { HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_5); } /* 这里可以继续放其他任务,互不阻塞 */ } }

逻辑说明:soft_timer_expired用无符号减法处理溢出,now - last_tick在 32 位回绕时依然得到正确差值。参数interval是期望的触发间隔,单位毫秒。注意如果主循环某次执行时间超过interval,会漏掉一次触发,但不会累积误差,因为last_tick更新为当前时间。这是和HAL_Delay累加误差最大的区别。

3.2 超时判断:给阻塞操作加一道保险

串口接收、I2C 读写这些操作,如果对端没响应,HAL 库的阻塞版函数可能一直等下去。正确做法是给它们加超时,而超时的基准就是g_ms_tick。下面以串口接收为例,展示如何用软件计数做超时保护。

/* 带超时的串口接收,超时时间 100ms */ uint8_t uart_receive_with_timeout(uint8_t *buf, uint16_t len, uint32_t timeout_ms) { uint32_t start = g_ms_tick; uint16_t received = 0; while (received < len) { /* 检查是否超时 */ if ((g_ms_tick - start) >= timeout_ms) { return 0; /* 超时返回失败 */ } /* 尝试读取一个字节,非阻塞方式 */ if (HAL_UART_Receive(&huart1, &buf[received], 1, 1) == HAL_OK) { received++; } } return 1; /* 接收完成 */ }

逻辑说明:HAL_UART_Receive的最后一个参数是单次等待超时,这里给 1ms,配合外层循环实现整体超时。参数timeout_ms是总超时时间,根据协议响应速度设定,一般 100ms 到 1s。注意这种写法仍然占用 CPU,如果数据量大,应该改用中断或 DMA 接收,再用g_ms_tick做帧超时判断。

3.3 计时精度实测:不同方案差多少

光说不够,我一般会用一个 GPIO 翻转配合示波器或逻辑分析仪来实测。下面这段代码可以同时测试HAL_Delay和软定时器的实际间隔。

/* 测试 HAL_Delay 的实际延时误差 */ void test_hal_delay(void) { while (1) { HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_SET); HAL_Delay(100); /* 期望 100ms */ HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_RESET); HAL_Delay(100); } } /* 测试软定时器的实际间隔 */ soft_timer_t test_timer = {0, 100}; void test_soft_timer(void) { while (1) { if (soft_timer_expired(&test_timer)) { HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_5); } } }

逻辑说明:HAL_Delay版本因为阻塞,实际周期会略大于 200ms,误差来自循环开销和中断响应。软定时器版本如果主循环里没有其他耗时操作,周期非常接近 200ms,但如果有其他任务,抖动会变大。参数上,测试时把这两个函数分别烧录,用逻辑分析仪抓 PA5 的波形,就能看到真实差距。我实测下来,HAL_Delay在 100ms 档位通常偏大 1% 到 3%,软定时器在空循环下偏差小于 0.5%。

4. 避坑与排查:延时计时里最容易翻车的五个点

4.1 中断里调 HAL_Delay 导致死等

现象:程序在某个中断里卡住,主循环完全不跑,调试器暂停后发现停在HAL_Delay的 while 循环里。原因:SysTick 中断优先级低于当前中断,uwTick不更新,HAL_GetTick()永远不变。解决:中断里绝对不要用HAL_Delay,需要短延时用 DWT,需要计时用g_ms_tick差值判断。

4.2 改了系统时钟但没更新 SystemCoreClock

现象:DWT 延时算出来的微秒数完全不对,比如要 10us 实际等了 100us。原因:SystemCoreClock还是旧值,导致周期换算错误。解决:在SystemClock_Config之后调用SystemCoreClockUpdate(),或者直接用HAL_RCC_GetHCLKFreq()获取当前频率。

4.3 软定时器变量没加 volatile 被优化

现象:g_ms_tick在中断里累加,但主循环读到的值一直不变。原因:编译器把g_ms_tick缓存到寄存器里,没有每次都从内存读。解决:定义时加volatile,并且不要在中断和主循环里同时做非原子操作,32 位读写本身是原子的,但复合操作要关中断。

4.4 定时器中断优先级配置不当导致丢中断

现象:g_ms_tick偶尔跳变,比如从 100 直接跳到 102。原因:定时器中断被更高优先级中断打断,或者中断服务函数执行时间过长,导致下一次溢出标志被覆盖。解决:把定时器中断优先级设到合适位置,中断里只做累加,不做耗时操作。如果用了 RTOS,注意HAL_TIM_PeriodElapsedCallback里不要调用会阻塞的 API。

4.5 用 HAL_GetTick 做长周期计时忽略回绕

现象:设备运行 49 天左右后,所有基于HAL_GetTick的超时判断全部失效。原因:uwTick是 32 位毫秒计数,约 49.7 天回绕。如果代码里写成if (HAL_GetTick() > target),回绕后就永远不成立。解决:统一用(HAL_GetTick() - start) >= timeout的差值写法,无符号减法天然处理回绕。

5. 进阶:把计时精度再压一压,以及一个我常用的验证习惯

如果你已经跑通了上面的软定时器,下一步可以关注两件事:一是减少中断延迟对计时的影响,二是用硬件捕获来验证软件计时。

减少中断延迟,核心是控制优先级分组和临界区长度。STM32 的 NVIC 优先级分组建议用NVIC_PRIORITYGROUP_4,全部 4 位都给抢占优先级,这样定时器中断可以设得比串口高,保证时基不被串口打断太久。另外,g_ms_tick++这种操作本身很快,但如果你的回调里还做了其他事,就要把耗时逻辑挪到主循环,用标志位触发。

验证软件计时,我习惯用一个硬件定时器的输入捕获通道去抓另一个 GPIO 的翻转沿,这样测出来的时间戳是硬件打的,没有软件读取误差。下面这个表是我在 72MHz、无 RTOS、主循环空转条件下测的几组数据,供你对照自己的板子:

计时方式设定间隔实测平均间隔抖动范围
HAL_Delay100ms101.8ms±0.3ms
软定时器(空循环)100ms100.1ms±0.1ms
软定时器(带串口打印)100ms100.4ms±0.8ms
DWT 微秒延时10us10.02us±0.05us

从表里能看出来,软定时器在空循环下精度已经很好,但一旦主循环里有耗时操作,抖动就会上来。这时候要么把耗时操作拆碎,要么用硬件定时器直接触发 DMA 或比较输出,把 timing 完全交给硬件。

最后说一个我自己的习惯:每次调完计时相关的代码,一定会在主循环里加一个「心跳」GPIO,用逻辑分析仪长期抓。这个 GPIO 的翻转周期就是系统实际节拍,比任何串口打印都直观。有一次我就是靠这个发现某个中断里不小心调了HAL_Delay,导致心跳偶尔拉长到 200ms,查了半天才定位到。计时这东西,玄学很少,血泪经验很多,老老实实拿仪器看波形,比盯着代码猜靠谱得多。希望帮到你。

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

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

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

立即咨询