开发环境
- 底层库:STM32Cube MCU Package (HAL 库)
- 硬件平台:STM32G4xx/F1xx/F4xx (通用)
- 核心资源:SysTick 滴答定时器(HAL_GetTick)
在嵌入式开发初期,我们习惯在 while(1) 里通过 HAL_Delay() 控制节奏。但随着任务增多(既要扫按键,又要刷屏幕,还要读传感器),传统的阻塞式延时会导致系统卡顿。
为了解决这个问题,在本方案中采用了轻量级任务调度器(Task Scheduler)架构。通过将系统拆分为多个非阻塞的任务(Task),并按照固定的时间片(如 10ms、50ms)轮询执行,从而确保了多任务环境下的实时性与流畅度。在无需 RTOS 的前提下,使用裸机开发最大限度提升了系统的实时响应能力与执行流畅度。
为什么选择 HAL 库实现?
很多从标准库转 HAL 库的朋友可能会问:标准库也能做调度器,为什么要强调 HAL 库?
- 零配置成本:在标准库中,你需要手动配置 SysTick_Config,并在 stm32f10x_it.c 的中断服务函数中手动累加全局变量。而 HAL 库在初始化时默认就开启了 SysTick,并提供了全局可调用的 HAL_GetTick() 函数,真正实现了'开箱即用'。
- 跨平台兼容:本套调度器代码在 HAL 库下具有极强的移植性。无论你从 F1 系列换到 G4 甚至 H7 系列,代码逻辑无需改动,只需要更换一下头文件即可。
- 更安全的时间戳:HAL 库内置了对 Systick 的管理,避免了开发者重复定义计时器导致的逻辑冲突。
核心原理
传统的 HAL_Delay() 就像是原地睡觉,睡醒前什么都干不了。而调度器逻辑就像是定闹钟打卡:
- CPU 不断巡逻:通过 while(1) 极速轮询。
- 时间戳比对:利用 HAL 库自带的 HAL_GetTick()(每 1ms 加 1)获取当前时间。
- 到点执行:如果'当前时间 - 上次执行时间 >= 设定周期',则触发任务函数。
代码封装
为了提高复用性,我们将调度器逻辑拆分为 scheduler.h 和 scheduler.c。
首先是结构体定义 scheduler.h,我们定义一个任务'身份证',包含它要做什么、多久做一次、上次什么时候做的。
typedef struct {
void (*pTaskFunc)(void); // 函数指针:要执行的任务
uint32_t interval; // 任务周期 (ms)
uint32_t lastTick; // 上次执行的时间戳
} Task_t;
其次是调度引擎的实现 scheduler.c,核心算法在于无符号数减法。即便 HAL_GetTick() 在运行 49.7 天后溢出归零,current - last 的结果依然能保持正确。
void scheduler_run(void) {
uint32_t currentTick = HAL_GetTick();
for (uint16_t i = 0; i < taskCount; i++) {
// 时间到!执行并更新时间戳
if (currentTick - pTaskList[i].lastTick >= pTaskList[i].interval) {
pTaskList[i].pTaskFunc();
pTaskList[i].lastTick = currentTick;
}
}
}
完整代码如下:
- scheduler.h
#ifndef __SCHEDULER_H
#define __SCHEDULER_H
// 注意:请根据您的芯片型号修改此头文件(例如 F1 系列为 stm32f1xx_hal.h)
#include "stm32g4xx_hal.h"
// 任务结构体定义
typedef struct {
void (*pTaskFunc)(void); // 任务函数指针
uint32_t interval; // 执行周期 (ms)
uint32_t lastTick; // 上次执行时间
} Task_t;
// API 接口声明
void scheduler_init(Task_t* tasks, uint16_t count);
void scheduler_run(void);
#endif
- scheduler.c
#include "scheduler.h"
static Task_t* pTaskList = NULL;
static uint16_t taskCount = 0;
/**
* @brief 初始化调度器
* @param tasks 任务数组首地址
* @param count 任务总数
*/
void scheduler_init(Task_t* tasks, uint16_t count) {
pTaskList = tasks;
taskCount = count;
// 初始化所有任务的时间戳
uint32_t currentTick = HAL_GetTick();
for (uint16_t i = 0; i < taskCount; i++) {
pTaskList[i].lastTick = currentTick;
}
}
/**
* @brief 调度器核心,在 main 循环中调用
*/
void scheduler_run(void) {
if (pTaskList == NULL) return;
uint32_t currentTick = HAL_GetTick();
for (uint16_t i = 0; i < taskCount; i++) {
// 使用无符号减法,自动处理 Systick 溢出问题
if (currentTick - pTaskList[i].lastTick >= pTaskList[i].interval) {
pTaskList[i].pTaskFunc(); // 执行任务
pTaskList[i].lastTick = currentTick;
}
}
}
使用案例
假设我们需要同时处理:20ms 的按键扫描、200ms 的屏幕刷新、1ms 的 LED 控制。
// 1. 定义任务列表
Task_t myTasks[] = {
{Key_Proc, 20, 0}, // 20ms 扫一次,防抖稳如狗
{LCD_Proc, 200, 0}, // 200ms 刷屏,肉眼无闪烁
{Led_Proc, 1, 0} // 1ms 快速响应逻辑
};
// 2. 初始化并运行
int main(void) {
HAL_Init();
SystemClock_Config();
// 初始化调度器,传入数组和数量
scheduler_init(myTasks, sizeof(myTasks) / sizeof(Task_t));
while (1) {
scheduler_run(); // 调度器开始巡逻
}
}
进阶用法
如果需要支持只运行一次的任务,可以在 Task_t 结构体中增加一个 uint32_t runCount 成员。在 scheduler_run 中判断,每执行一次 runCount--,当减到 0 时不再执行。这能让你的调度器支持更复杂的业务逻辑。
技巧:我们可以约定,如果 runCount 设置为 0xFFFFFFFF(最大值),则代表该任务是永久循环执行;如果是其他数值,则代表剩余执行次数。
Task_t myTasks[] = {
// 任务函数 周期 (ms) 初始时间 执行次数
{Key_Proc, 20, 0, 0xFFFFFFFF}, // 永久循环执行
{LCD_Proc, 200, 0, 0xFFFFFFFF}, // 永久循环执行
{PowerOn_Msg, 1, 0, 1}, // 开机只执行 1 次
{Beep_Alarm, 500, 0, 5} // 报警响 5 次后自动停止
};
注意事项
虽然调度器实现了'伪并行',但它本质还是单线程。以下三点必须遵守:
- 严禁使用阻塞延时 在任何 Task_Proc 任务函数内部,绝对不能出现 HAL_Delay() 或长死循环。如果 LCD_Proc 阻塞了 50ms,那么 Key_Proc 就会在这 50ms 内完全失灵。
- 任务执行时间必须 < 任务周期 如果一个任务设定每 10ms 执行一次,但它本身逻辑运行需要 15ms,系统就会发生'任务追尾',导致其他任务的时间轴全部向后偏移。
- Systick 优先级 STM32 HAL 库默认 Systick 中断优先级最低(15)。如果你的应用中有极其耗时的中断处理函数,可能会导致 HAL_GetTick() 计时偏慢,建议提高优先级。
调试技巧
如果怀疑系统负载过高,可以在 scheduler_run 的循环开始前后翻转一个测试 IO 口。通过示波器观察该 IO 口的高电平时间,就能直观地看到所有任务总的执行耗时。
为了验证调度器的精准度并排查潜在的任务阻塞风险,我引入了 IO 电平翻转法 进行实测。在 LCD_Proc(屏幕刷新任务)的起始与结束位置分别插入 GPIO 操作代码,用于标记该任务的 CPU 占用区间:
void LCD_Proc(void) {
// 在任务进入时拉高 PA7
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_7, GPIO_PIN_SET);
/* --- 屏幕刷新逻辑:sprintf、LCD 显示等 --- */
// 在任务退出时拉低 PA7
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_7, GPIO_PIN_RESET);
}
逻辑分析仪实测结果
使用逻辑分析仪监听 PA7 引脚,捕获到的波形如下:

从测量图中可以看到,连续两个脉冲上升沿之间的时间间隔(Period)精确地维持在 200ms。这证明了我们的调度器在 while(1) 轮询下,依然能保持极高的定时精度。
注意:只要这个高电平时间 远小于 200ms(例如实测为 5ms),就说明系统负载尚有充足余量。如果高电平宽度接近甚至超过了任务周期(200ms),系统就会发生严重的'任务追尾',此时必须优化代码逻辑或拆分任务。


