Delay_us 与 Delay_ms:用 SysTick 做一把可靠的时间尺

前面为了让程序"等一会儿",我们写过这样的空循环:

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延时了。

相关推荐
小程序设计3 小时前
基于STM32森林火灾远程监测模块设计
stm32·单片机·嵌入式硬件
沐欣工作室_lvyiyi5 小时前
基于STM32单片机汽车充电桩控制模块设计(论文+源码)
stm32·单片机·汽车·毕业设计
逐梦之程5 小时前
嵌入式固件升级IAP及调试软件
stm32·单片机
ebiobiz6 小时前
STM32F4 Unity 单元测试与多次测试失败率统计详细步骤
stm32·unity·单元测试
Brilliantwxx7 小时前
【STM32】 I2C 中断模式收发数据实战:从阻塞到中断的架构改造(AT24C02)
stm32·单片机·嵌入式硬件
星河单片机8 小时前
单片机成品方案:基于STM32单片机智能门禁系统
stm32·单片机·嵌入式硬件
en.en..10 小时前
STM 与 ARM
stm32
Naisu Xu11 小时前
记一次STM32H503冷启动后进入HardFult_Handler的问题
stm32·单片机·嵌入式硬件·调试·内存问题
程序员正茂12 小时前
Keil中将device由 STM32F103ZE 改为 STM32F103C8
stm32·单片机·嵌入式硬件