3. Tick的硬件实现:使用硬件定时器(如SysTick)产生Tick中断

好,咱们进入第三章。这一章很实在——直接动手写代码。

系统时钟节拍,也就是 Tick,是整个任务调度的「心跳」。没有它,调度器就是个死物。我见过不少初学者,软件逻辑写得飞起,结果 Tick 配置错了,整个系统跑起来跟抽风一样。所以这一章,咱们把 Tick 的硬件实现彻底讲透。

3.1 为什么选择 SysTick?

ARM Cortex-M 内核里内置了一个 24 位的递减定时器,叫 SysTick。说白了,它就是专门为操作系统或任务调度器准备的。

我个人习惯,只要用 Cortex-M 系列芯片,Tick 首选 SysTick。原因很简单:

  • 内核自带:不需要额外配置外设定时器,省资源
  • 中断优先级可控:可以设为最低优先级,避免干扰关键中断
  • 所有 Cortex-M 统一:代码移植性极好,从 M0 到 M7 通用

你想想看,如果每个平台都去配一个 TIM 或者 GPT,那代码得写多少份?SysTick 一套代码,通吃。

我的经验: 在非 Cortex-M 平台(比如 RISC-V、AVR)上,我会用通用定时器模拟 SysTick 的行为。但核心逻辑——一个固定周期的递减计数器 + 中断——是完全一样的。

3.2 SysTick 寄存器速览

SysTick 只有 4 个寄存器,非常简洁。我刚开始学的时候,觉得寄存器越少越好,后来发现确实如此——不容易出错。

寄存器 地址偏移 描述
SYST_CSR 0x00 控制与状态寄存器(使能、中断使能、时钟源选择)
SYST_RVR 0x04 重装载值寄存器(决定 Tick 周期)
SYST_CVR 0x08 当前值寄存器(递减中,读它可判断剩余时间)
SYST_CALIB 0x0C 校准寄存器(一般不用,厂家预置)

核心就三个:CSR 控制开关,RVR 设周期,CVR 看当前值。嗯,就这么简单。

3.3 配置与初始化代码实战

咱们直接上代码。以 STM32F4 为例,配置一个 1ms 的 Tick。

/* 系统时钟节拍初始化:1ms 中断 */
void SysTick_Init(uint32_t systemClockHz)
{
    /* 1. 计算重装载值 */
    /* 公式:RELOAD = (系统时钟频率 / 1000) - 1 */
    /* 假设 systemClockHz = 168000000 (168MHz) */
    uint32_t reloadValue = (systemClockHz / 1000) - 1;

    /* 2. 设置重装载寄存器 */
    SysTick->LOAD = reloadValue;

    /* 3. 清空当前值寄存器 */
    SysTick->VAL = 0;

    /* 4. 配置控制寄存器 */
    /* 位0: ENABLE = 1 使能定时器 */
    /* 位1: TICKINT = 1 使能中断 */
    /* 位2: CLKSOURCE = 1 使用处理器时钟(AHB) */
    SysTick->CTRL = (1 << 0) | (1 << 1) | (1 << 2);
}

这段代码,我几乎每个项目都会写一遍。注意那个 -1,很多人会忘。因为计数器从 RELOAD 减到 0 一共是 RELOAD+1 个周期。

我曾经踩过的坑: 有一次我把系统时钟算错了,以为 168MHz 除以 1000 是 168000,结果写成了 168000。实际上应该是 168000 - 1 = 167999。差一个数,Tick 周期就变成了 1.000006ms,看似不多,但系统跑一天下来,累积误差能到几百毫秒。调度器的时间片轮转就全乱了。

3.4 中断服务函数怎么写?

SysTick 的中断服务函数名字是固定的——SysTick_Handler。你改了名字,编译器可不会帮你自动映射。

/* 全局 Tick 计数器 */
volatile uint32_t g_ulSysTickCount = 0;

void SysTick_Handler(void)
{
    /* 递增 Tick 计数 */
    g_ulSysTickCount++;

    /* 这里可以放调度器的核心逻辑 */
    /* 比如:检查是否有任务到期、触发 PendSV 进行上下文切换 */
    /* 但注意:不要在中断里做太多事! */
}

看到那个 volatile 了吗?这个关键字不能省。因为 g_ulSysTickCount 在中断里被修改,主循环里也要读,编译器可能会优化掉这个变量,导致你永远读到 0。

核心原则: Tick 中断里只做「标记」,不做「处理」。真正的任务切换交给 PendSV 去干。这样能保证中断响应时间最短。

3.5 时钟源选择:AHB 还是 AHB/8?

SysTick 的 CLKSOURCE 位决定了时钟源:

  • 1:使用处理器时钟(AHB),比如 168MHz
  • 0:使用 AHB/8,比如 21MHz

我一般选 AHB。为什么?因为精度更高。但如果你系统时钟特别高(比如 400MHz),而你想让 Tick 周期很长(比如 10ms),那 RELOAD 值可能超过 24 位最大值(16777215)。这时候就得用 AHB/8 了。

举个例子:

系统时钟 目标 Tick AHB 模式 RELOAD AHB/8 模式 RELOAD
168 MHz 1 ms 167999 ✅ 20999 ✅
400 MHz 10 ms 3999999 ✅ 499999 ✅
400 MHz 100 ms 39999999 ❌ 溢出 4999999 ✅

溢出时,硬件会自动截断,结果就是 Tick 周期变短,而且你很难排查。所以配置前一定要算一下。

3.6 多平台适配技巧

不同厂家的库函数封装不一样,但底层寄存器是一样的。我习惯写一个抽象层:

/* 硬件抽象接口 */
void HAL_Tick_Init(uint32_t sysClkHz)
{
    /* STM32 用 CMSIS 方式 */
    SysTick_Config(sysClkHz / 1000);

    /* 如果是 NXP LPC 系列,可能用 Chip_SysTick_Init() */
    /* 如果是 GD32,可能用 systick_config() */
    /* 但最终都是操作那三个寄存器 */
}

/* 获取当前 Tick 值 */
uint32_t HAL_Tick_Get(void)
{
    return g_ulSysTickCount;
}

这样,上层调度器完全不用关心底层用的是 SysTick 还是 TIM2。移植的时候,只改这个文件就够了。

3.7 验证 Tick 是否准确

代码写完了,怎么知道 Tick 准不准?我一般用两种方法:

  1. 示波器看 GPIO 翻转:在中断里翻转一个 GPIO,看周期是不是 1ms
  2. 逻辑分析仪抓波形:更精确,能看到抖动

有一次我在一个项目里发现 Tick 周期是 1.02ms,查了半天,发现是时钟树配置错了——HCLK 分频系数设成了 2,实际系统时钟只有一半。所以,验证 Tick 之前,先确认系统时钟对不对。

小技巧: 在 SysTick_Handler 里加一个静态变量做分频,每 1000 次翻转一次 GPIO,这样示波器上看到的就是 1Hz 的方波,更容易观察。

3.8 本章知识结构图

下面这张图,把 SysTick 的配置流程和它在调度器中的位置串起来了:

SysTick 配置与调度器关系图 SysTick 配置流程与调度器关系 系统时钟 (AHB / AHB/8) 计算 RELOAD = (sysClk / 1000) - 1 配置 LOAD / VAL / CTRL 寄存器 SysTick 中断 (1ms 一次) 递增 g_ulSysTickCount 触发 PendSV 切换任务 注意:中断里只做「标记」,真正的上下文切换在 PendSV 中完成 这样可以保证中断响应时间最短,避免中断嵌套导致系统崩溃

3.9 常见问题与排查

最后,我把这些年遇到的 SysTick 问题整理了一下:

现象 可能原因 排查方法
系统不跑,卡死 SysTick 中断优先级高于其他中断,导致死锁 检查 NVIC 优先级分组,把 SysTick 设为最低
Tick 周期不准 系统时钟频率算错,或 RELOAD 值溢出 用示波器测 GPIO 翻转,核对时钟树配置
调度器不切换任务 g_ulSysTickCount 没加 volatile 检查变量声明,加 volatile 关键字
中断响应慢 在 SysTick_Handler 里做了太多事 把耗时操作移到主循环或 PendSV 中

嗯,这一章的内容就到这里。代码不多,但每一行都关键。你把这个配置吃透了,后面的调度策略和任务切换才能站得住脚。