STM32 别再死等延时了!教你一招:不用 RTOS 也能让多个任务同时跑
文章目录
开发环境
- 底层库: 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)获取当前时间。
- 到点执行:如果“当前时间 - 上次执行时间 ≥ \ge ≥ 设定周期”,则触发任务函数。
代码封装
为了提高复用性,我们将调度器逻辑拆分为 scheduler.h 和 scheduler.c。
首先是结构体定义 scheduler.h,我们定义一个任务“身份证”,包含它要做什么、多久做一次、上次什么时候做的。
typedefstruct{void(*pTaskFunc)(void);// 函数指针:要执行的任务uint32_t interval;// 任务周期 (ms)uint32_t lastTick;// 上次执行的时间戳} Task_t;其次时调度引擎的实现scheduler.c,核心算法在于无符号数减法。即便 HAL_GetTick() 在运行 49.7 天后溢出归零,current - last 的结果依然能保持正确。
voidscheduler_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"// 任务结构体定义typedefstruct{void(*pTaskFunc)(void);// 任务函数指针uint32_t interval;// 执行周期 (ms)uint32_t lastTick;// 上次执行时间} Task_t;// API 接口声明voidscheduler_init(Task_t* tasks,uint16_t count);voidscheduler_run(void);#endif- scheduler.c
#include"scheduler.h"static Task_t* pTaskList =NULL;staticuint16_t taskCount =0;/** * @brief 初始化调度器 * @param tasks 任务数组首地址 * @param count 任务总数 */voidscheduler_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 循环中调用 */voidscheduler_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. 初始化并运行intmain(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 占用区间:
voidLCD_Proc(void){// 在任务进入时拉高 PA7HAL_GPIO_WritePin(GPIOA, GPIO_PIN_7, GPIO_PIN_SET);/* --- 屏幕刷新逻辑:sprintf、LCD显示等 --- */// 在任务退出时拉低 PA7HAL_GPIO_WritePin(GPIOA, GPIO_PIN_7, GPIO_PIN_RESET);}逻辑分析仪实测结果
使用逻辑分析仪监听 PA7 引脚,捕获到的波形如下:

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