4. 系统滴答定时器 SysTick:HAL_Delay() 实现原理、LL库实现微秒级延时、裸机调度框架雏形

各位同学,咱们今天聊聊 SysTick。这个外设可以说是 STM32 里最不起眼、但最常用的一个模块。你写的每个 HAL_Delay(),背后都是它在干活。

我刚开始用 STM32 的时候,觉得 SysTick 就是个定时器,没啥好研究的。直到有一次做多任务调度,才发现它的价值远不止延时这么简单。说白了,它是整个裸机系统的“心跳”。

4.1 SysTick 是什么?

SysTick 是 Cortex-M 内核自带的一个 24 位递减计数器。它不属于任何外设总线,而是直接挂在内核上。这意味着无论你怎么重置外设时钟,SysTick 都能正常工作。

特性 说明
位宽 24 位,最大值 16,777,215
计数方向 递减计数,减到 0 时触发中断
时钟源 可选择 HCLK 或 HCLK/8
中断 计数到 0 时产生异常 #15
自动重装 支持,减到 0 后自动加载重装值

它的寄存器只有四个:CTRL(控制与状态)、LOAD(重装值)、VAL(当前值)、CALIB(校准值)。操作起来非常简单。

核心要点:SysTick 是内核级定时器,不依赖外设时钟树。即使你改了系统时钟,只要内核还在跑,SysTick 就能工作。

4.2 HAL_Delay() 的实现原理

咱们先看看 HAL 库是怎么用 SysTick 实现毫秒延时的。其实原理很简单:

  1. 系统初始化时,把 SysTick 配置成 1ms 中断一次
  2. 每次中断,一个全局变量 uwTick 加 1
  3. HAL_Delay() 不断查询 uwTick 是否达到目标值

来看代码:

// 在 HAL_Init() 中被调用
__weak HAL_StatusTypeDef HAL_InitTick(uint32_t TickPriority)
{
    // 配置 SysTick 1ms 中断
    HAL_SYSTICK_Config(SystemCoreClock / 1000);
    HAL_NVIC_SetPriority(SysTick_IRQn, TickPriority, 0);
    return HAL_OK;
}

// SysTick 中断服务函数
void SysTick_Handler(void)
{
    HAL_IncTick();  // uwTick++
}

// HAL_Delay 实现
__weak void HAL_Delay(uint32_t Delay)
{
    uint32_t tickstart = uwTick;
    uint32_t wait = Delay;

    // 防止中断影响,加个锁
    while ((uwTick - tickstart) < wait)
    {
    }
}

这里有个细节:uwTick - tickstart 这个写法,其实是为了处理溢出。当 uwTick 从 0xFFFFFFFF 回到 0 时,减法结果依然是正确的。这是无符号溢出的特性,我当年第一次看到时还愣了一下。

注意:HAL_Delay() 是阻塞延时。如果在中断里调用,会导致系统卡死。我见过不少新手在中断服务函数里写 HAL_Delay(10),结果程序直接跑飞。

4.3 LL 库实现微秒级延时

HAL 库只提供了毫秒级延时。但很多场景,比如驱动 WS2812 灯带、读取 DHT11 传感器,都需要微秒级精度。这时候就得自己动手了。

LL 库没有直接提供 LL_Delay_us(),但我们可以用 SysTick 的 COUNTFLAG 位来实现。思路是这样的:

  • 读取当前 SysTick 的 VAL 值
  • 计算目标计数差值
  • 轮询等待 VAL 递减到目标值

代码实现:

// 微秒级延时(基于 SysTick,不依赖中断)
void delay_us(uint32_t us)
{
    uint32_t ticks;
    uint32_t reload = SysTick->LOAD;
    uint32_t current_val;

    // 计算需要的计数次数
    // 假设 SysTick 时钟为 72MHz,1us = 72 个计数
    ticks = us * (SystemCoreClock / 1000000);

    // 如果延时太长,分多次执行
    while (ticks > reload)
    {
        current_val = SysTick->VAL;
        // 等待当前周期结束
        while ((SysTick->CTRL & SysTick_CTRL_COUNTFLAG_Msk) == 0);
        ticks -= (reload - current_val);
    }

    // 执行剩余部分
    current_val = SysTick->VAL;
    uint32_t target = (current_val > ticks) ?
                      (current_val - ticks) :
                      (reload + current_val - ticks);

    while (SysTick->VAL > target);
}

个人经验:这个实现有个前提——SysTick 中断不能关。如果关了,COUNTFLAG 位不会自动清零,轮询会死循环。我建议在延时前后保存/恢复中断状态,或者干脆用 DWT 周期计数器来实现微秒延时,更稳定。

4.4 裸机调度框架雏形

SysTick 最强大的用法,是作为裸机调度器的“心跳”。咱们可以基于它实现一个简单的协作式调度器。

核心思想:

  • SysTick 每 1ms 中断一次
  • 在中断里检查各个任务的时间片
  • 时间到了,设置一个标志位
  • 主循环里轮询标志位,执行任务

来看代码框架:

// 任务控制块
typedef struct {
    void (*func)(void);     // 任务函数指针
    uint32_t period;        // 执行周期(ms)
    uint32_t elapsed;       // 已过去时间
    uint8_t  running;       // 是否就绪
} Task_t;

// 任务列表
static Task_t tasks[MAX_TASKS];
static uint8_t task_count = 0;

// 注册任务
void task_register(void (*func)(void), uint32_t period_ms)
{
    if (task_count < MAX_TASKS) {
        tasks[task_count].func = func;
        tasks[task_count].period = period_ms;
        tasks[task_count].elapsed = 0;
        tasks[task_count].running = 0;
        task_count++;
    }
}

// SysTick 中断中调用
void task_scheduler_tick(void)
{
    for (uint8_t i = 0; i < task_count; i++) {
        tasks[i].elapsed++;
        if (tasks[i].elapsed >= tasks[i].period) {
            tasks[i].elapsed = 0;
            tasks[i].running = 1;  // 标记就绪
        }
    }
}

// 主循环调度
void task_dispatch(void)
{
    while (1) {
        for (uint8_t i = 0; i < task_count; i++) {
            if (tasks[i].running) {
                tasks[i].running = 0;
                tasks[i].func();  // 执行任务
            }
        }
        // 可以加个 WFI 指令省电
        // __WFI();
    }
}

这个框架的好处:所有任务都在主循环里执行,没有中断嵌套问题。每个任务都是非阻塞的,不能在里面写死循环或 HAL_Delay()。我习惯在每个任务开头检查时间戳,用状态机来管理耗时操作。

下面这张图展示了整个调度流程:

裸机调度框架流程图 SysTick 1ms 中断 遍历所有任务 时间到? 是 标记 running=1 否 主循环 task_dispatch() 说明:中断中只做标记,不执行任务。所有任务在主循环中顺序执行,保证非阻塞。

4.5 避坑指南

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

  • SysTick 优先级不要设太高。 我习惯设成最低优先级,避免影响其他中断响应。
  • 不要在 SysTick 中断里做复杂运算。 它只是个“心跳”,不是“大脑”。复杂逻辑放主循环。
  • 微秒延时函数要关中断吗? 看场景。如果只是轮询 COUNTFLAG,可以不用关。但如果你的系统对时序要求极高,建议关中断防止被抢占。
  • 调度器里的任务不能阻塞。 一旦某个任务用了 HAL_Delay(1000),整个系统就卡住了。用状态机 + 时间戳来替代阻塞延时。

我的习惯:我会在 SysTick 中断里只做两件事——更新 uwTick 和调用 task_scheduler_tick()。其他所有逻辑都放到主循环里。这样中断服务函数执行时间极短,系统响应更快。