
简介这份资源面向STM32嵌入式开发初学者与需要巩固HAL库基础的工程师围绕延时与计时两大常见需求提供基于HAL库与CubeMX的完整工程例程。内容涵盖SysTick驱动的毫秒级HAL_Delay用法以及通过TIM定时器实现更精确的周期计时与中断回调处理帮助读者理解时钟配置、预分频与计数周期之间的关系。压缩包共959个文件以557个c源文件与241个h头文件为主体另含51个汇编文件、28个链接脚本及工程配置、库文件等整体约21.61MB目录按Src、Inc、Drivers等模块组织便于对照查阅。目前已有2852人学习下载。读者可从中获得可直接编译运行的工程模板、定时器初始化与中断回调的参考写法以及CubeMX配置与代码联动的排错思路适合作为延时计时功能的开发起点。1. 从一次串口丢包说起这套 HAL 延时与计时例程到底能解决什么去年帮一个做工业网关的朋友排查问题现象很典型STM32 用 HAL 库跑 Modbus 轮询偶尔丢一帧概率大概千分之三。示波器抓波形发现从机响应间隔忽长忽短代码里明明写的是HAL_Delay(2)实测有时候拖到 3.7ms。这不是玄学是 HAL_Delay 的底层实现决定的——它依赖 SysTick 中断而中断优先级、其他中断的抢占、甚至编译器优化等级都会影响它的实际精度。后来我把延时全部换成基于 DWT 周期计数器的忙等方案丢包率直接归零。这套 HAL 库实现 STM32 延时与计时例程核心就是解决三类问题第一HAL_Delay在中断上下文里不能用会死等因为 SysTick 优先级低于当前中断第二微秒级延时 HAL 库压根没提供第三长时间计时用HAL_GetTick()会溢出49.7 天回绕一次做数据记录或超时判断时容易翻车。例程覆盖了 CubeMX 配置、SysTick 与 DWT 两种延时实现、毫秒/微秒级计时封装、以及溢出安全的超时判断模板。适合正在用 HAL 库做裸机开发、需要精确时序控制或长时间运行计时的嵌入式工程师尤其是做传感器驱动、通信协议栈、电机控制这类对时序敏感的场景。2. 先搞懂 HAL 的时基从哪来CubeMX 里 SysTick 与 DWT 的选型逻辑2.1 SysTick 作为 HAL 时基的默认配置与代价CubeMX 新建工程时SYS → Timebase Source默认就是 SysTick。这个配置下HAL 库会在HAL_Init()里调用HAL_InitTick()把 SysTick 配成 1ms 中断一次中断里只做一件事uwTick。HAL_Delay(ms)的实现就是读当前uwTick加上要延时的毫秒数然后死循环等uwTick追上目标值。代价有三个。第一HAL_Delay在中断里调用会卡死——因为 SysTick 中断优先级默认是最低的NVIC 里配的 15如果当前正在处理一个优先级更高的中断SysTick 中断进不来uwTick不增加死循环永远出不去。第二1ms 的粒度决定了它最小只能延时 1ms想要 500us 做不到。第三uwTick是 32 位无符号数每 49.7 天回绕一次直接比较大小会出错。我一般会建议如果工程里有时序敏感的外设驱动把 Timebase Source 从 SysTick 改成 TIM6 或 TIM7。这样 SysTick 就空出来了可以专门给 RTOS 或者自定义延时用。CubeMX 里改这个配置很简单SYS → Timebase Source下拉选一个基本定时器就行HAL 库会自动生成HAL_InitTick()的对应实现。2.2 DWT 周期计数器的原理与启用条件DWT 是 Cortex-M 内核自带的调试监视单元里面有个CYCCNT寄存器每个内核时钟周期加一。M3/M4/M7 都支持M0/M0 没有。用它做延时的好处是不依赖任何中断纯忙等精度到单个时钟周期而且不受中断优先级影响。启用 DWT 需要三步使能DEMCR寄存器的TRCENA位清零DWT_CYCCNT使能DWT_CTRL的CYCCNTENA位。代码就三行但顺序不能错先开 TRCENA 才能访问 DWT 寄存器。// dwt_delay.c #include stm32f4xx.h // 根据实际芯片系列替换头文件 #define DWT_CTRL (*(volatile uint32_t *)0xE0001000) #define DWT_CYCCNT (*(volatile uint32_t *)0xE0001004) #define DEMCR (*(volatile uint32_t *)0xE000EDFC) void DWT_Init(void) { DEMCR | (1 24); // 使能 TRCENA否则 DWT 寄存器不可写 DWT_CYCCNT 0; // 清零周期计数器 DWT_CTRL | (1 0); // 使能 CYCCNTENA } // 微秒级忙等延时CPU 主频 168MHz 时 1us 168 个周期 void DWT_Delay_us(uint32_t us) { uint32_t start DWT_CYCCNT; uint32_t ticks us * (SystemCoreClock / 1000000); while ((DWT_CYCCNT - start) ticks); }逻辑说明DWT_CYCCNT - start这种写法利用了无符号数减法的回绕特性即使 CYCCNT 溢出也能正确计算差值。参数us是微秒数SystemCoreClock是系统主频CubeMX 生成的system_stm32f4xx.c里已经定义好了。注意ticks的计算在 168MHz 下延时 1000us 就是 168000 个周期不会溢出 32 位。2.3 两种时基的适用边界对比特性SysTick (HAL_Delay)DWT (CYCCNT)最小粒度1ms单周期 (约 6ns 168MHz)中断上下文可用否是受中断优先级影响是否长时间计时溢出49.7 天约 25.6 秒 168MHz低功耗模式可配合 sleep忙等功耗高适用场景粗延时、任务调度精确时序、中断内延时DWT 的溢出周期短所以它只适合做短延时不适合做长时间计时。长时间计时还是得靠 SysTick 的uwTick但要做溢出安全处理。常见做法是封装一个Tick_Elapsed()函数用无符号减法判断。3. 把延时封装成可复用的模块从微秒忙等到毫秒计时3.1 微秒级延时的三种实现与选择除了 DWT还有两种常见的微秒延时做法。一种是空指令循环__NOP()根据主频算循环次数但编译器优化等级一变循环次数就变了非常不可靠。另一种是用一个硬件定时器比如 TIM2 配成 1us 计数轮询计数器的值。这种做法精度也高但占用一个定时器资源。我一般会优先用 DWT因为它不占外设代码量最少。但如果芯片是 M0 系列没有 DWT那就只能用定时器方案。下面是一个基于 TIM 的微秒延时实现以 TIM2 为例// tim_delay.c void TIM_Delay_Init(void) { __HAL_RCC_TIM2_CLK_ENABLE(); TIM2-PSC (SystemCoreClock / 1000000) - 1; // 预分频到 1MHz TIM2-ARR 0xFFFFFFFF; // 最大重装载值 TIM2-CR1 | TIM_CR1_CEN; // 使能计数器 } void TIM_Delay_us(uint32_t us) { uint32_t start TIM2-CNT; while ((TIM2-CNT - start) us); }参数说明PSC预分频值把定时器时钟降到 1MHz这样每个计数就是 1us。ARR设成最大值让计数器自由运行。TIM2-CNT - start同样利用无符号减法处理回绕。注意 TIM2 是 32 位定时器如果是 16 位定时器比如 TIM3溢出周期只有 65ms做长延时需要额外处理。3.2 毫秒级计时与溢出安全的超时判断HAL_GetTick()返回的就是uwTick直接用它做超时判断有个经典错误// 错误写法溢出时会死循环 uint32_t start HAL_GetTick(); while ((HAL_GetTick() - start) timeout_ms) { // 等待 }这个写法在uwTick回绕时HAL_GetTick() - start的结果仍然正确因为无符号减法会自动处理回绕。但下面这种写法就有问题// 错误写法直接比较大小 uint32_t start HAL_GetTick(); while (HAL_GetTick() (start timeout_ms)) { // 如果 start timeout_ms 溢出条件永远为真 }正确的做法是封装一个带溢出保护的超时结构体// timeout.h typedef struct { uint32_t start_tick; uint32_t timeout_ms; } Timeout_t; void Timeout_Start(Timeout_t *t, uint32_t ms) { t-start_tick HAL_GetTick(); t-timeout_ms ms; } uint8_t Timeout_Expired(Timeout_t *t) { return ((HAL_GetTick() - t-start_tick) t-timeout_ms); }逻辑说明HAL_GetTick() - t-start_tick在无符号运算下即使HAL_GetTick()回绕差值依然正确。比如start_tick 0xFFFFFF00当前 tick 回绕到0x00000100差值就是0x200即 512ms符合预期。参数timeout_ms最大可以设到0xFFFFFFFF约 49.7 天。3.3 在 CubeMX 里把时基改成 TIM6 的完整步骤如果决定把 HAL 时基从 SysTick 换成 TIM6CubeMX 里的操作顺序是第一步Pinout Configuration → System Core → SYS把Timebase Source从SysTick改成TIM6。第二步确认TIM6出现在左侧列表里它的配置是自动生成的不需要手动改参数。第三步生成代码后检查stm32f4xx_hal_msp.c里有没有HAL_TIM_Base_MspInit函数里面会开 TIM6 的时钟。第四步在main.c里HAL_Init()之后HAL 库会自动调用HAL_InitTick()把 TIM6 配成 1ms 中断。这样做的好处是 SysTick 完全空出来了。如果跑 FreeRTOSSysTick 可以给 RTOS 用如果不跑 RTOSSysTick 可以配成更高频率的中断做更精细的任务调度。注意改完时基后HAL_Delay依然可用只是底层从 SysTick 换成了 TIM6行为不变。4. 避坑与排查延时不准、计时溢出、中断卡死的五个血泪经验4.1 HAL_Delay 在中断里卡死现象在串口接收中断里调用HAL_Delay(1)程序直接卡住不返回看门狗复位。原因SysTick 中断优先级默认是 15最低串口中断优先级如果设得比它高SysTick 中断进不来uwTick不增加HAL_Delay死循环。解决中断里绝对不要用HAL_Delay。需要短延时用 DWT 忙等需要长延时用状态机超时判断把延时逻辑放到主循环里。4.2 DWT 初始化后延时依然不准现象调用了DWT_Init()但DWT_Delay_us(100)实测出来是 130us 左右。原因SystemCoreClock变量的值和实际主频不一致。比如 CubeMX 里配的是 168MHz但SystemCoreClock没有更新还是默认的 16MHz算出来的周期数偏大。解决在main()开头调用SystemCoreClockUpdate()确保SystemCoreClock反映实际主频。或者在 CubeMX 的Project Manager → Code Generator里勾选Update SystemCoreClock选项。4.3 超时判断在 49.7 天后失效现象设备连续运行 50 天后所有基于HAL_GetTick()的超时判断全部失效通信中断。原因uwTick是 32 位49.7 天回绕一次。如果代码里用了HAL_GetTick() start timeout这种写法回绕后条件永远为真。解决统一用(HAL_GetTick() - start) timeout的写法无符号减法自动处理回绕。封装成Timeout_t结构体全局替换。4.4 低功耗模式下 DWT 延时失效现象进入 STOP 模式后唤醒DWT 延时比预期长很多。原因STOP 模式下内核时钟停了DWT_CYCCNT 不计数。唤醒后 CYCCNT 从停止的值继续但实际时间已经过去了。解决低功耗场景不要用 DWT 做延时。唤醒后如果需要精确延时重新初始化 DWT或者改用 RTC 唤醒中断方式。4.5 编译器优化导致忙等循环被优化掉现象Debug 模式下延时正常Release 模式下延时几乎为零。原因while ((DWT_CYCCNT - start) ticks);这行代码里DWT_CYCCNT是volatile指针不会被优化。但如果用的是普通变量做循环计数编译器可能把整个循环优化掉。解决所有硬件寄存器访问必须用volatile修饰。如果是空循环延时在循环里加__NOP()或者__DSB()防止优化。5. 进阶技巧用 DWT 做代码执行时间测量与性能剖析延时和计时反过来用就是测量。DWT 的 CYCCNT 不仅能做延时还能精确测量一段代码的执行时间。这个技巧在优化算法、排查性能瓶颈时特别有用。// profile.h #define PROFILE_START() uint32_t _profile_start DWT_CYCCNT #define PROFILE_END() (DWT_CYCCNT - _profile_start) // 使用示例测量一个浮点运算函数的执行周期数 void test_function(void) { PROFILE_START(); // 这里放要测量的代码 float result 0; for (int i 0; i 1000; i) { result i * 0.1f; } uint32_t cycles PROFILE_END(); // cycles 就是这段代码消耗的时钟周期数 // 168MHz 下cycles / 168 微秒数 }逻辑说明PROFILE_START()宏记录当前 CYCCNT 值PROFILE_END()返回差值。这个差值就是代码执行期间经过的时钟周期数。注意测量期间如果发生了中断中断服务程序的周期数也会被算进去所以最好在测量时关中断或者多次测量取最小值。我一般会把这个技巧用在三个地方第一对比不同算法的执行效率比如查表法 vs 实时计算第二测量通信协议栈里各层的处理耗时找出瓶颈第三验证编译器优化效果看 Release 模式到底比 Debug 快多少。还有一个实用技巧是结合HAL_GetTick()做长时间性能统计。比如每秒钟统计一次 CPU 空闲时间在主循环里记录进入空闲时的 tick 和 CYCCNT出空闲时再记录一次累加空闲周期数除以总周期数就是空闲率。这个数据对判断系统负载很有参考价值。从那以后我每次新建工程第一件事就是把 DWT 初始化加进去第二件事就是把超时判断全部换成Timeout_t封装。这两个习惯帮我省了至少三次通宵排查。希望帮到你。本文还有配套的精品资源点击获取