任务调度核心思想:从超级循环到状态机

各位同学,今天我们来聊聊嵌入式系统里最基础、也最容易被忽视的话题——任务调度。很多初学者一上来就追着 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 位递减计数器,专门用来产生系统心跳。

基本思路是这样的:

  1. 配置 SysTick 中断,比如 1ms 触发一次
  2. 每个任务分配一个时间片计数器
  3. SysTick 中断里递减计数器
  4. 主循环检查哪个任务的计数器归零了,就执行它

来看一个简化实现:

// 任务控制块
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,不会阻塞。就算按键按着不放,其他任务照样跑。

个人习惯: 我一般把状态枚举定义在头文件里,然后在状态机函数开头加一个调试打印宏。出问题的时候,打开串口看状态切换日志,问题定位特别快。

三种调度方式的对比

特性 超级循环 时间片轮转 状态机模型
任务独立性 差,互相阻塞 中,时间片隔离 好,状态独立
实时性 无保证 周期可预测 响应快
代码复杂度 低 中 中高
调试难度 低 中 中
适用场景 极简系统 多周期任务 复杂交互逻辑

实际项目中,我通常把时间片轮转和状态机结合起来用。时间片负责调度,状态机负责任务内部逻辑。这样既有宏观的调度保证,又有微观的状态可控性。

本章知识体系

下面这张图总结了三种调度方式的关系和演进路径:

任务调度核心思想演进 超级循环 while(1) 顺序执行 局限性 • 任务互相阻塞 • 实时性无保证 • 紧急响应延迟 时间片轮转 SysTick 驱动调度 核心机制 • 固定时间片 • 任务周期独立 • 非抢占式调度 状态机 FSM 优势 • 状态可记忆 • 非阻塞执行 • 调试直观 最佳实践:时间片轮转 + 状态机模型 组合使用 宏观调度 + 微观状态控制 = 可靠裸机系统

总结一下: 超级循环适合极简系统,时间片轮转解决任务调度问题,状态机模型解决任务内部阻塞问题。三者不是互斥的,而是层层递进的关系。我建议你从超级循环开始,逐步引入时间片,最后用状态机优化关键任务。