任务调度核心思想:从超级循环到状态机
各位同学,今天我们来聊聊嵌入式系统里最基础、也最容易被忽视的话题——任务调度。很多初学者一上来就追着 RTOS 跑,却连裸机调度都没搞明白。我个人觉得,先把前后台系统吃透,后面学 FreeRTOS 才会真正理解它解决了什么问题。
前后台系统(超级循环)的局限性
先看一段最常见的代码,我相信你肯定写过:
int main(void) {
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
MX_USART1_UART_Init();
while (1) {
按键扫描();
数码管刷新();
串口数据处理();
LED闪烁();
}
}
这就是典型的前后台系统——前台是中断,后台是超级循环。看起来简单直观,对吧?但我在实际项目中吃过它的亏。
核心问题有三个:
- 任务互相阻塞:如果按键扫描里用了 HAL_Delay(10),整个循环都得等它。串口数据来了?等着吧。
- 实时性无法保证:每个任务的执行周期完全取决于循环总长度。你想想看,如果串口处理突然变慢,LED 闪烁的周期就跟着飘了。
- 紧急事件响应延迟:中断可以抢断,但中断只做标记,真正处理还是在主循环里。万一主循环正在执行一个耗时操作,紧急事件就被堵住了。
我曾经踩过的坑: 做一个四轴飞行器的遥控器,接收机数据通过外部中断标记,主循环里处理。结果因为数码管刷新用了阻塞式延时,导致遥控指令处理延迟,飞行器直接炸机了。嗯,从那以后我再也不敢在超级循环里放阻塞延时了。
时间片轮转调度原理
怎么解决上面的问题?一个朴素的想法是:把 CPU 时间切成均匀的小片,每个任务轮流用。这就是时间片轮转调度。
在 STM32 上实现时间片轮转,核心是借助 SysTick 定时器。SysTick 是 Cortex-M 内核自带的 24 位递减计数器,专门用来产生系统心跳。
基本思路是这样的:
- 配置 SysTick 中断,比如 1ms 触发一次
- 每个任务分配一个时间片计数器
- SysTick 中断里递减计数器
- 主循环检查哪个任务的计数器归零了,就执行它
来看一个简化实现:
// 任务控制块
typedef struct {
void (*task_func)(void);
uint16_t period_ms; // 执行周期
uint16_t tick_cnt; // 当前计数值
uint8_t run_flag; // 执行标志
} Task_t;
Task_t tasks[] = {
{按键扫描, 5, 0, 0},
{数码管刷新, 10, 0, 0},
{串口数据处理, 20, 0, 0},
{LED闪烁, 50, 0, 0},
};
// SysTick 中断服务函数
void SysTick_Handler(void) {
for (int i = 0; i < TASK_NUM; i++) {
if (tasks[i].tick_cnt++ >= tasks[i].period_ms) {
tasks[i].tick_cnt = 0;
tasks[i].run_flag = 1;
}
}
}
// 主循环
while (1) {
for (int i = 0; i < TASK_NUM; i++) {
if (tasks[i].run_flag) {
tasks[i].run_flag = 0;
tasks[i].task_func();
}
}
}
关键点: 每个任务只在自己的时间片里运行,不会阻塞其他任务。按键扫描就算用 HAL_Delay,也只影响它自己,数码管刷新照样 10ms 跑一次。
但这里有个陷阱——任务执行时间不能超过时间片。如果串口数据处理需要 30ms,而它的周期是 20ms,那就出问题了。我建议你在设计阶段就估算好每个任务的最长执行时间。
任务状态机模型
有了时间片轮转,任务之间不互相阻塞了。但每个任务内部怎么组织?直接用顺序执行?那又回到超级循环的老路了。
我的做法是:每个任务都实现成一个有限状态机(FSM)。
为什么用状态机?
- 任务可以记住上次执行到哪里了
- 每次只执行一个状态,不会卡死
- 状态切换清晰,调试方便
来看一个按键扫描任务的状态机实现:
typedef enum {
KEY_IDLE,
KEY_DEBOUNCE,
KEY_PRESSED,
KEY_RELEASE
} KeyState_t;
KeyState_t key_state = KEY_IDLE;
void KeyScan_Task(void) {
uint8_t key_val = HAL_GPIO_ReadPin(KEY_GPIO_Port, KEY_Pin);
switch (key_state) {
case KEY_IDLE:
if (key_val == 0) { // 按键按下
key_state = KEY_DEBOUNCE;
debounce_timer = 0;
}
break;
case KEY_DEBOUNCE:
if (++debounce_timer >= 10) { // 10ms 消抖
if (key_val == 0) {
key_state = KEY_PRESSED;
// 触发按键事件
} else {
key_state = KEY_IDLE; // 抖动误判
}
}
break;
case KEY_PRESSED:
// 等待释放
if (key_val == 1) {
key_state = KEY_RELEASE;
}
break;
case KEY_RELEASE:
key_state = KEY_IDLE;
break;
}
}
你看,每次调用只执行一个 case,不会阻塞。就算按键按着不放,其他任务照样跑。
个人习惯: 我一般把状态枚举定义在头文件里,然后在状态机函数开头加一个调试打印宏。出问题的时候,打开串口看状态切换日志,问题定位特别快。
三种调度方式的对比
| 特性 | 超级循环 | 时间片轮转 | 状态机模型 |
|---|---|---|---|
| 任务独立性 | 差,互相阻塞 | 中,时间片隔离 | 好,状态独立 |
| 实时性 | 无保证 | 周期可预测 | 响应快 |
| 代码复杂度 | 低 | 中 | 中高 |
| 调试难度 | 低 | 中 | 中 |
| 适用场景 | 极简系统 | 多周期任务 | 复杂交互逻辑 |
实际项目中,我通常把时间片轮转和状态机结合起来用。时间片负责调度,状态机负责任务内部逻辑。这样既有宏观的调度保证,又有微观的状态可控性。
本章知识体系
下面这张图总结了三种调度方式的关系和演进路径:
总结一下: 超级循环适合极简系统,时间片轮转解决任务调度问题,状态机模型解决任务内部阻塞问题。三者不是互斥的,而是层层递进的关系。我建议你从超级循环开始,逐步引入时间片,最后用状态机优化关键任务。