☰
STM32F1/F4延时函数选型指南:SysTick与DWT实战
2026/10/19 9:39:02 网站建设 项目流程

简介:这份资源面向STM32嵌入式开发初学者与需要快速集成延时功能的工程师,提供STM32F1与STM32F4系列微控制器可用的延时函数实现。压缩包内共2个文件,包含1个c源文件与1个h头文件,包体仅848B,轻量易移植,直接加入工程即可调用。内容围绕延时函数的基本原理展开,涵盖基于循环计数的软件延时与利用SysTick、通用定时器实现硬件延时的思路,并针对F1与F4在CPU速度、时钟频率上的差异给出不同实现策略,支持毫秒级到微秒级的宽范围延时,延时上限可达477218ms与477218588us。头文件负责函数原型与宏定义,源文件封装具体实现,便于在其他模块中直接引用。目前已有3011人学习下载,适合需要快速验证定时逻辑、控制执行流程或进行外设时序调试的开发者参考,也可作为理解STM32延时机制与定时器用法的入门素材。

1. 从一次串口乱码说起:STM32F1/F4 延时函数到底该怎么选

前阵子帮一个做工业控制的朋友排查问题,他的板子跑 STM32F103,串口每隔几毫秒吐一次数据,上位机收到的全是乱码。示波器一打,UART 波特率偏差大得离谱,根因不是时钟配置,而是他随手写了个for(i=0;i<1000;i++);当延时用,编译器一开-O2优化,循环直接被削掉,时序全崩。这类“玄学”问题在 STM32 项目里太常见了,F1 和 F4 两代芯片主频、内核、SysTick 行为都有差异,延时函数写不对,轻则通信错帧,重则外设初始化失败。这份资源把 STM32F1 和 STM32F4 两套延时方案整理在一起,从裸机delay_us/delay_ms到 SysTick 中断计数,再到 DWT 周期计数器,适合正在做底层驱动、传感器时序、WS2812 灯带或者软件模拟 I2C/SPI 的开发者。下面我按“能跑起来 → 能看懂 → 能改”的顺序拆一遍。

2. 两代芯片的延时方案对比:SysTick、DWT 与空循环的取舍

2.1 为什么 F1 和 F4 不能共用一份延时源码

STM32F1 是 Cortex-M3 内核,最高主频常见 72MHz;STM32F4 是 Cortex-M4F,常见 168MHz,带浮点单元和更复杂的流水线。两者都有 SysTick,但 F4 的时钟树里 SysTick 可以选 HCLK 或 HCLK/8,F1 默认也是 HCLK/8,很多例程直接沿用旧配置,结果 F4 上延时偏了 8 倍。更关键的是,F4 的 Flash 等待周期和 ART 加速器让指令执行时间不再和 F1 一样“一条指令一个周期”,用空循环做微秒延时,误差会随主频和优化等级剧烈波动。所以这份资源把两套实现分开写,不是偷懒,是必须。

常见做法是:F1 用 SysTick 做 1ms 中断,在中断里递减计数实现delay_ms,微秒级用简单的循环计数;F4 则推荐用 DWT 的 CYCCNT 寄存器做周期级延时,精度能到 1/168MHz,且不受中断影响。下面这张表是我整理两代芯片延时方案时的对比,参数按常见工程配置填:

方案适用芯片最小分辨率是否占用中断受优化影响典型误差
空循环 forF1/F4约 1us(不可控)否极大50% 以上
SysTick 轮询F1/F41us否小5% 以内
SysTick 中断计数F1/F41ms是无1% 以内
DWT CYCCNTF4 优先1 周期否无接近 0

2.2 SysTick 轮询版:F1 上最稳的微秒延时

先看 F1 上我常用的 SysTick 轮询实现。核心思路是:每次调用delay_us时,把 SysTick 的 LOAD 设为 1us 对应的周期数,清空 VAL,启动计数,然后死等 COUNTFLAG 置位。这样不占中断,也不会被编译器优化掉,因为读的是硬件寄存器。

// STM32F1 72MHz 下的微秒延时,基于 SysTick 轮询 #include "stm32f10x.h" void delay_us(uint32_t us) { uint32_t temp; // 72MHz 下 1us = 72 个周期,重装载值设为 72-1 SysTick->LOAD = 72 * us - 1; SysTick->VAL = 0; // 清空当前计数值 SysTick->CTRL |= (1 << 0); // 使能 SysTick,不开启中断 do { temp = SysTick->CTRL; } while ((temp & 0x01) && !(temp & (1 << 16))); // 等待 COUNTFLAG SysTick->CTRL &= ~(1 << 0); // 关闭 SysTick SysTick->VAL = 0; }

逻辑说明:LOAD决定一次倒计时长度,VAL写 0 会清空当前值并重新从 LOAD 开始。CTRL的 bit0 是使能位,bit16 是 COUNTFLAG,计数到 0 时硬件置 1。循环里同时判断使能位和标志位,避免死循环。参数上,us不能超过LOAD的最大值 0xFFFFFF/72 ≈ 233ms,超过就要拆成毫秒处理。注意这里没有开中断,所以不会打断其他外设,适合在中断服务函数里调用。

2.3 F4 上的 DWT 周期计数器:精度拉满

F4 主频高,SysTick 轮询在微秒级还能用,但如果你要做 WS2812 这种纳秒级时序,就得请出 DWT。Cortex-M4 的 DWT 有个 CYCCNT 寄存器,每个内核时钟周期加 1,直接读它做差值,精度就是一个周期。

// STM32F4 168MHz 下基于 DWT 的微秒延时 #include "stm32f4xx.h" void DWT_Init(void) { CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; // 使能跟踪 DWT->CYCCNT = 0; DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; // 启动周期计数 } void delay_us_dwt(uint32_t us) { uint32_t start = DWT->CYCCNT; uint32_t ticks = us * 168; // 168MHz 下 1us = 168 周期 while ((DWT->CYCCNT - start) < ticks); }

逻辑说明:DEMCR的 TRCENA 位必须先置 1,否则 DWT 不工作,这是很多人翻车的地方。CYCCNT是 32 位,168MHz 下约 25 秒溢出一次,做短延时完全够用。ticks计算时注意us不要太大导致乘法溢出,一般延时不超过 100ms 没问题。这个函数不关中断,但如果在高优先级中断里调用,要确保 DWT 已初始化。参数us直接决定循环次数,改主频时同步改 168 这个系数即可。

2.4 毫秒延时:中断计数版才是正解

微秒用轮询,毫秒就别再死等了。标准做法是配 SysTick 每 1ms 中断一次,在SysTick_Handler里递减一个全局变量,delay_ms只管等这个变量归零。这样 CPU 在延时期间还能响应其他中断,功耗也低。

// F1/F4 通用的毫秒延时,基于 SysTick 1ms 中断 volatile uint32_t g_ms_tick = 0; void SysTick_Handler(void) { if (g_ms_tick != 0) { g_ms_tick--; } } void delay_ms(uint32_t ms) { g_ms_tick = ms; while (g_ms_tick != 0); }

逻辑说明:g_ms_tick必须加volatile,否则编译器可能把它缓存到寄存器里,导致while永远不退出。SysTick 初始化时把 LOAD 设为SystemCoreClock/1000 - 1,CTRL 使能中断。F1 和 F4 的SystemCoreClock变量由库函数维护,改主频后自动更新。注意这个delay_ms不能在比 SysTick 优先级更高的中断里调用,否则中断嵌套会导致计数不递减,直接卡死。

3. 工程目录与移植步骤:把延时模块塞进你的项目

3.1 推荐的文件组织方式

拿到这份资源后,别一股脑全拖进工程。我一般这样放:在项目根目录建BSP文件夹,里面放delay.c和delay.h,F1 和 F4 的差异用宏隔开,或者干脆分两个文件delay_f1.c、delay_f4.c,在头文件里根据芯片型号条件编译。这样以后换芯片只改一个宏,不用满工程找。

// delay.h 里用宏区分芯片系列 #ifndef __DELAY_H #define __DELAY_H #include "stm32f10x.h" // F1 工程改成这个 // #include "stm32f4xx.h" // F4 工程改成这个 void delay_init(void); void delay_us(uint32_t us); void delay_ms(uint32_t ms); #endif

逻辑说明:头文件里只暴露三个接口,具体实现藏在.c里。delay_init负责初始化 SysTick 或 DWT,必须在main开头调用一次。如果你用的是 HAL 库,注意 HAL 自己会配 SysTick 做 1ms 时基,别和你的delay_ms抢,常见做法是直接复用HAL_GetTick()做毫秒延时,微秒再单独实现。

3.2 移植到 F1 标准库工程的步骤

第一步,把delay.c加入 Keil 或 IAR 的工程分组。第二步,在main.c里#include "delay.h",然后在SystemInit()之后、外设初始化之前调用delay_init()。第三步,检查stm32f10x_it.c里有没有SysTick_Handler,如果有,把我们的计数逻辑合并进去,别重复定义。第四步,编译前确认优化等级,微秒延时函数建议加__attribute__((optimize("O0")))或者用volatile修饰循环变量,防止被优化掉。

提示:F1 的 SysTick 时钟默认是 HCLK/8,如果你在delay_init里没改CTRL的 CLKSOURCE 位,1us 的 LOAD 值要按 9MHz 算,即 9 个周期,而不是 72。这个坑我踩过,延时直接差 8 倍。

3.3 移植到 F4 HAL 库工程的注意事项

F4 用 HAL 库时,HAL_Init()内部已经把 SysTick 配成 1ms 中断,并且定义了SysTick_Handler调用HAL_IncTick()。这时候你再写一个SysTick_Handler会冲突。我的做法是:毫秒延时直接用HAL_Delay(),微秒延时用 DWT 方案,两者互不干扰。DWT 初始化放在main里HAL_Init()之后调用一次即可。如果你非要用自己的 SysTick 中断计数,那就得把 HAL 的时基改成别的定时器,比如 TIM6,否则 HAL 的HAL_Delay和超时机制全会乱。

// F4 HAL 工程里初始化 DWT 的调用位置 int main(void) { HAL_Init(); // 先让 HAL 配好 SysTick DWT_Init(); // 再启动 DWT 周期计数 SystemClock_Config(); // 后面正常初始化外设 }

逻辑说明:DWT_Init不依赖 HAL,但必须在HAL_Init之后,因为HAL_Init会配置 Flash 等待周期和向量表,DWT 的跟踪功能需要内核时钟稳定。SystemClock_Config如果改了主频,DWT 的周期系数要同步改,比如从 168MHz 超频到 180MHz,delay_us_dwt里的 168 要改成 180。

4. 避坑与排查:延时不准、卡死、被优化掉的常见原因

4.1 延时比预期长很多或短很多

现象:串口波特率对不上,或者传感器读出来的时序完全错。原因:SysTick 时钟源选错,F1 默认 HCLK/8,F4 默认 HCLK,但很多从 F1 抄来的代码没改 LOAD 值。解决:在delay_init里显式设置SysTick->CTRL |= SysTick_CTRL_CLKSOURCE_Msk;强制用 HCLK,然后按SystemCoreClock重新算 LOAD。改完用示波器打一个 GPIO 翻转,实测脉宽。

4.2 程序卡在 while 循环里出不来

现象:调用delay_ms后死机,看门狗复位。原因:g_ms_tick没加volatile,编译器优化后循环条件永远为真;或者delay_ms在比 SysTick 优先级更高的中断里调用,中断嵌套导致计数不更新。解决:加volatile;检查 NVIC 优先级分组,确保 SysTick 优先级高于调用它的中断,或者干脆不在中断里用毫秒延时。

4.3 开了优化后延时直接消失

现象:Debug 版正常,Release 版外设全挂。原因:空循环或简单的while(i--)被编译器当成无副作用代码删掉。解决:微秒延时必须基于硬件寄存器,比如 SysTick 的 VAL 或 DWT 的 CYCCNT,这些读操作有副作用,编译器不敢删。如果非要用循环,变量加volatile,但精度依然没法保证。

4.4 DWT 初始化后 CYCCNT 不计数

现象:delay_us_dwt一调用就卡死,CYCCNT读出来一直是 0。原因:CoreDebug->DEMCR的 TRCENA 位没置 1,或者调试器占用了 DWT。解决:确认DWT_Init里先写 DEMCR 再写 DWT->CTRL;如果用的是 J-Link 或 ST-Link,有些调试配置会关掉跟踪,换到正常上电运行再测。另外,低功耗模式下 DWT 会停,唤醒后要重新初始化。

4.5 毫秒延时和 HAL_Delay 混用导致时基错乱

现象:F4 工程里同时用HAL_Delay和自定义delay_ms,HAL 的超时判断全乱。原因:两个都改 SysTick,或者自定义的SysTick_Handler覆盖了 HAL 的。解决:二选一。用 HAL 就HAL_Delay做毫秒,DWT 做微秒;不用 HAL 就自己配 SysTick,别链接 HAL 的时基模块。我一般在新工程里直接看stm32f4xx_it.c里SysTick_Handler的实现,确认它调的是谁。

5. 二次开发:把延时精度再提一档的改造思路

如果你已经跑通了上面的代码,接下来大概率会遇到更苛刻的场景:比如用 GPIO 模拟 800kHz 的 WS2812 时序,或者驱动一颗要求纳秒级建立时间的 ADC。这时候微秒级延时就不够了,得把 DWT 的周期计数用满。我的改造思路是封装一个delay_ticks函数,直接按周期数延时,上层再根据主频换算成纳秒。

// 按内核周期数延时,精度到一个周期 void delay_ticks(uint32_t ticks) { uint32_t start = DWT->CYCCNT; while ((DWT->CYCCNT - start) < ticks); } // 纳秒延时,168MHz 下 1ns ≈ 0.168 周期,这里按周期向上取整 void delay_ns(uint32_t ns) { uint32_t ticks = (ns * (SystemCoreClock / 1000000)) / 1000; if (ticks == 0) ticks = 1; delay_ticks(ticks); }

逻辑说明:SystemCoreClock是当前主频,单位 Hz。ns * (SystemCoreClock/1000000) / 1000先把纳秒转成微秒对应的周期数,再除以 1000 得到纳秒级周期。注意整数除法会丢精度,对时间要求极端的场景可以把乘除顺序调整一下,或者直接用 64 位中间变量。ticks最小为 1,避免传 0 时函数直接返回导致上层以为延时了。

改造完记得做验证。我一般会拿一个空闲的 GPIO,在延时前后翻转,用示波器测实际脉宽,和理论值对比。比如调用delay_ns(500),168MHz 下理论是 84 个周期,约 500ns,示波器打出来如果偏差超过 10%,就要检查SystemCoreClock是否和实际主频一致,或者 DWT 是否被调试器干扰。另一个验证方法是拿两个定时器做输入捕获,一个产生标准脉冲,一个测延时后的边沿,算出误差。

还有一个容易被忽略的点:中断对 DWT 延时的影响。DWT 是周期计数,中断来了照样走,所以delay_ticks的实际耗时会被中断拉长。如果你在做严格时序,比如 WS2812 的 0 码和 1 码,必须在延时期间关中断,或者用 DMA + 定时器方案替代软件延时。我一般会在delay_ns外面包一层__disable_irq()和__enable_irq(),但关中断时间不能太长,否则会影响系统实时性。

从那以后我每次移植延时函数,都强制走一遍“示波器实测 + 优化等级切换 + 中断嵌套测试”这三步,再也不敢凭感觉写for循环了。希望这份整理能帮到你,少走几个通宵排查时序的弯路。

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

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

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

立即咨询