3. SysTick定时器与时间基准:任务调度的心跳

SysTick,说白了就是 Cortex-M 内核自带的一个 24 位倒计时定时器。你不需要外接任何晶振或配置复杂的时钟树,它就在那里,随时能用。我个人习惯把它叫做“系统心跳”——没有它,任务调度就是一句空话。

为什么这么说?你想想看,任务调度需要什么?需要知道什么时候该切换任务,需要精确的延时,需要统计任务运行时间。所有这些,都依赖一个稳定、精准的时间基准。SysTick 就是干这个的。

3.1 SysTick 寄存器详解

SysTick 只有 4 个寄存器,不多,但每个都很关键。我刚开始学的时候觉得寄存器太多记不住,后来发现其实就这几个,背都背下来了。

寄存器 地址 功能描述
CTRL 0xE000E010 控制与状态寄存器:使能、时钟源、中断标志
LOAD 0xE000E014 重装载值寄存器:倒计时结束后自动加载的值
VAL 0xE000E018 当前值寄存器:读取当前倒计时值,写任意值清零
CALIB 0xE000E01C 校准值寄存器:厂家提供的校准参数,一般不用

重点说下 CTRL 寄存器,它的位定义是这样的:

  • 位 0 (ENABLE):SysTick 使能位。写 1 启动,写 0 停止。
  • 位 1 (TICKINT):中断使能位。写 1 允许 SysTick 产生异常,写 0 则只计数不中断。
  • 位 2 (CLKSOURCE):时钟源选择。0 = 外部参考时钟(通常是 HCLK/8),1 = 处理器时钟(HCLK)。
  • 位 16 (COUNTFLAG):只读标志位。倒计时到 0 时硬件置 1,读取后自动清零。

关键点:CLKSOURCE 位决定了 SysTick 的计数频率。如果选择 HCLK(位 2 = 1),那么 SysTick 每 1/HCLK 秒减 1。假设 HCLK = 72MHz,那么每减 1 就是 1/72 微秒。这个精度,做微秒级延时绰绰有余。

3.2 毫秒级延时函数实现

先来个简单的。毫秒级延时,说白了就是让 SysTick 倒计时 N 毫秒,然后等着。我一般这样写:

/* 毫秒延时函数 */
static __IO uint32_t uwTick;

void SysTick_Handler(void)
{
    uwTick++;
}

void delay_ms(uint32_t ms)
{
    uint32_t tickstart = uwTick;
    while ((uwTick - tickstart) < ms);
}

嗯,这里有个细节要注意。为什么用 uwTick - tickstart 而不是 uwTick < tickstart + ms?因为 uwTick 是 32 位无符号数,会溢出。用减法可以避免溢出带来的问题——即使 uwTick 绕回 0,减法结果依然是正确的差值。这个技巧我在项目里吃过亏才学会的。

初始化 SysTick 的代码也很简单:

/* 初始化 SysTick,1ms 中断一次 */
void SysTick_Init(void)
{
    /* 重装载值 = HCLK / 1000 - 1 */
    /* 假设 HCLK = 72MHz */
    SysTick->LOAD = 72000 - 1;
    
    /* 选择 HCLK 作为时钟源,使能中断,启动计数器 */
    SysTick->VAL  = 0;
    SysTick->CTRL = (1 << 1) | (1 << 2) | (1 << 0);
}

小提示:LOAD 寄存器的值为什么减 1?因为 SysTick 从 LOAD 减到 0 一共是 LOAD+1 个时钟周期。要得到 1ms 中断,需要 (HCLK/1000) 个周期,所以 LOAD = HCLK/1000 - 1。

3.3 微秒级延时函数实现

毫秒级延时靠中断,微秒级延时就不能用中断了——中断响应有延迟,微秒级精度根本保不住。我的做法是轮询 COUNTFLAG 标志位:

/* 微秒延时函数(轮询方式) */
void delay_us(uint32_t us)
{
    uint32_t reload = SysTick->LOAD;
    uint32_t ticks = us * (SystemCoreClock / 1000000);
    uint32_t start = SysTick->VAL;
    uint32_t elapsed;
    
    while (1) {
        uint32_t current = SysTick->VAL;
        
        if (start > current) {
            elapsed = start - current;
        } else {
            /* 发生了溢出 */
            elapsed = (reload - current) + (start + 1);
        }
        
        if (elapsed >= ticks) {
            break;
        }
    }
}

这段代码的逻辑是:记录起始的 VAL 值,然后不断读取当前 VAL,计算已经过去了多少个时钟周期。如果发生了溢出(start < current),就加上一个重装载值。

注意:微秒级延时函数会占用 CPU 100% 的算力。在延时期间,CPU 一直在循环里转,不能做其他事。所以只适合短时间延时(几十到几百微秒),长时间延时请用毫秒级中断方式。

3.4 时间基准的校准与验证

写完了延时函数,怎么验证它准不准?我一般用 GPIO 翻转法:

/* 验证延时精度 */
void delay_test(void)
{
    GPIOA->BSRR = GPIO_PIN_0;   /* 拉高 */
    delay_us(100);
    GPIOA->BRR  = GPIO_PIN_0;   /* 拉低 */
    delay_us(100);
}

然后用示波器看 PA0 的波形。如果频率是 5kHz(周期 200μs,高电平 100μs,低电平 100μs),说明延时是准的。我曾经在一个项目里发现延时偏大,查了半天发现是 HCLK 配置错了——系统时钟跑在 36MHz 而不是 72MHz,LOAD 值自然就不对了。

验证方法 工具 精度 适用场景
GPIO 翻转 + 示波器 示波器 ±1 个时钟周期 微秒级验证
定时器捕获 另一个定时器 ±1 个时钟周期 自动化测试
逻辑分析仪 逻辑分析仪 ±10ns 高精度验证

3.5 SysTick 作为任务调度的心跳

任务调度需要什么?需要一个周期性的“滴答”信号。SysTick 的 1ms 中断正好干这个。我通常这样组织:

/* 系统心跳变量 */
volatile uint32_t g_sys_tick_ms = 0;

void SysTick_Handler(void)
{
    g_sys_tick_ms++;
    
    /* 在这里可以添加任务调度器的时基处理 */
    /* 比如:检查任务是否到期、更新软件定时器等 */
}

这个 g_sys_tick_ms 就是整个系统的“心跳”。所有需要时间的地方都依赖它:

  • 任务延时:任务主动让出 CPU,等待若干心跳后重新调度
  • 超时检测:等待外设响应时,设置一个超时时间
  • 周期执行:每隔 N 个心跳执行一次特定任务
  • 性能统计:统计任务运行时间、CPU 利用率

核心思想:SysTick 中断是任务调度的“心脏”。它每 1ms 跳动一次,驱动整个系统的运行。没有这个心跳,任务调度就是死水一潭。

3.6 知识结构图:SysTick 与时间基准

下面这张图展示了 SysTick 在整个系统中的位置和作用:

SysTick 时间基准结构图 SysTick 时间基准结构图 HCLK (72MHz) CLKSOURCE 选择 SysTick 定时器 24位倒计时 · 自动重装载 SysTick 中断 COUNTFLAG 轮询 任务调度器 软件定时器 延时函数 两种工作模式 • 中断模式:1ms 心跳 • 轮询模式:微秒级延时 关键寄存器 CTRL · LOAD · VAL · CALIB

3.7 避坑指南

最后,分享几个我踩过的坑:

  • 中断优先级:SysTick 中断的优先级默认是 -1(最高),不要随意改低,否则任务调度可能被其他中断阻塞。
  • 临界区保护:在中断中读写 g_sys_tick_ms 时,主循环中也要用临界区保护,否则可能读到不一致的值。
  • 时钟源切换:如果系统运行中切换了 HCLK 频率,记得重新计算 LOAD 值,否则时间基准就乱了。
  • 微秒延时的精度:轮询方式受中断影响,如果 SysTick 中断频繁触发,微秒延时的实际时间会偏大。

我的习惯:在项目初期就把 SysTick 初始化好,然后所有延时都基于 g_sys_tick_ms 实现。这样后期加任务调度、软件定时器什么的,直接复用就行,不用改代码。