前面为了让程序"等一会儿",我们写过这样的空循环:
c
volatile uint32_t i;
for (i = 0; i < 72000; i++)
{
}
它能让 CPU 忙上一段时间,却没有真正回答"这一段时间到底有多长"。主频改了、编译器优化级别改了,或者循环体稍微变了,等待时间都会跟着变。
现在把这件事换一种做法:让芯片内置的 SysTick 计数器持续倒数,程序读取它当前还剩多少,再把已经倒过的计数累加起来。计数来自固定的时钟,Delay_us() 和 Delay_ms() 就有了可以计算的依据。
SysTick 是谁
SysTick 不是 TIM1、TIM2 这类 STM32 外设,而是 Cortex-M 内核自带的一只小型定时器。它只有一个向下计数器:从重装值开始,每来一个时钟减 1,减到 0 后重新装入重装值。
它有三个会在代码里遇到的寄存器:
| 寄存器 | 这里用来做什么 |
|---|---|
SysTick->LOAD |
保存每轮倒数的起点 |
SysTick->VAL |
读取当前还剩多少计数 |
SysTick->CTRL |
开关计数器、选择时钟、开关计数到 0 的中断 |
这里先把它们当作 SysTick 的操作入口,把"如何用计数值换算出时间"这件事做清楚。
从 72MHz 算到 1us
当前系统时钟按 72MHz 计算,也就是每秒 72,000,000 个内核时钟。一个时钟的时间是:
text
1 / 72,000,000 秒 ≈ 0.01389us
如果希望 SysTick 每 1ms 重装一次,需要的计数次数是:
text
72,000,000 / 1,000 = 72,000 次
计数器从 LOAD 倒数到 0,再完成一次重装,完整经过的是 LOAD + 1 次计数。因此寄存器里应当写:
c
SysTick->LOAD = 72000 - 1;
也就是 LOAD = 71999。如果把 72000 直接写进去,完整周期会多一个时钟。
每微秒对应的计数次数可以由系统时钟计算:
c
fac_us = SystemCoreClock / 1000000U;
72MHz 时,fac_us 是 72;延时 10us,就需要累计大约 720 个计数。
这里的 SystemCoreClock 必须反映当前真实的内核时钟。不能把按 72MHz 算出的 72 直接写死,否则换成 36MHz 后,所谓的 1us 会变成约 2us。
计数器绕回 0 怎么算
SysTick->VAL 是向下变化的。假设上一次读到 1000,这次读到 900,经过的计数就是:
text
1000 - 900 = 100
麻烦出现在它跨过 0 的时候。假设 LOAD = 71999,上一次读到 10,计数器经过 0 后重新装入 71999,这次读到 71990。经过的计数应当是两段相加:
text
从 10 数到 0:10 次
从 0 重装到 71999,再数到 71990:(71999 + 1) - 71990 次
总计:10 + (71999 + 1) - 71990 = 20 次
所以每次读取当前值时,分两种情况:
c
if (start_val >= current_val)
{
elapsed += start_val - current_val;
}
else
{
elapsed += (start_val + load + 1U) - current_val;
}
只要两次读取之间最多跨过一次 0,这个计算就能得到正确的经过计数。延时循环每次都很快读取一次 VAL,正常的微秒和毫秒等待都满足这个条件。
完整的延时库
Delay.h
c
#ifndef __DELAY_H
#define __DELAY_H
#include "stm32f10x.h"
void Delay_Init(void);
void Delay_us(uint32_t us);
void Delay_ms(uint32_t ms);
#endif
Delay.c
c
/* Delay.c ------ 用 SysTick 做阻塞延时
*
* 这段代码只做一件事:等到时钟真的走过了指定的拍数。
* 它不靠数循环圈数,所以编译器怎么优化都不会改变延时长度;
* 主频变了,则由 SystemCoreClock 反映出来。
*/
#include "Delay.h"
/* SysTick 每一轮倒数的起点,也就是写进 LOAD 寄存器的值。
* 下面三个变量只在 Delay.c 内部使用,所以都加 static:
* 别的 .c 文件看不见它们,也不会和别人的同名变量撞车。 */
static uint32_t delay_load;
/* 1us 对应多少个 SysTick 计数。72MHz 时算出来是 72。 */
static uint32_t delay_fac_us;
/* 初始化是否成功:0 = 还不能用,1 = 可以延时。
* Delay_us() / Delay_ms() 一进来就先检查它。 */
static uint8_t delay_ready;
void Delay_Init(void)
{
uint32_t reload;
/* 希望 SysTick 每 1ms 走完一轮,所以把 1 秒的计数平均分成 1000 份。
*
* 这里要减 1:计数器从 reload 一直数到 0,一共经过 reload + 1 次计数。
* 想让「一轮」正好等于 1ms 的计数(72000 次),寄存器里就得写
* 72000 - 1。直接写 72000 的话,一轮会多出一个时钟。 */
reload = (SystemCoreClock / 1000U) - 1U;
/* LOAD 寄存器只有 24 位,最大能放 0xFFFFFF。
* 如果主频高到 1ms 的计数超过这个上限,就装不进去。
* 这种时候宁可不启动,也不要给出错误的时间:
* delay_ready 保持为 0,后面的延时函数会直接返回。
* 这种做法以后会经常用到,进入一个函数,先看错误情况。
* 满足错误情况,直接退出,没有退出,那就是正常运行。 */
if (reload > 0x00FFFFFFU)
{
delay_ready = 0U;
return;
}
SysTick->CTRL = 0U; /* 先关掉计数器,免得配置改到一半它还在跑 */
SysTick->LOAD = reload; /* 每一轮从这个值开始往下数 */
SysTick->VAL = 0U; /* 把当前计数值清零(写任何值都是清零) */
/* CTRL 这一行同时做两件事:
* CLKSOURCE ------ 选用内核时钟,而不是内核时钟的 1/8;
* ENABLE ------ 让计数器开始工作。
*
* TICKINT 被注释掉是故意的:
* 本系列的 1ms 节拍已经由 TIM1 提供,SysTick 在这里只当一个
* 「随时能读数的计数器」用。不打开中断,就不会每 1ms 多进一次
* 空的 SysTick_Handler()。
* 注意:不开中断不影响计数,VAL 照样在变。 */
SysTick->CTRL = SysTick_CTRL_CLKSOURCE
/* | SysTick_CTRL_TICKINT */
| SysTick_CTRL_ENABLE;
delay_load = reload; /* 后面算回绕的时候要用到它 */
/* 1us 需要多少计数:72MHz ÷ 1000000 = 72。
* 这里用 SystemCoreClock 在运行时算,而不是直接写死 72:
* 换成 36MHz 以后,所谓的 1us 才不会被拉长成 2us。 */
delay_fac_us = SystemCoreClock / 1000000U;
/* 主频低于 1MHz 时上面会算出 0,那样任何延时都会立刻结束。
* 这里兜一个底,至少让它慢一点,而不是完全不等。 */
if (delay_fac_us == 0U)
{
delay_fac_us = 1U;
}
delay_ready = 1U; /* 走到这里才算初始化完成 */
}
/* 一直等到累计计数达到 target_count,然后返回实际累计到的计数。
*
* 思路:不指望一次就读出「一共过了多久」,而是把等待切成很多小段。
* 每一小段用「上次读到的 VAL」减去「这次读到的 VAL」,得到这一段走过的
* 计数,再一段一段累加起来。这样即使中间跨过了 0,计数也不会丢。 */
static uint64_t Delay_ReadElapsed(uint64_t target_count)
{
uint32_t start_val;
uint32_t current_val;
uint64_t elapsed; /* 累计值可能很大:72MHz 下延时 5 秒要 3.6 亿个计数,
* 32 位装不下,所以用 64 位 */
start_val = SysTick->VAL; /* 记下起点 */
current_val = start_val;
elapsed = 0U;
while (elapsed < target_count)
{
current_val = SysTick->VAL; /* 再问硬件一次:现在还剩多少 */
/* VAL 是往下走的。只要没跨过 0,上次读到的值一定比这次大,
* 直接相减就是这一段经过的计数。 */
if (start_val >= current_val)
{
elapsed += start_val - current_val;
}
/* 走到 else 说明这中间跨过了 0,VAL 已经重新装在 reload 附近。
* 这一段要分两截算:
* 从 start_val 数到 0 ------ 走了 start_val 次
* 从重装后的起点数到当前值 ------ 走了 (delay_load + 1) - current_val 次
* 两截相加,就是下面这一行。 */
else
{
elapsed += (start_val + delay_load + 1U) - current_val;
}
start_val = current_val; /* 这一段的终点,就是下一段的起点 */
}
return elapsed;
}
void Delay_us(uint32_t us)
{
uint32_t ctrl;
uint64_t target_count;
/* 初始化没成功,或者本来就不需要等,直接返回 */
if (delay_ready == 0U || us == 0U)
{
return;
}
/* 要等多少拍 = 微秒数 × 每微秒的计数。
* 先转成 64 位再乘,us 比较大时中间结果也不会溢出。 */
target_count = (uint64_t)us * delay_fac_us;
/* 先记下 CTRL 现在是什么样,然后只清掉 TICKINT 这一位。
* 注意这里没有动 ENABLE:计数器仍在倒数,VAL 仍在变化,
* 只是这段等待期间不让 SysTick 中断插进来。
* 延时结束后,把原来的 CTRL 原样写回去。 */
ctrl = SysTick->CTRL;
SysTick->CTRL = ctrl & ~SysTick_CTRL_TICKINT;
Delay_ReadElapsed(target_count);
SysTick->CTRL = ctrl;
}
void Delay_ms(uint32_t ms)
{
uint32_t ctrl;
uint64_t target_count;
if (delay_ready == 0U || ms == 0U)
{
return;
}
/* 每走完一轮正好是 1ms,也就是 delay_load + 1 个计数。
* 所以这里的拍数 = 毫秒数 × 每一轮的计数。
* 用乘法一次算完,不必循环调用 1000 次 Delay_us()。 */
target_count = (uint64_t)ms * (uint64_t)(delay_load + 1U);
ctrl = SysTick->CTRL;
SysTick->CTRL = ctrl & ~SysTick_CTRL_TICKINT;
Delay_ReadElapsed(target_count);
SysTick->CTRL = ctrl;
}
这段代码看着有一百多行,真正在计时的只有 Delay_ReadElapsed() 里那个循环,不到十行。其余部分都在处理"什么时候不能这么做":主频高到装不进 24 位、还没初始化、计数跨过了 0、毫秒数乘起来会溢出。软件延时总有误差,现在这些容易出错的地方都被写出来了,而不是假设它们不会发生。误差落在明处,总好过在某个换了主频的下午突然冒出来。
另外值得留意一个身份问题。SysTick 在很多工程里的标准用法是当"系统时基":开着中断,每 1ms 自动进一次 SysTick_Handler(),库里的毫秒延时就是这么累加出来的。这里把同一只计数器降级成了一个"随时能读数的秒表",不开中断。同一个外设可以有不同角色,取决于你需不需要它来打断你。
delay_fac_us 的算法也值得单独记住:它没有写死 72,而是每次都用 SystemCoreClock 现算。这个习惯在后面会反复出现------凡是和具体主频绑在一起的数字,都尽量让它在运行时算出来,而不是抄一个当时能跑通的常数。这也是这份代码换到别的 F103 板子上还能直接用的原因。
最后回到那个回绕公式。它要处理的场景其实很简单:一个只会往下数的计数器,迟早会数到 0,然后重新开始。硬件不会替你记住"上一轮已经数完了",这件事只能由软件补上,start_val + delay_load + 1U - current_val 就是补的那个动作本身。以后凡是要读一个"会绕回来"的计数器------定时器的 CNT、编码器的计数、毫秒时间戳------都会碰到同一个问题,解法也还是这一套:把绕回的那一段单独算出来,再加回去。
在程序里怎么用
初始化一次:
c
#include "Delay.h"
#include "led.h"
int main(void)
{
LED_Init();
Delay_Init();
while (1)
{
LED_Toggle();
Delay_ms(500);
}
}
需要微秒级等待时,直接调用:
c
Delay_us(10);
参考资料
关于 SysTick 的递减计数、重装和回绕计算,参考了 CSDN 博主 y258367 的文章《基于 STM32F103C8T6,Systick 滴答计时器实现精准us延时》(原文链接)。
最后橘猫说
空循环只能说"我转了很多圈",SysTick 才能告诉程序"时钟真的走了这么多拍"。从这里开始,Delay_us() 和 Delay_ms() 就是后续代码里统一的等待接口。
嵌入式里有很多东西,本身就不是完美的:软件延时不够精确,陀螺仪会零漂,系统也可能卡死。但是,所谓的完美真的存在吗?不完美就是不能用了吗?不是的。裸奔的 delay 循环依赖主频,但我们还有外部晶振和 RTC 时钟;陀螺仪会零漂,但我们还有加速度计和磁力计帮它校正;系统卡死了,还有看门狗强制复位。
嵌入式芯片上没有"完美"这个寄存器,人生也没有。但是我们有选择,也有感情。只要你不孤立地运行自己,只要你愿意挂载上那些"外部标准"和"长期参照",过程虽然曲折,你的延时总会等到合适的时机、合适的人,你的漂移总会被一个值得的人理解。你不是要成为一颗无误差的晶振,你要成为的是一个能够自适应、自校准、带反馈的闭环系统------那样的系统,才配得上真实世界的风浪。
最后蜘蛛说
终于可以不用那不顺眼的for延时了。