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 一套代码,通吃。
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 个周期。
3.4 中断服务函数怎么写?
SysTick 的中断服务函数名字是固定的——SysTick_Handler。你改了名字,编译器可不会帮你自动映射。
/* 全局 Tick 计数器 */
volatile uint32_t g_ulSysTickCount = 0;
void SysTick_Handler(void)
{
/* 递增 Tick 计数 */
g_ulSysTickCount++;
/* 这里可以放调度器的核心逻辑 */
/* 比如:检查是否有任务到期、触发 PendSV 进行上下文切换 */
/* 但注意:不要在中断里做太多事! */
}
看到那个 volatile 了吗?这个关键字不能省。因为 g_ulSysTickCount 在中断里被修改,主循环里也要读,编译器可能会优化掉这个变量,导致你永远读到 0。
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 准不准?我一般用两种方法:
- 示波器看 GPIO 翻转:在中断里翻转一个 GPIO,看周期是不是 1ms
- 逻辑分析仪抓波形:更精确,能看到抖动
有一次我在一个项目里发现 Tick 周期是 1.02ms,查了半天,发现是时钟树配置错了——HCLK 分频系数设成了 2,实际系统时钟只有一半。所以,验证 Tick 之前,先确认系统时钟对不对。
3.8 本章知识结构图
下面这张图,把 SysTick 的配置流程和它在调度器中的位置串起来了:
3.9 常见问题与排查
最后,我把这些年遇到的 SysTick 问题整理了一下:
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 系统不跑,卡死 | SysTick 中断优先级高于其他中断,导致死锁 | 检查 NVIC 优先级分组,把 SysTick 设为最低 |
| Tick 周期不准 | 系统时钟频率算错,或 RELOAD 值溢出 | 用示波器测 GPIO 翻转,核对时钟树配置 |
| 调度器不切换任务 | g_ulSysTickCount 没加 volatile | 检查变量声明,加 volatile 关键字 |
| 中断响应慢 | 在 SysTick_Handler 里做了太多事 | 把耗时操作移到主循环或 PendSV 中 |
嗯,这一章的内容就到这里。代码不多,但每一行都关键。你把这个配置吃透了,后面的调度策略和任务切换才能站得住脚。