news 2026/9/7 5:46:51

STM32 HAL库延时与计时原理详解:从HAL_Delay到DWT与输入捕获

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
STM32 HAL库延时与计时原理详解:从HAL_Delay到DWT与输入捕获

简介:一份基于STM32 HAL库的延时与定时器计时开发例程,面向嵌入式入门及中级开发者,帮助读者借助STM32CubeMX图形化配置工具完成定时器、时钟树等初始化,并掌握HAL_Delay实现毫秒级延时、利用定时器中断实现精确计时的常用工程写法。压缩包共959个文件、约21.61MB,主体为C源码(557个)与头文件(241个),同时包含汇编启动文件、链接脚本、IAR/Keil工程文件及ioc/mxproject配置,可直接导入开发环境查看或二次修改。工程展示了TIM定时器的预分频与周期设置,通过周期中断回调实现1ms计数,并给出可扩展到秒级计时、周期性任务调度的完整示例,对理解HAL库底层封装和STM32中断机制很有帮助。无论是做LED闪烁这类基础练习,还是为传感器采集、通信协议提供时间基准,这套代码都能提供清晰参照。目前已有2847人学习下载,适合需要快速上手定时器、寻找可运行参考代码的开发者。 做嵌入式开发这几年,我越来越觉得“时间”才是程序里最硬的后台。坐标是STM32F103C8T6,跑的是HAL库,延时和计时几乎是每个工程都绕不开的底层操作:传感器上电要稳定时间、按键消抖要等毛刺过去、串口超时要判断结束、PWM周期要精确产生。很多新手上来就是HAL_Delay一把梭,用着用着就开始出问题——延时卡死、计时不准、在中断里调了一下整个系统直接冻结。这篇就把HAL库下的延时与计时从原理到实战拆开讲透,适合刚接触HAL库的读者,也适合那些靠“试参数”调时间的老手,看完你可以把时间管理当成工具,而不是玄学。

我先把常用的时间手段摆在一张表里,后面每一类都会展开说。

方案依赖资源精度阻塞/非阻塞适用场景
HAL_DelaySysTick毫秒级阻塞简单等待、主循环延时
定时器轮询TIM2-TIM4等微秒级可调可阻塞可非阻塞精确延时、状态机调度
DWT周期计数器内核调试组件CPU周期级非阻塞测量代码执行时间
输入捕获TIM捕获通道微秒级中断回调测外部脉宽、频率、角度

1. 为什么“时间”是STM32一切逻辑的地基

我在调试一块磁编码器读取板的时候有过一次非常深刻的教训。芯片是STM32F103C8T6,用HAL库模拟IIC去读MT6701,读取流程本身写得很顺,上电也能出数,但就是偶尔会读出跳变的数据。一开始以为是IIC时序问题,示波器挂了半天没找到原因,后来用逻辑分析仪一看,发现是Sensor读取过程中的微妙时序被一个高优先级中断打乱了。那个中断服务函数里恰好用了HAL_Delay做等待,每来一次中断,主循环的读取节奏就被拉长一截。从那以后我才真正意识到,延时和计时不是“随便调个值就行”的事,它直接影响整个系统的确定性

很多从标准库转HAL库的开发者会有一种错觉,觉得HAL_Delay就是一个高级版的delay_ms,调用就完事。实际上HAL库对时间管理的设计比标准库“重”得多,它引入了uwTick全局时钟、HAL_GetTick、定时器句柄结构体这些概念。你如果不理解这一层,碰到的就远不止延时不准确这么简单,会有卡死、复位、响应迟钝各种诡异问题。

这篇文章我尽量不讲空话,每一段都是能直接落地的。我默认你用的是STM32CubeMX生成的基础工程,芯片是F103C8T6,主频72MHz,HAL库版本是F1系列的1.8.x左右。在这个基础上,我会先讲HAL_Delay的“坑”,再讲怎么用定时器做真正的延时和计时,然后讲DWT和输入捕获这两个容易被忽略的高精度方案。整个过程你会明白一件事:HAL库只是给了你一把锤子,怎么用、什么时候换工具,才是自己的本事

2. HAL_Delay为什么会卡死:SysTick时钟源与中断抢占的连带效应

2.1 先从HAL_Delay的源码看它的真实依赖

如果你点进HAL库的stm32f1xx_hal.c,会看到HAL_Delay的实现很有意思:

__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中断里调用HAL_IncTick来累加的:

void SysTick_Handler(void) { HAL_IncTick(); }

所以说白了,HAL_Delay能不能正常跑,取决于两个前提:第一,SysTick中断必须能持续触发;第二,uwTick必须能被正常累加。只要这两个前提之一被破坏,HAL_Delay就变成一个永远跳不出去的while循环。很多人的“卡死”问题,根子就在这里。

2.2 中断里调用HAL_Delay:一个典型的死锁场景

我先描述一个现象:你在EXTI中断回调里写了一句HAL_Delay(10),然后发现整个程序在触发中断之后再也回不去了。为什么?因为CubeMX默认把SysTick的优先级配置为最低优先级(15)。外部中断EXTI的优先级通常比15要高,于是进入EXTI中断服务函数后,SysTick中断没法抢占执行,uwTick停止增长,HAL_Delay的while循环永远等不到目标值。

这个坑几乎每个HAL库开发者都会踩一次。有人问,那把SysTick优先级调高不就行了?理论上确实可以,但这不是正路。在中断服务函数里做毫秒级阻塞延时,本来就意味着你在浪费CPU时间,而且会让中断嵌套深度不可控。真需要等10毫秒,正确做法是记录当前时间戳,然后在主循环或者状态机里判断“时间到了没有”。

排查的时候可以按照这个链路走一遍:卡死之后先看仿真器里的uwTick变量是否还在递增;如果uwTick停了,再确认SysTick_Handler有没有被其他文件覆盖或者屏蔽;如果SysTick还在跑但HAL_Delay还是出不去,检查调用位置是不是处于关中断的临界区,或者当前中断优先级是不是高于SysTick。这个排查顺序能帮你省下大量瞎猜的时间。

2.3 时钟初始化顺序与RTOS共存时的小动作

还有一类卡死不太容易被发现,发生在你改过时钟树之后。CubeMX生成的SystemClock_Config里,HAL_RCC_ClockConfig会调用HAL_InitTick去配置SysTick的优先级和重装值。但如果你在HAL_Init之后又重新配置过时钟,或者在某些低功耗唤醒流程里手动关了SysTick,那uwTick就可能停更。HAL库设计了一个HAL_InitTick函数,但它不是任何时候都能被自动调用的,这一点很多人不知道。

如果你在工程里还集成了RTOS,比如FreeRTOS,情况会更麻烦。FreeRTOS默认会接管SysTick作为系统节拍源,那么HAL_Delay和osDelay就会去抢同一个节拍器。常见现象是HAL_Delay的时间被放大好几倍,甚至完全卡死。解决办法是让FreeRTOS使用TIM6或TIM7作为节拍源,或者干脆在RTOS环境下全面用osDelay替代HAL_Delay。别混着用,这是我一直坚持的原则。

3. 用基本定时器造一个不依赖SysTick的延时与计时器

3.1 定时器时基单元拆解:PSC、ARR、CNT之间的关系

如果说SysTick是系统自带的“心跳”,那通用定时器就是你手里可以自由编排的“秒表”。STM32F103C8T6上有TIM1到TIM4,其中TIM1是高级定时器,TIM2到TIM4是通用定时器。我习惯用TIM2做延时计时,因为它是32位计数器,溢出周期特别长,基本不用操心回绕问题。

定时器的时基单元就三个关键寄存器:预分频器PSC、自动重装载寄存器ARR、计数器CNT。时钟源进来之后先经过PSC分频,得到一个计数频率,然后CNT在这个频率下不断加1,加到ARR的值就触发更新事件,然后CNT归零重新开始。所以计数频率的计算公式是:

定时器计数频率 = 定时器输入时钟 / (PSC + 1)

这里有一个特别容易算错的地方:不要把PSC当成分频系数本身。PSC等于0的时候,计数器时钟等于输入时钟;PSC等于71的时候,才是72分频。很多人配置的时候少加了1,延时时间整整快了一倍。

3.2 针对F103C8T6的一个实例计算

我用CubeMX生成默认工程,外部晶振8MHz,系统时钟72MHz,APB1总线时钟36MHz。按照STM32F1的时钟树规则,APB1不分频或者分频系数不为1时,挂载在APB1上的定时器时钟会翻倍。默认CubeMX里APB1分频器是2,所以TIM2的输入时钟是36MHz乘以2,也就是72MHz。

我想让定时器以1MHz的频率计数,也就是每个tick代表1微秒,那么预分频值就是72MHz除以1MHz再减1,结果为71。ARR可以设成0xFFFFFFFF,让计数器自由滚转。这样CNT每增加1,就是过了1微秒,做延时和计时都非常直观:

void MX_TIM2_Init(void) { TIM_ClockConfigTypeDef sClockSourceConfig = {0}; TIM_MasterConfigTypeDef sMasterConfig = {0}; htim2.Instance = TIM2; htim2.Init.Prescaler = 71; htim2.Init.CounterMode = TIM_COUNTERMODE_UP; htim2.Init.Period = 0xFFFFFFFF; htim2.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; htim2.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_DISABLE; if (HAL_TIM_Base_Init(&htim2) != HAL_OK) { Error_Handler(); } }

注意,CubeMX图形化界面里填的是Prescaler和Counter Period,直接对应PSC和ARR寄存器值。你如果想生成的是1微秒节拍,Prescaler填71,Counter Period填0xFFFFFFFF,不要写反了。

3.3 阻塞式微秒延时:把HAL_Delay的下限拉低

HAL_Delay只能做到毫秒级,很多时候不够用。比如模拟IIC、读取高速SPI传感器、适配一些要求苛刻的时序,都需要微秒级延时。SysTick虽然也能改成微秒,但改了之后HAL库其他依赖uwTick的地方就全乱套了,所以不如单独拎一个定时器出来做微秒延时。

最简单的写法是利用CNT差值做阻塞等待:

void TIM2_Delay_us(uint32_t us) { uint32_t start = __HAL_TIM_GET_COUNTER(&htim2); while ((__HAL_TIM_GET_COUNTER(&htim2) - start) < us) { } }

这个函数看起来很粗糙,但实际用下来稳定性非常好。因为TIM2按1MHz计数,CNT差值就是微秒数。这里最核心的细节是必须用无符号减法的差值来判断,而不是直接拿CNT和target比较。由于CNT会一直增长到0xFFFFFFFF然后清零回绕,如果你写成“CNT大于等于target就退出”,一旦回绕,判断结果就是错的。无符号减法天然处理了回绕,只要差值在计数器范围内的任意长度都能得到正确结果。

还有一个细节:这种阻塞延时函数内部没有关闭中断,所以如果被高优先级中断打断,实际等待时间会被拉长。不过在绝大多数场景下,微秒级延时的容错范围都够用,比HAL_Delay的中断依赖问题强太多了。

3.4 非阻塞延时:把“等时间”从循环里解放出来

阻塞式延时最大的问题是浪费CPU。主循环里如果到处是delay,按键扫描、屏幕刷新、通信处理全部会被卡住。真正的工程写法是非阻塞延时:记录一个开始时间戳,然后该干嘛干嘛,到主循环里判断时间差是否达到目标。

以TIM2作为时钟源,可以这样封装:

uint32_t tick_now(void) { return __HAL_TIM_GET_COUNTER(&htim2); } bool tick_elapsed(uint32_t start_tick, uint32_t interval) { return (__HAL_TIM_GET_COUNTER(&htim2) - start_tick) >= interval; }

然后按键消抖就变成了这样:

static uint32_t key_start; static uint8_t key_state = 0; void key_task(void) { switch (key_state) { case 0: if (HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin) == RESET) { key_start = tick_now(); key_state = 1; } break; case 1: if (tick_elapsed(key_start, 20000)) // 20ms消抖 { key_state = 2; } break; case 2: // 触发一次按键事件 key_state = 0; break; default: key_state = 0; break; } }

这段代码里我故意把20毫秒写成了20000个tick,因为TIM2按1MHz计数,1个tick就是1微秒。如果你把定时器节拍改成其他频率,这个数值要一起换算。用非阻塞延时改造之后,主循环的利用率立刻高起来,所有状态机都可以平铺在while(1)里并行推进,这才是嵌入式工程里应该有的时间管理思路。

4. 高精度计时的隐藏方案:DWT内核周期计数器

4.1 为什么HAL_Delay测不了代码执行时间

HAL_Delay只能让程序“等”,它不能回答一个更重要的问题:某段代码到底跑了多长时间。调试IIC时序、定位通信协议的性能瓶颈、判断传感器读数的执行开销,都需要一个高分辨率的时间测量工具。定时器也能做,但定时器资源有限,而且你往往不想为了一个调试功能拆掉正在用的外设。

ARM Cortex-M3内核里有个数据观察点与跟踪单元DWT,其中有一个CYCCNT寄存器,专门统计内核时钟周期数。这个外设不像SysTick那样需要占用一个固定中断,也不像通用定时器那样要配置时钟树,它直接数CPU的周期,精度就是最高等级的周期级。

4.2 DWT的初始化和用法

DWT在F103上默认并不开启,需要手动打开。初始化只需四步:解锁调试寄存器、清零计数器、开启计数使能、确认调试寄存器使能位。

void DWT_Init(void) { CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; DWT->CYCCNT = 0; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; }

使用的时候,先读一个起始值,运行被测函数,再读一个结束值,差值就是CPU周期数。72MHz主频下,1微秒等于72个周期。

void DWT_Delay_us(uint32_t us) { uint32_t start = DWT->CYCCNT; uint32_t ticks = us * (SystemCoreClock / 1000000); while ((DWT->CYCCNT - start) < ticks) { } }

这个DWT_Delay_us看起来和定时器版本的微秒延时很像,但它不占用任何外设,只靠内核调试组件,在需要临时性测量时特别香。我经常在代码里临时插入DWT_GetCycle来量化某个函数耗时,评估完再删掉,非常方便。

4.3 用DWT实测HAL_Delay的真实误差

之前我一直怀疑HAL_Delay并不像手册上写的那么准,有了DWT之后我专门测了一下。在主循环里调用HAL_Delay(1),然后用DWT的周期差值换算真实时间,发现大多在1.0到1.5毫秒之间浮动。原因是HAL_Delay本身有自己查询uWTick的循环开销,再加上SysTick中断触发的随机性,误差就出来了。连续调用多次之后,误差还会累积,特别是用来做采样周期的时候,周期抖动会被放大。

相比之下,用TIM2做非阻塞计时或者用DWT做微秒级等待,抖动要小得多。这个结论不是否定HAL_Delay,而是明确告诉你它的边界在哪里。它的定位就是简单可靠的毫秒级粗延时,满足指示灯、按键消抖、串口打印这些场合完全没问题,但如果你在做一个需要稳定采样率的滤波算法,或者处理对时序敏感的总线通信,就该换其他方案。

4.4 DWT计时的边界条件

DWT也有自己的边界。第一,它依赖内核时钟,芯片进入Stop模式、Standby模式后内核时钟停了,CYCCNT自然就不走了;第二,复位后DWT配置会被清空,所以必须在启动阶段重新初始化;第三,不要和调试器的ITM功能冲突,有些调试场景会占用DWT的配置,这时候你的初始化可能不生效。知道这三点,你就能正确地把它当做一个“调试期神器”,而不是所有场景都依赖它。

5. 用输入捕获测量外部信号脉宽:从CubeMX配置到中断回调

5.1 输入捕获的工作原理

延时和计时解决的是一端的时间控制问题,但工程里还有一种需求恰好相反:外部给了一个信号,你要测出它的周期或者高电平持续时间。典型例子是超声波模块测距、磁编码器输出PWM角度信号、遥控器接收头的脉宽解调。这就轮到定时器的输入捕获功能登场了。

输入捕获的原理是:配置定时器通道的边沿触发,当引脚上出现上升沿或下降沿时,硬件自动把当前CNT的值锁存到捕获寄存器CCR里,同时触发捕获中断。你只需要在软件里记录两次捕获的CCR值,做差就能得到该脉冲的时间宽度。因为捕获动作是硬件完成的,精度非常高,不受中断响应延迟影响。

5.2 CubeMX侧的配置要点

打开CubeMX工程,选中TIM2,把Channel1设置为Input Capture direct mode。这里有两个参数要特别留意:一个是Prescaler,同样按1MHz计数就填71;另一个是Counter Period,最好填0xFFFFFFFF,防止测量长脉宽时计数器溢出清零导致数据错误。然后在NVIC设置里勾选TIM2 global interrupt,生成代码。

如果你用的是TIM3这类16位定时器,Counter Period最大只能填65535,测量超过65.535毫秒的脉宽就会出问题,需要配合溢出中断计数来扩展。用TIM2做输入捕获的一个隐藏优势就是32位计数器,基本长脉宽都能覆盖。

5.3 中断回调里测量高电平持续时间的完整写法

HAL库处理捕获中断的入口是HAL_TIM_IC_CaptureCallback这个弱函数,你在自己的代码里重新实现它就行。我测PWM高电平时长是用“捕获上升沿之后,把捕获极性切换到下降沿,再捕获下降沿”的思路,两个捕获值的差就是高电平时间。

volatile uint32_t pwm_high_ticks = 0; static uint32_t cap1; void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM2) { if (htim->Channel == HAL_TIM_ACTIVE_CHANNEL_1) { static uint8_t edge = 0; if (edge == 0) { // 上升沿来了,记录时间,改为下降沿捕获 cap1 = __HAL_TIM_GET_COMPARE(&htim2, TIM_CHANNEL_1); __HAL_TIM_SET_CAPTUREPOLARITY(&htim2, TIM_CHANNEL_1, TIM_INPUTCHANNELPOLARITY_FALLING); edge = 1; } else { // 下降沿来了,两次捕获值之差就是高电平时间 uint32_t cap2 = __HAL_TIM_GET_COMPARE(&htim2, TIM_CHANNEL_1); pwm_high_ticks = cap2 - cap1; __HAL_TIM_SET_CAPTUREPOLARITY(&htim2, TIM_CHANNEL_1, TIM_INPUTCHANNELPOLARITY_RISING); edge = 0; } } } }

这段代码里最值得强调的还是cap2 - cap1这个无符号减法。因为CNT一直在累加并回绕,只有用差值法才能在任何位置都得到正确结果。定时器按1MHz计数,所以pwm_high_ticks的单位就是微秒。我在主循环里只需要读这个全局变量的值,然后换算成实际参数即可。

使用的时候还有个小陷阱:HAL库某些版本的通道判断宏可能不一致,有的回调里htim->Channel会被表示成HAL_TIM_ACTIVE_CHANNEL_1,有的版本直接就是TIM_CHANNEL_1。编译报错就换一种写法,不影响逻辑。

5.4 我用这套方案做过的实测案例

我拿这个输入捕获方案配过MT6701磁编码器输出的PWM模式。那颗芯片会把当前角度按比例调制成一个PWM的占空比,范围大概是从0.5微秒到2.5微秒的高电平时长。用定时器捕获后,高电平时长换算就能得到实时角度,分辨率能做到0.1度级别左右。整个过程没有用任何模拟采集,精度和实时性都让当时的我相当满意。

类似的思路还能直接套到HC-SR04超声波模块上:TRIG引脚发一个10微秒的高电平触发,然后测ECHO引脚高电平持续时间,芯片FPGA核心里回波时间乘以声速再除以2就是距离。这类“等待外部时间”的需求,统一用输入捕获解决,比纯轮询GPIO稳定得多。你把这套代码保存下来,下次遇到测脉宽的场景只需要改下引脚和定时器通道,基本就是复制粘贴的事。

最后再分享一个我个人的经验:延时和计时方案的选型,本质是在资源、精度和代码复杂度之间做取舍。HAL_Delay赢得简单,代价是阻塞、毫秒级、依赖SysTick;定时器方案赢在可控,代价是要初始化一个外设;DWT赢在精准,代价是调试组件不能随便复用。最强力的做法是写一个独立的bsp_time.c,把TIM2的tick_now、tick_elapsed、DWT的微秒延时和延时函数全部收敛在里面,项目之间直接拷贝。这套东西我用了好几个项目,逻辑稳定之后几乎不用再碰,花一个晚上认真搞一次,能让你后面无数个调试夜晚变得安稳。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/7 5:46:27

BOLIDE项目部署指南:AI模型本地部署与API集成实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/7 5:45:30

深度学习21个项目实例:从视觉检测到环境配置的实战路线

简介&#xff1a;《深度学习21个项目实例》是一份面向深度学习初学者的实践型资源&#xff0c;围绕21个可运行项目串联理论知识与编码过程&#xff0c;适合已经掌握Python基础、准备系统学习神经网络并完成完整训练流程的读者。压缩包共包含911个文件&#xff0c;整体大小约55.…

作者头像 李华
网站建设 2026/9/7 5:44:09

Isaac Lab实战教程:四足、机械臂与人形机器人强化学习训练指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/7 5:43:48

SpringJDBC条件进阶:JdbcTemplate动态查询与参数安全实践

1. 先理解“条件进阶”到底在解决什么问题SpringJDBC 是 Spring 框架里处理数据库访问的基础方案&#xff0c;很多项目在没有引入 MyBatis、JPA 这类重量级 ORM 框架时&#xff0c;都会直接用它来操作数据库。JDBC 本身写起来啰嗦&#xff0c;SpringJDBC 通过JdbcTemplate把连接…

作者头像 李华
网站建设 2026/9/7 5:42:51

ESP32驱动SES电子价签墨水屏:从拆机到中文显示实战

简介&#xff1a;面向物联网开发者的 ESP32 驱动 SES 价签墨水屏完整工程包&#xff0c;解决电子纸标签的 SPI 通信、初始化刷新及蓝牙远程改价等实际问题。资源共 13 个文件&#xff0c;以 C 语言源码、JSON 配置、sdkconfig 构建配置和 Markdown/txt 说明文档为主&#xff0c…

作者头像 李华
网站建设 2026/9/7 5:41:23

AI辅助不等于作者身份:从写作八环节到署名边界

AI辅助写作已经成了很多人每天离不开的事。写技术博客、写论文初稿、写工作报告、做课程作业&#xff0c;打开对话窗口让AI扩一段、润一下、换个语气&#xff0c;几乎变成了默认操作。但有一个判断很容易被忽略&#xff1a;AI assistance is not authorship&#xff0c;AI 辅助…

作者头像 李华