HAL库实现STM32延时与计时:从SysTick到定时器的完整指南

发布时间:2026/9/8 1:40:30
HAL库实现STM32延时与计时:从SysTick到定时器的完整指南 简介这份基于STM32 HAL库的延时与计时例程面向嵌入式初学者与快速上手开发者展示如何利用CubeMX图形化配置定时器并借助HAL_Delay与定时器中断实现毫秒级延时和精确计时可满足实时控制、周期性任务等常见场景需求。压缩包共含959个文件总大小21.61MB以C源码、头文件、汇编启动文件及工程配置为主另有编译生成的.o/.axf/.hex等构建产物目录结构完整适合直接导入STM32CubeMX与Keil/IAR工程学习。资源已有2847人学习下载例程不仅给出main.c中的延时调用示例还包含定时器初始化、中断回调函数写法及Time.ioc配置文件便于对照理解外设初始化流程与分频参数设置。通过这套例程读者可快速掌握HAL库定时器的标准配置思路减少底层寄存器调试时间为后续项目打下基础。 做嵌入式开发的人不管用没用过HAL库大概率都跟延时和计时打过交道。尤其是从标准库切到HAL库之后很多人第一反应就是“延时怎么又卡死了”“GetTick怎么不准了”这些坑我在项目里踩过不少也帮别人排查过不少。这篇文章不聊虚的直接围绕“HAL库实现Stm32延时与计时”这件事把我实际用下来觉得最关键、最容易被忽略的东西讲清楚。内容包括HAL_Delay背后的SysTick机制、为什么它会卡死、怎么用基本定时器做微秒级延时、怎么用HAL库的接口搭一个可靠的计时方案最后再把我遇到过的典型问题和排查思路整理出来。适合刚接触HAL库的新手也适合那些被延时问题折腾过的老手来对一下思路。1. 为什么在HAL库下延时和计时要单独认真对待1.1 标准库和HAL库在延时问题上的本质差异很多从标准库转过来的朋友最直观的感受是“以前用Delay延时没啥事换了HAL库怎么动不动就死机”。这不是错觉而是两套库的设计思路完全不同。标准库里大家习惯用的延时函数本质就是一个简单的循环计数比如经典的void Delay(uint32_t nCount) { while(nCount--); }这种写法不依赖任何硬件资源CPU就在那里空转。问题也很明显不同主频下延时时间完全不一样换个芯片或者改个晶振延时时间就变了而且编译器优化等级一改循环时间也跟着变极不稳定。HAL库的HAL_Delay则是基于SysTick系统节拍来实现的。SysTick是一个24位的递减计数器被配置成固定的节拍频率默认是1ms中断一次HAL库在中断里维护一个全局的Tick变量。HAL_Delay做的事情就是不断查询这个Tick值有没有走到目标位置。理论上这套机制更科学延时精度不受编译器优化影响。但问题恰恰出在“中断”这两个字上——HAL_Delay依赖SysTick中断来累加Tick值如果SysTick中断被更高优先级的中断长期抢占或者你在其他中断里调用了HAL_Delay就可能出现延时卡死的现象。这个我在后面会单独展开讲。1.2 项目里到底需要哪些延时和计时场景在切入代码之前先梳理一下实际项目中会遇到的需求类型这样才能理解为什么不能只靠一个HAL_Delay打天下。第一类是纯阻塞式延时也就是“延时期间什么都不干”比如初始化传感器时等待上电稳定、等待某个时序信号完成。这类需求对延时的要求是“简单可靠”但对延时的精度要求并不高差不多就行。第二类是精准时序控制比如驱动DHT11温湿度传感器、WS2812灯带这类对时序有严格要求的器件。DHT11要求主机拉低总线至少18ms然后释放并等待响应而WS2812对单个码元的时序要求甚至精确到几百纳秒。这类场景下HAL_Delay完全不够用必须使用定时器或SysTick的微秒级延时方案。第三类是计时/超时判断比如等待串口数据接收完成、等待某个外设响应。这类场景的核心不是“延多久”而是“在一定时间内持续检测某个条件是否满足”。用HAL_GetTick来实现超时判断是最常见的做法。第四类是高精度测量场景比如频率测量、脉宽测量需要用到定时器输入捕获功能。这类场景我放在后面讲计时方案的时候再具体展开。从这几类需求就能看出来单纯的HAL_Delay解决不了所有问题。合理的做法是构建一个层级化的延时计时体系毫秒级用HAL_Delay微秒级用定时器或SysTick优化计时和超时用HAL_GetTick或定时器计数器。2. HAL_Delay与SysTick的核心机制与避坑要点2.1 HAL_Delay内部到底怎么工作的先来看HAL库中HAL_Delay的实现这是理解一切问题的基础。不同版本的HAL库实现略有差异但核心逻辑是一样的__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中断里被。tickstart记录的是进入函数时的Tick值然后死循环不断用当前Tick减去tickstart直到差值大于等于要延时的时间。这个写法有一个精妙之处它用无符号整数减法来处理Tick回绕。假设tickstart是0xFFFFFFF0当前Tick回绕到0x00000010两者相减得到的还是0x20也就是32ms计算不会出错。所以只要延时的毫秒数不超过0xFFFFFFFF约49.7天这个减法逻辑就是安全的。但是有一个边界条件需要注意HAL_Delay对wait的处理。如果Delay的值小于HAL_MAX_DELAY它会加上一个节拍数一般等于1这是为了补偿从读取tickstart到进入循环之间的时间差。这个细节很多文档不会提但实际调试的时候会发现某些版本的库延时总是比预期多1ms或少1ms往往就是这个补偿逻辑导致的。2.2 为什么延时函数会卡死说完了正常机制再来看大家最头疼的卡死问题。搜索热词里出现“stm32延时函数delay卡死”不是偶然这个问题在HAL库环境下确实高频发生。第一个典型场景是在中断服务函数里调用HAL_Delay。假设你的串口接收中断优先级比SysTick高在串口中断处理中调用HAL_Delay而SysTick中断因为优先级低一直被抢占Tick值始终不更新HAL_Delay里的while循环条件永远不满足就死等在那里了。更糟糕的是如果其他中断也被阻塞整个系统就崩溃了。第二个典型场景是SysTick中断被关闭或异常挂起。有些代码为了某些敏感时序操作会执行__disable_irq()关闭全局中断如果这时候调用HAL_Delay同样会因为Tick不更新而死循环。第三个场景和FreeRTOS等操作系统有关。如果在FreeRTOS下使用HAL_Delay默认情况下SysTick已经被OS接管了HAL_Delay不再累加Tick直接卡死或延时时间错乱。这个我后文会提一下处理方法。避坑的核心原则HAL_Delay只建议在主循环或低优先级任务中调用中断里如果要延时改用定时器阻塞等待或者干脆设计成非阻塞状态机如果必须在中断里短延时用下面的微秒级延时函数替代。2.3 SysTick优先级和重映射问题还有一个容易踩的坑是SysTick中断优先级配置。STM32的Cortex-M内核中SysTick中断的优先级是通过NVIC配置的。HAL库默认在HAL_Init里调用了HAL_InitTick把SysTick配置为最低优先级。这在裸机环境下问题不大但如果你的系统里有对响应时间要求很高的中断低优先级的SysTick中断可能会被延迟响应导致HAL_Delay的时间偏长、不稳定。解决方法是提高SysTick的抢占优先级或者对延时精度要求高的场景干脆不用SysTick改用硬件定时器。另外HAL_Delay与SysTick的关系需要注意SysTick除了用于HAL_Delay还被HAL_GetTick用来提供系统运行时间。如果你在某个时刻重新初始化了SysTick或修改了系统时钟会导致Tick计数错乱所有基于HAL_GetTick的超时判断都可能出错。我在做低功耗唤醒的时候就踩过这个坑唤醒后时钟切换Tick值从错误的地方继续跑串口超时判断直接失效。3. 基于硬件定时器的精准延时与计时方案3.1 为什么需要微秒级延时前面说了HAL_Delay只能提供毫秒级延时但很多外设的时序要求精确到微秒甚至几百纳秒。比如DHT11需要微秒级延时来控制总线时序DS18B20的时序要求每个时隙都是微秒级别的WS2812信号灯带0码和1码的脉宽差异只有几百纳秒模拟I2C协议时标准模式要求SCL时钟周期不低于4.7us快速模式下也要1.3us左右。这些场景如果用HAL_Delay完全无法满足。解决办法有两个思路一是直接操作SysTick把SysTick的节拍临时改为1us二是用通用计时器TIM实现微秒级延时。我实际项目里推荐用基本定时器如TIM6、TIM7因为通用定时器常常被分配给PWM输出、输入捕获等功能基本定时器反而空闲。3.2 用基本定时器实现微秒级延时这个方法的核心思路是把定时器的时钟配成1MHz也就是计数一次正好1us然后阻塞等待计数器走到目标值。以STM32F103C8T6为例APB1总线的最高频率是36MHz定时器时钟经过倍频后可达72MHz。要把72MHz分频到1MHz预分频值设置为72-1即可。来看一个我一直在用的实现基于TIM6void TIM6_Init(void) { __HAL_RCC_TIM6_CLK_ENABLE(); TIM6-PSC 72 - 1; // 72MHz / 72 1MHz计数1次 1us TIM6-ARR 0xFFFF; // 最大计数值方便任意延时 TIM6-CR1 | TIM_CR1_URS; // 仅更新事件触发中断不做普通中断这里只是配置 TIM6-EGR TIM_EGR_UG; // 产生一次更新事件使PSC立即生效 TIM6-CR1 | TIM_CR1_CEN; // 使能定时器 } void delay_us(uint16_t us) { TIM6-CNT 0; // 计数器归零 while (TIM6-CNT us); // 等待计数值到达目标 } void delay_ms(uint16_t ms) { while (ms--) { delay_us(1000); } }这个实现用到的寄存器操作很少但效果非常稳定。关键点在于TIM6-EGR TIM_EGR_UG这一行很多新手容易漏掉。PSC寄存器写入后不会立即生效需要软件触发一次更新事件预分频器才会按新值重新开始计数。不加这一句的话第一次延时的实际时间可能是错误的。关于延时精度有朋友说用这种阻塞延时也会偏大。实测下来的主要误差来源是调用函数本身有数条指令的开销通常在几百ns到1us不等以及TIM6-CNT清零到进入while循环之间也有几条指令。所以最前面几个微秒的延时会有微小偏差。纠正方式也很简单进入循环前加一条__NOP()或者把延时值减去一个固定补偿量。不过说实话对于绝大多数外设驱动场景1-2us的误差完全可以接受不必过度补偿。3.3 用定时器实现高精度计时频率测量、脉宽测量说完延时再来说计时。延时是“让系统等待一段已知时间”计时是“测量一段未知时间”。在嵌入式里用的最多的计时需求就是测量外部信号的频率和脉宽。STM32的定时器普遍支持输入捕获功能可以记录外部信号发生跳变时的计数器值。通过两次捕获值的差值就能计算出信号周期。以TIM2的通道1为例配置步骤如下把定时器时钟配到合适的频率比如1MHz这样每个计数单位就是1us在STM32CubeMX里将TIM2 Channel1配置为Input Capture direct mode把预分频值设为72-1得到1MHz的计数频率使能捕获中断在中断回调里读取ICR值两次捕获值相减即可。核心代码如下// 捕获中断回调 void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM2) { static uint16_t last_capture 0; uint16_t now_capture __HAL_TIM_GET_COMPARE(htim, TIM_CHANNEL_1); if (now_capture last_capture) { period_ticks now_capture - last_capture; } else { period_ticks (0xFFFF - last_capture) now_capture; // 计数器回绕处理 } last_capture now_capture; } }注意计数器回绕的问题。16位定时器在1MHz计数频率下最多只能测量约65ms的周期超过这个范围就会因为计数器回绕而无法区分。解决方法是级联一个更高位的软件计数器在更新中断里对回绕次数进行统计把16位扩展成32位这样可测范围就大大增加了。4. 实战搭建一套完整的延时与计时例程4.1 使用STM32CubeMX快速配置既然是HAL库的例程那必然要提CubeMX。我一般建议在CubeMX里先生成工程骨架把外设时钟、串口、引脚配置都自动生成了然后再手动添加延时计时相关的代码。在CubeMX里的配置要点如下选择芯片型号比如STM32F103C8T6配置时钟树把系统时钟配置到最高频率F103一般是72MHz需要用到定时器的话在Timers里勾选TIM6或TIM7只开启时钟其他不用配置需要计时功能的话配置TIM2为Input Capture模式如果需要调试串口输出结果配置一个UART比如USART1波特率115200。时钟树配置这里有个注意点APB1定时器时钟的倍频关系在不同系列芯片上不一样。F1系列的定时器时钟默认是APB1的两倍而F4系列则不一定是两倍得看具体的时钟树配置。最好在CubeMX的Clock Configuration页面里实际看一下定时器时钟是多少MHz再反推PSC值不要照搬我上面写的72-1。生成工程后代码结构基本是这样int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART1_UART_Init(); MX_TIM6_Init(); // 基本定时器初始化 MX_TIM2_Init(); // 输入捕获初始化 TIM6_Init(); // 手动配置PSC和使能 HAL_TIM_IC_Start_IT(htim2, TIM_CHANNEL_1); // 启动输入捕获中断 while (1) { // 主循环 } }4.2 增加支持HAL_Delay与精确实时的通用延时模块这里我建议把所有延时计时的接口统一封装供整个工程共用一个模块。实际项目中最好是新建一个delay.c/delay.h里面提供以下接口delay_us(uint16_t us)基于TIM6的微秒级延时delay_ms(uint16_t ms)毫秒级延时内部调用delay_usHAL_Delay重写我一般把HAL库自带的HAL_Delay给override掉改成基于定时器实现的版本这样所有第三方库代码里调用的HAL_Delay也能用到新实现不用大改业务代码。这里有个HAL库的细节HAL_Delay前面有__weak修饰符意思是你可以自己实现一个同名函数来覆盖它。我把HAL库自带实现注释掉替换成自己基于TIM6的版本void HAL_Delay(uint32_t Delay) { while (Delay--) { delay_us(1000); } }这样做的一个附加好处是HAL_Delay不再依赖SysTick中断即使SysTick被操作系统接管比如跑FreeRTOS时我的HAL_Delay依然能正常工作。当然这样做也会带来一个影响如果系统里还有其他代码依赖HAL_GetTick来获取系统时间那么HAL_Delay和HAL_GetTick就不再走同一个时基了。如果同时使用这两个功能建议还是保留HAL库原来的HAL_Delay另外起名叫Delay_Ms避免混用导致逻辑错乱。4.3 增加非阻塞延时与超时判断的推荐写法很多刚开始用HAL库的人写代码喜欢把所有事情都放在大循环里顺序执行。一旦某个传感器初始化需要等待响应整个系统就卡在那里了。更合理的做法是用“非阻塞延时”的思路先记录一个起始时间然后不断检查时间差一旦超时立即跳出。HAL_GetTick最适合用来做这种非阻塞操作。我举一个串口等待响应的例子uint32_t start_tick HAL_GetTick(); uint8_t response_received 0; while ((HAL_GetTick() - start_tick) 100) // 等待最多100ms { if (uart_rx_flag 1) { response_received 1; break; } } if (response_received) { // 处理收到的数据 } else { // 超时处理 }这个写法的优势在于等待期间CPU可以继续响应其他中断和执行其他逻辑不会死等。对触摸按键、蓝牙AT指令、传感器应答等场景都适用。需要注意的是HAL_GetTick的返回值类型是uint32_t超过49.7天后会回绕但用无符号减法判断超时的话回绕问题不影响正确性这一点和HAL_Delay内部逻辑是一致的。4.4 实际例程DHT11温湿度读取中的延时组合纸上谈兵没意思我拿DHT11来串一遍完整流程这个器件对延时的要求比较典型能同时用上微秒级延时、毫秒级延时和非阻塞超时判断。DHT11的通信时序是主机先把总线拉低至少18ms然后释放并延时20-40us等待从机响应。从机回复一个80us的低电平响应信号然后拉高80us再开始输出40bit数据。每个bit的表示方式是50us低电平后跟着26-28us的高电平表示050us低电平后跟着70us高电平表示1。用我们的延时模块来写关键部分void DHT11_Start(void) { gpio_set_output(DHT11_PIN); // 配置为输出 HAL_GPIO_WritePin(DHT11_PORT, DHT11_PIN, GPIO_PIN_RESET); delay_us(20000); // 拉低至少20ms HAL_GPIO_WritePin(DHT11_PORT, DHT11_PIN, GPIO_PIN_SET); delay_us(40); // 释放等待响应 } uint8_t DHT11_ReadBit(void) { uint8_t bit; while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) GPIO_PIN_SET); // 等待低电平开始 // 延时约28us后采样 delay_us(28); if (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) GPIO_PIN_SET) { bit 1; while (HAL_GPIO_ReadPin(DHT11_PORT, DHT11_PIN) GPIO_PIN_SET); // 等待高电平结束 } else { bit 0; } return bit; }这个例子里开始信号用毫秒级延时位采样用微秒级延时。如果你的延时函数精度不够DHT11读出来的数据就会各种错乱温度湿度跳来跳去。我遇到很多“DHT11读数不稳定”的求助帖最后排查下来都是延时精度问题。换上我上面的TIM6延时方案之后基本一次通过。5. 常见问题与排查技巧实录5.1 高频问题速查表根据我自己的项目经验和在技术社区看到的高频求助整理了一组延时计时相关的典型问题直接做成表格方便对照排查。现象可能原因排查方式与解决方法程序卡死在HAL_DelaySysTick中断未运行或优先级过低检查是否有__disable_irq()查看NVIC中SysTick优先级不要在中断里调用HAL_Delay延时时间偏差大偏长/偏短时钟树配置错误、PSC计算不对用CubeMX确认外设时钟频率计算PSC时注意倍频关系移植到F4/F7后延时不准F4系列APB1定时器时钟倍频与F1不同不要照搬F1的72MHz查看实际时钟频率后重算PSC使用FreeRTOS后HAL_Delay卡死SysTick被FreeRTOS接管使用osDelay替代HAL_Delay或重写HAL_Delay基于定时器实现DHT11/DS18B20数据错乱微秒级延时精度不足使用硬件定时器延时替代HAL_Delay输入捕获测的频率值偏大未处理计数器回绕增加更新中断扩展为32位计数器各种外设偶发超时HAL_GetTick被干扰影响超时判断检查SysTick配置和中断优先级改用专门的硬件定时器5.2 排查思路和工具建议排除定时问题我个人的经验是从三个层面依次排查。第一层先确认时钟配置对不对。用CubeMX的时钟树页面确认系统时钟和各个总线时钟尤其是TIMx的时钟来源。这里有个小技巧直接给定时器输出一个方波信号用逻辑分析仪或示波器看频率如果和预期一致说明时钟链路没问题。第二层确认延时函数的实际时间是准的。写一个测试程序翻转一个GPIO比如while (1) { HAL_GPIO_WritePin(LED_PORT, LED_PIN, GPIO_PIN_SET); delay_us(100); HAL_GPIO_WritePin(LED_PORT, LED_PIN, GPIO_PIN_RESET); delay_us(100); }然后用示波器看这个GPIO的高低电平宽度。如果测出来每个高电平宽度在100us左右说明延时函数基本靠谱。注意要减去GPIO翻转本身的开销通常也就几us问题不大。第三层如果延时函数本身没问题但集成到整个系统后出问题那就重点检查中断优先级和全局中断状态。特别是有很多外设中断同时跑的时候SysTick中断被打断的几率会上升HAL_Delay的精度和稳定性都会变差。把SysTick优先级调高或者把延时相关的关键部分改用定时器实现往往就能解决。5.3 聊聊CubeMX和寄存器混编的一点心得最后再多说一句关于CubeMX和寄存器操作的事。HAL库的封装层级比较多很多实时操作通过HAL库API来做会有不可忽略的调用开销。比如读取定时器的CNT寄存器直接用TIM6-CNT比调用__HAL_TIM_GET_COUNTER要快得多又比如修改PSC值直接用寄存器操作就避免了很多HAL库的检查逻辑。我实际写代码的风格是CubeMX生成工程骨架和初始化代码业务逻辑里凡是涉及高性能、精准时序的地方直接寄存器操作别绕弯。这样工程结构清晰性能也基本不受影响。那有没有用HAL库API做得更好的情况呢也有。比如输入捕获的中断处理HAL库已经帮你处理了各种标志位的清除和回调分发再自己去写NVIC和中断处理反而容易漏掉某些细节。这种场景就用HAL库接口省心。简单原则就是HAL库用它的阻塞式、回调式封装寄存器用在需要极致实时的细微操作上两边分工明确项目才不那么折腾。延时和计时这个事看似简单但确实是嵌入式开发的基础底盘。希望这篇内容能帮你少走点弯路。如果后续你也在具体外设上遇到过延时相关的疑难杂症欢迎拿着时序图和代码波形来对拍很多所谓的神秘现象归根结底还是时序没吃透。本文还有配套的精品资源点击获取