4. 工程模板概念

系统滴答定时器,圈里人都叫它 SysTick。说白了,它就是 Cortex-M 内核自带的一个 24 位递减计数器。我刚开始接触 STM32 时,总觉得这玩意儿不就是个定时器嘛,有啥特别的?后来做项目多了才明白,它可是整个操作系统的“心跳”,也是我们做裸机延时最趁手的工具。

嗯,咱们这节课就把 SysTick 彻底吃透。从寄存器怎么配,到怎么写出好用的微秒级和毫秒级延时函数,一步到位。

4.1 SysTick 寄存器详解

SysTick 只有 4 个寄存器,不多,但个个关键。我习惯把它们分成三部分:控制寄存器、重装载寄存器、当前值寄存器,还有一个校准寄存器(这个用得少,但了解一下没坏处)。

寄存器 地址偏移 描述 复位值
CTRL 0x00 控制和状态寄存器 0x00000000
LOAD 0x04 重装载值寄存器 0x00000000
VAL 0x08 当前值寄存器 0x00000000
CALIB 0x0C 校准值寄存器(只读) 0x00000000

咱们重点看前三个。CTRL 寄存器里最常用的三个位:ENABLE(位 0)使能定时器,TICKINT(位 1)使能中断,CLKSOURCE(位 2)选择时钟源。这里有个坑,我踩过——CLKSOURCE 为 1 时用内核时钟(HCLK),为 0 时用 HCLK/8。不同系列默认值可能不一样,千万别想当然。

核心要点:SysTick 是 24 位计数器,最大值 0xFFFFFF(16,777,215)。重装载值写进 LOAD 寄存器后,计数器从 LOAD 值递减到 0,然后自动重装。VAL 寄存器读的时候返回当前计数值,写任何值都会清零。

4.2 实现微秒级延时函数

微秒级延时,说白了就是“数数”。不用中断,直接轮询 COUNTFLAG 位。我早期做的一个传感器驱动项目,要求时序精度在 5 微秒以内,就是用这个思路搞定的。

实现原理很简单:先把 SysTick 配好,然后根据要延时的微秒数算出需要计多少个时钟周期,写进 LOAD 寄存器,接着等计数器减到 0 就行。

// 微秒级延时(不使能中断,轮询方式)
void delay_us(uint32_t us)
{
    uint32_t reload = SystemCoreClock / 1000000;  // 1微秒需要的时钟周期数
    uint32_t ticks = us * reload;                 // 总共需要计的数
    
    // 配置 SysTick
    SysTick->CTRL = 0;                            // 先关闭
    SysTick->LOAD = ticks & 0x00FFFFFF;           // 设置重装载值(24位)
    SysTick->VAL = 0;                             // 清空当前值
    SysTick->CTRL = 0x01;                         // 使能,内核时钟,不使能中断
    
    // 等待计数完成
    while ((SysTick->CTRL & 0x00010000) == 0);    // 等待 COUNTFLAG 置位
    
    SysTick->CTRL = 0;                            // 关闭 SysTick
}

注意:这个函数有个限制——延时时间不能超过 0xFFFFFF / (SystemCoreClock / 1000000) 微秒。比如 72MHz 主频下,最大延时约 233 微秒。超过这个值,就得用循环或者毫秒级延时了。

4.3 实现毫秒级延时函数

毫秒级延时,我建议用中断方式。为什么?因为不阻塞 CPU,你可以在中断里做其他事。比如我做过一个 LED 呼吸灯效果,主循环跑逻辑,SysTick 中断里更新 PWM 占空比,配合得天衣无缝。

实现思路:配置 SysTick 每 1 毫秒产生一次中断,在中断服务函数里递减一个全局变量。延时函数就等这个变量减到 0。

static volatile uint32_t delay_ms_count = 0;

// SysTick 中断服务函数
void SysTick_Handler(void)
{
    if (delay_ms_count > 0)
    {
        delay_ms_count--;
    }
}

// 毫秒级延时(中断方式)
void delay_ms(uint32_t ms)
{
    delay_ms_count = ms;
    while (delay_ms_count > 0);
}

// 初始化 SysTick,产生 1ms 中断
void SysTick_Init(void)
{
    uint32_t reload = SystemCoreClock / 1000;     // 1毫秒需要的时钟周期数
    
    SysTick->CTRL = 0;                            // 先关闭
    SysTick->LOAD = reload - 1;                   // 设置重装载值(注意减1)
    SysTick->VAL = 0;                             // 清空当前值
    SysTick->CTRL = 0x07;                         // 使能 + 内核时钟 + 使能中断
}

小技巧:LOAD 寄存器为什么要减 1?因为计数器从 LOAD 减到 0 需要 LOAD+1 个时钟周期。比如你想计 1000 个周期,LOAD 就写 999。这个细节我当年调试了半小时才想明白。

4.4 编写 delay.c 与 delay.h

好了,现在咱们把上面这些代码整理成规范的工程文件。我习惯把延时函数单独放一个模块,方便移植。你想想看,换个芯片或者改个主频,只需要改一个头文件里的宏定义就行。

delay.h 头文件

#ifndef __DELAY_H
#define __DELAY_H

#include "stm32f10x.h"  // 根据芯片型号调整头文件

void SysTick_Init(void);
void delay_us(uint32_t us);
void delay_ms(uint32_t ms);

#endif

delay.c 源文件

#include "delay.h"

static volatile uint32_t delay_ms_count = 0;

void SysTick_Init(void)
{
    uint32_t reload = SystemCoreClock / 1000;
    
    SysTick->CTRL = 0;
    SysTick->LOAD = reload - 1;
    SysTick->VAL = 0;
    SysTick->CTRL = 0x07;
}

void delay_us(uint32_t us)
{
    uint32_t reload = SystemCoreClock / 1000000;
    uint32_t ticks = us * reload;
    
    if (ticks > 0x00FFFFFF)
    {
        ticks = 0x00FFFFFF;  // 限制最大值
    }
    
    SysTick->CTRL = 0;
    SysTick->LOAD = ticks & 0x00FFFFFF;
    SysTick->VAL = 0;
    SysTick->CTRL = 0x01;
    
    while ((SysTick->CTRL & 0x00010000) == 0);
    
    SysTick->CTRL = 0;
}

void delay_ms(uint32_t ms)
{
    delay_ms_count = ms;
    while (delay_ms_count > 0);
}

void SysTick_Handler(void)
{
    if (delay_ms_count > 0)
    {
        delay_ms_count--;
    }
}

嗯,这里有个细节我想强调一下:delay_us 函数里我加了最大值限制。为什么?因为如果传入的 us 太大,计算出来的 ticks 可能超过 24 位能表示的范围。我曾经在一个项目里没加这个限制,结果延时时间完全不对,排查了半天才发现是溢出问题。

使用建议:

  • 在 main 函数最开始调用 SysTick_Init() 完成初始化
  • 微秒延时用于短时间、高精度场景(如传感器时序、通信协议)
  • 毫秒延时用于一般场景(如 LED 闪烁、按键消抖)
  • 如果同时使用两种延时,确保 SysTick 中断优先级设置合理

4.5 SysTick 工作流程总览

为了让你更直观地理解 SysTick 的工作流程,我画了一张图。从初始化到计数完成,每一步都清清楚楚。

SysTick 工作流程图 开始配置 SysTick 步骤1:选择时钟源(CLKSOURCE) 步骤2:写入 LOAD 寄存器 步骤3:VAL 寄存器写 0 步骤4:使能 SysTick(ENABLE=1) 计数器递减至 0? 否 是 延时完成 中断方式:触发中断 轮询方式:检查 COUNTFLAG

这张图把整个流程串起来了。你写代码的时候,脑子里过一遍这个流程,基本不会出错。

4.6 避坑指南与经验分享

最后,我把自己这些年踩过的坑总结一下,希望能帮你少走弯路。

  • 时钟源选择要明确:我曾经在一个项目里,从 F103 换到 F407,没注意 SysTick 时钟源配置,结果延时时间差了一倍。后来养成习惯,每次初始化都显式设置 CLKSOURCE。
  • 中断优先级别设太高:SysTick 中断优先级如果设得比某些外设中断还高,可能会影响实时性。我一般设成最低优先级,或者用 PendSV 来处理。
  • 微秒延时别在中断里用:这个函数是轮询的,会阻塞 CPU。如果在中断服务函数里调用,轻则影响其他中断响应,重则导致系统卡死。
  • 多任务环境下注意重入:如果用了 RTOS,SysTick 通常被操作系统占用。这时候再用自己的延时函数,就会冲突。解决办法是用操作系统提供的延时 API。

调试小技巧:如果你发现延时不准,先检查 SystemCoreClock 这个变量对不对。很多新手移植代码时忘了更新这个值,导致所有延时都按错误的主频计算。我一般会在初始化后先跑一个 1 秒的延时,用示波器量一下 GPIO 翻转的周期,验证延时是否准确。

好了,SysTick 这块的内容就讲到这里。代码已经给你了,原理也讲透了,剩下的就是动手练。把 delay.c 和 delay.h 加到你的工程里,跑几个例子试试,感受一下微秒和毫秒延时的区别。嗯,实践出真知,动手吧。