4. 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 实现毫秒延时的。其实原理很简单:
- 系统初始化时,把 SysTick 配置成 1ms 中断一次
- 每次中断,一个全局变量
uwTick加 1 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()。我习惯在每个任务开头检查时间戳,用状态机来管理耗时操作。
下面这张图展示了整个调度流程:
4.5 避坑指南
最后分享几个我踩过的坑:
- SysTick 优先级不要设太高。 我习惯设成最低优先级,避免影响其他中断响应。
- 不要在 SysTick 中断里做复杂运算。 它只是个“心跳”,不是“大脑”。复杂逻辑放主循环。
- 微秒延时函数要关中断吗? 看场景。如果只是轮询 COUNTFLAG,可以不用关。但如果你的系统对时序要求极高,建议关中断防止被抢占。
- 调度器里的任务不能阻塞。 一旦某个任务用了 HAL_Delay(1000),整个系统就卡住了。用状态机 + 时间戳来替代阻塞延时。
我的习惯:我会在 SysTick 中断里只做两件事——更新 uwTick 和调用 task_scheduler_tick()。其他所有逻辑都放到主循环里。这样中断服务函数执行时间极短,系统响应更快。