STM32延时的核心需求:精度和资源占用平衡
在STM32嵌入式开发中,精确延时是非常基础但又极其关键的功能。无论是驱动单总线传感器(比如DS18B20)、控制LCD屏幕时序、还是生成精确的脉冲信号,都需要用到微秒级甚至纳秒级精度的延时。很多新手刚开始使用STM32,会直接用简单的空循环延时,或者随便用系统时钟分频做延时,结果实际使用中发现延时误差很大,温度传感器读不出数据,屏幕驱动时序不对点不亮,遇到问题还不知道为什么不准。
STM32有多种实现延时的方式,不同方式精度不同,适用场景也不同。本文从延时的基本需求出发,梳理几种常用精确延时的实现原理、代码实现和优缺点,帮开发者根据自己的场景选择最合适的方案,解决延时不准的问题。
一、STM32延时的核心需求:精度和资源占用平衡
我们说的精确延时,一般要求误差不超过10%,对于高精度时序要求,误差要控制在1%以内。和Linux、RTOS里的休眠不同,我们说的延时是总线阻塞式延时,也就是调用延时函数之后,CPU一直停在这里等待延时结束,不能做其他任务,因为很多外设时序要求总线必须等待延时完成,才能进行下一步操作。
阻塞式精确延时有两个核心要求:
精度足够:微秒级延时误差不能超过几微秒,满足绝大多数外设的时序要求;
实现简单,资源占用少:最好不要占用一个硬件定时器,浪费宝贵的硬件资源,能利用现有资源实现最好。
接下来我们从最简单的实现方式讲起,一步步分析不同实现方式的优劣。
二、最基础的空循环延时:原理简单,但精度差误差大
空循环延时是最容易想到的实现方式,就是写一个空的for循环,让CPU循环N次,靠循环消耗的时间实现延时。原理非常简单,不需要任何硬件资源,几行代码就能实现,我们最常见的写法是这样的:
void delay_us(uint32_t us) {
uint32_t i;
for (i = 0; i < us * (SystemCoreClock / 1000000); i++) {
__NOP();
}
}
这种方式看起来简单,实际精度却非常差,主要问题有四个:
不同优化等级下循环速度不一样:如果开启O2优化,编译器会把空循环优化掉,整个循环直接没了,根本不会产生延时,要是优化等级低,循环指令周期变长,延时就会变长,误差很大;
指令周期不固定:一个for循环包含了计数器递增、判断跳转、NOP多个指令,不同架构下指令周期不同,计算出来的循环次数很容易错,哪怕差一个时钟周期,微秒级延时误差就会超过10%;
中断会影响精度:如果延时过程中来了一个中断,CPU去处理中断,延时时间就会变长,误差几微秒到几十微秒不等,对时序要求高的场景直接就出错了;
主频变化就失效:如果代码换了一个主频,原来的循环次数就不对了,哪怕是同一项目,动态调整主频之后延时就不准了,需要重新计算循环次数。
空循环唯一的优点就是不需要硬件资源,代码简单,只适合对精度要求不高的场景,比如LED闪烁这种几毫秒级别的延时,不适合需要精确延时的外设驱动场景。如果想要更高的精度,需要用基于硬件的实现方式。
三、内核定时器SysTick实现延时:资源零占用,精度足够常用场景
SysTick(系统滴答定时器)是Cortex-M内核自带的一个24位向下计数的定时器,专门给操作系统提供系统时钟,也可以用来做精确延时,不需要占用额外的硬件定时器,是目前STM32裸机开发中最常用的精确延时实现方式。
1. 实现原理
SysTick的时钟一般直接接内核时钟(也就是系统主频),计数频率等于系统主频,计数周期是1个主频时钟周期,精度可以达到1个时钟周期,非常高。我们只需要配置SysTick为1计数周期1us,或者直接计算要延时多少个时钟周期,让计数器倒计数,等待计数完成就返回,就能得到精确的延时。
常见的实现方式有两种:
配置SysTick的周期为1ms,给系统提供心跳,然后延时的时候累计心跳计数,只能实现毫秒级延时,精度不够,不适合微秒级延时;
延时的时候临时设置SysTick的计数值,延时完成之后恢复原来的配置,这种方式可以实现微秒级精确延时,精度很高,是主流的实现方式。
2. 代码实现(标准库版本)
void delay_init(void) {
// SysTick时钟选择为内核时钟,不需要开启中断,只需要计数
SysTick_Config(SystemCoreClock / 1000); // 先配置1ms周期,给系统心跳用,如果不需要心跳也可以直接用的时候临时改
}
// 微秒级精确延时
void delay_us(uint32_t us) {
uint32_t ticks;
uint32_t told;
uint32_t tnow;
uint32_t cnt = 0;
uint32_t reload = SysTick->LOAD; // 获取原来的自动重装载值
ticks = us * (SystemCoreClock / 1000000); // 计算需要的时钟周期数
cnt = 0;
told = SysTick->VAL; // 获取当前的计数器值
while (1) {
tnow = SysTick->VAL;
if (tnow != told) {
if (tnow < told) {
cnt += told - tnow; // 计数器还没回绕,直接加差值
} else {
cnt += reload - tnow + told; // 计数器回绕,补全计数
}
told = tnow;
if (cnt >= ticks) {
break; // 计数够了,退出
}
}
}
}
// 毫秒级延时,基于微秒封装
void delay_ms(uint32_t ms) {
uint32_t i;
for (i = 0; i < ms; i++) {
delay_us(1000);
}
}
如果用HAL库开发,其实可以更简单,HAL已经提供了HAL_Delay()实现毫秒级延时,但HAL_Delay本质是靠SysTick心跳实现,只能实现毫秒级,而且依赖中断,精度不够,微秒级延时还是用上面的方法自己实现最好。
3. 这种方式的优缺点
优点:
不需要占用额外的硬件定时器,用Cortex-M内核自带的SysTick,资源零占用,对STM32来说任何型号都能用,兼容性好;
精度很高,可以达到1微秒以内的误差,满足绝大多数外设驱动的时序要求,比如DS18B20、OLED、单总线传感器都能正常工作;
主频变化的时候只需要重新计算计数值,不用修改代码,SystemCoreClock变量会自动更新,适配不同主频。
缺点:
如果延时过程中关闭了SysTick,就没法用,一般不会出现这种情况;
如果中断优先级比SysTick高,延时过程中被高优先级中断打断,还是会有误差,但绝大多数场景下误差都在可接受范围内;
是阻塞式延时,和所有阻塞延时一样,延时的时候CPU不能做其他任务,但这是阻塞式延时的需求决定的,不是这种实现方式本身的问题。
总的来说,SysTick实现精确延时是目前裸机开发中性价比最高的方式,精度足够,资源占用为零,代码简单,是绝大多数场景的首选。
四、定时器周期中断实现延时:更高精度,适合大延时
如果需要更长的延时,或者项目中已经有多余的硬件定时器,也可以用定时器来实现精确延时,原理就是配置定时器的计数频率为1MHz,这样一个计数周期就是1us,需要延时多少微秒就设置多少计数值,然后等待定时器计数完成,非常直观。
1. 实现原理
以通用定时器TIM2为例,系统主频72MHz,配置预分频器PSC为71,那么定时器的计数频率就是72MHz/(71+1) = 1MHz,每个计数就是1us,我们开启定时器的更新中断,需要延的时候设置自动重装载值为要延时的us数,然后等待计数完成,计数完成之后触发中断,唤醒等待,精度可以做到1us,误差比SysTick还小。
如果不需要中断,也可以用查询的方式,不断读取计数器的值,等计数器到了就返回,一样能实现精确延时,不需要占用中断资源。
2. 核心实现代码
void TIM_Delay_Init(void) {
// 配置TIM2,计数频率1MHz
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE);
TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure;
TIM_TimeBaseStructure.TIM_Period = 1000; // 初始周期随便设
TIM_TimeBaseStructure.TIM_Prescaler = 71; // 72MHz -> 1MHz
TIM_TimeBaseStructure.TIM_ClockDivision = 0;
TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up;
TIM_TimeBaseInit(TIM2, &TIM_TimeBaseStructure);
TIM_Cmd(TIM2, ENABLE);
}
void TIM_Delay_us(uint32_t us) {
// 设置计数器值,从零开始计数
TIM_SetCounter(TIM2, 0);
// 等待计数到目标值
while (TIM_GetCounter(TIM2) < us);
}
void TIM_Delay_ms(uint32_t ms) {
while (ms--) {
TIM_Delay_us(1000);
}
}
这种查询方式的实现非常简单,不需要开中断,代码比SysTick还简洁,精度更高,因为定时器计数器独立于CPU,哪怕CPU被中断打断,计数器还是会继续计数,所以延时时间不会因为中断而变长,误差比SysTick更小,对精度要求特别高的场景更有优势。
3. 优缺点总结
优点:
精度最高,哪怕被中断打断,延时时间也不会变,误差不超过1微秒,适合高精度时序要求;
代码非常简单,逻辑清晰,不容易出问题;
可以实现很长的延时,只要定时器位数足够,32位定时器可以延时几个小时,都能精确控制。
缺点:
需要占用一个硬件定时器,STM32的定时器资源有限,很多项目中定时器都被用在PWM、输入捕获这些地方,没有多余的定时器给延时用,浪费资源;
查询方式会一直占用CPU,和其他阻塞延时一样,但这是阻塞延时本身的需求,不影响使用。
如果你的项目中刚好有多余的硬件定时器,对精度要求又很高,那么定时器查询方式是最好的选择,精度比SysTick更高。
五、更高阶:DWT周期计数器实现纳秒级延时,不需要占用任何资源
Cortex-M3/M4/M7内核都自带DWT(数据观察点和跟踪)模块,DWT里面有一个自由运行的周期计数器,每个时钟周期自动加1,我们可以直接读这个计数器的值来做延时,精度可以达到一个时钟周期,也就是纳秒级,而且不需要占用任何定时器资源,也不需要修改任何配置,直接就能用,是目前最高精度的免费延时实现方式。
1. 实现原理
DWT本来是用来做调试跟踪的,它的CYCCNT计数器从内核复位开始,每个时钟周期自动加1,只要开启之后就一直运行,不需要CPU干预,我们需要延时的时候,先读取当前的CYCCNT值,然后不断读新的CYCCNT值,差值等于我们需要的时钟周期数就返回,就能实现精确延时,精度就是一个时钟周期,比前面几种方式都高。
2. 代码实现
void DWT_Delay_Init(void) {
// 开启DWT计数器
CoreDebug->DEMCR |= CoreDebug_DEMCR_TRCENA_Msk; // 开启DWT时钟
DWT->CYCCNT = 0; // 清零计数器
DWT->CTRL |= DWT_CTRL_CYCCNTENA_Msk; // 开启周期计数
}
void DWT_Delay_us(uint32_t us) {
uint32_t start = DWT->CYCCNT;
uint32_t cycles = us * (SystemCoreClock / 1000000); // 计算需要的周期数
while ((DWT->CYCCNT - start) < cycles);
}
void DWT_Delay_ns(uint32_t ns) {
uint32_t start = DWT->CYCCNT;
uint32_t cycles = ns * (SystemCoreClock / 1000000000);
while ((DWT->CYCCNT - start) < cycles);
}
这种实现方式简直是完美,不需要占用任何硬件资源,不需要中断,精度直接到纳秒级,比前面所有方式都高,而且代码非常简洁,现在越来越多的开发者开始用这种方式做精确延时。
3. 注意事项和优缺点
注意事项:
只有Cortex-M3及以上内核才有DWT,Cortex-M0/M0+没有这个模块,没法用,STM32F1、F4、H7、G4系列都是M3/M4/M7内核,都能用,STM32F0系列就用不了;
有些禁用调试的场景下,DWT可能会被关闭,一般正常开发使用都没问题,量产产品也能正常用。
优点:
精度最高,纳秒级精度,比任何其他方式都准,完全满足最高精度的时序要求;
不占用任何硬件资源,不需要定时器,不需要SysTick配置,只要开一次就一直能用;
计数器是硬件自动计数,哪怕被中断打断,计数还是正确,延时时间不会变长,误差极小;
代码非常简洁,几十行代码就搞定,不需要复杂配置。
缺点:
只支持Cortex-M3及以上内核,不支持M0/M0+,兼容性差一点;
几乎没有其他缺点,是目前STM32精确延时的最优实现。
六、实际开发中常见问题和解决方法
不管用哪种方式,实际开发中经常会遇到延时不准的问题,我们整理了最常见的原因和解决方法:
1. 系统主频修改之后延时不准
原因:计算计数值的时候用了硬编码的主频,不是用SystemCoreClock变量,修改主频之后硬编码不对,就不准了。解决方法:所有计数值计算都用SystemCoreClock动态计算,修改主频之后SystemCoreClock会自动更新,不需要修改代码。
2. 短延时误差大
比如延时1us,实际出来是3us,一般是因为函数调用本身有开销,进入函数、返回都需要几个时钟周期,误差就出来了。解决方法:可以把函数改成内联函数,减少函数调用开销,或者调整计算的计数值,减去函数调用本身消耗的周期,就能修正误差。
3. 中断导致延时变长
SysTick和空循环很容易遇到这个问题,延时过程中来了中断,处理中断耽误了时间,延时就变长了。解决方法:如果对精度要求高,换成DWT或者硬件定时器延时,硬件定时器不管CPU有没有被打断,都会一直计数,延时时间不会变。如果一定要用SysTick,可以在延时之前关闭中断,延时完成再开,但是关闭中断会影响其他中断响应,不推荐。
4. 编译器优化把空循环优化没了
空循环延时常见问题,开启O2优化之后,编译器发现空循环没有用,直接把整个循环删掉了,就没有延时了。解决方法:循环里面加一个volatile变量,每次循环都修改这个变量,编译器就不会优化,或者直接不用空循环,用SysTick或者DWT更靠谱。
总结
STM32实现精确延时有四种主流方式,各自适合不同的场景:
对精度要求不高,随便用用:选空循环,代码简单,不用额外资源;
普通裸机开发,大多数外设驱动:选SysTick,不用占用额外硬件,精度足够,兼容性最好,所有Cortex-M内核都能用;
有多余硬件定时器,对精度要求高:选硬件定时器查询,精度比SysTick高,不怕中断打断;
M3及以上内核,追求最高精度零资源占用:选DWT周期计数器,纳秒级精度,不用占用任何资源,是目前最优的方案。
只要选对适合自己场景的实现方式,避开编译器优化、中断影响、硬编码主频这些常见的坑,就能实现稳定的精确延时,满足绝大多数嵌入式开发的需求。 以上是根据你的要求生成的内容,如需修改可继续提出。





