4. 任务调度机制:抢占式调度、时间片轮转、协作式调度、调度器启动与挂起

调度机制,说白了就是操作系统决定「下一个该谁跑」的规则。FreeRTOS 支持三种调度方式:抢占式、时间片轮转、协作式。嗯,这里要注意,它们不是互斥的,实际项目中经常混合使用。

我个人习惯把调度器比作「交通指挥员」。抢占式调度就像交警直接喊停一辆车让另一辆先走;时间片轮转就是每辆车跑固定时间然后换下一辆;协作式嘛,就是每辆车自己说「我跑累了,让后面的先走」。

4.1 抢占式调度(Preemptive Scheduling)

这是 FreeRTOS 默认的调度方式,也是绝大多数嵌入式项目的选择。

核心规则: 任何时候,只要一个更高优先级的任务就绪了,当前任务立刻被挂起,高优先级任务开始运行。

我在项目中遇到过一个问题:一个低优先级的任务正在写 Flash,突然来了一个高优先级的中断,中断里释放了信号量,唤醒了一个中等优先级的任务。结果中等优先级任务把低优先级任务抢占了,Flash 写到一半就停了。嗯,这就是典型的「优先级反转」前兆。

抢占发生的时机:

  • 任务被创建时,如果新任务优先级高于当前任务
  • 中断退出时,如果中断中唤醒了一个更高优先级的任务
  • 任务主动调用阻塞函数(如 vTaskDelay)时
  • 任务释放了某个资源,导致更高优先级任务就绪

配置抢占式调度很简单,在 FreeRTOSConfig.h 中设置:

#define configUSE_PREEMPTION          1   // 启用抢占式调度
#define configUSE_TIME_SLICING        0   // 先关掉时间片轮转

你想想看,如果两个任务优先级相同,抢占式调度会怎么处理?答案是:谁先就绪谁先跑,跑完才轮到另一个。除非你同时开启了时间片轮转。

4.2 时间片轮转(Time Slicing / Round-Robin)

时间片轮转解决的是「同优先级任务公平竞争」的问题。每个任务跑一个固定长度的时间片(通常是 1 个 tick),时间到了就切换到下一个同优先级任务。

配置方式:

#define configUSE_TIME_SLICING        1   // 启用时间片轮转
#define configUSE_PREEMPTION          1   // 通常和抢占式一起用

我记得有一次调试一个多传感器采集系统,三个同优先级的任务分别采集温度、湿度、气压。没开时间片轮转时,温度任务一直占着 CPU,湿度和气压任务几乎得不到运行。开了时间片轮转后,三个任务轮流跑,数据采集就均匀了。

避坑指南: 我曾经在时间片轮转中犯过一个低级错误——把时间片设得太短(比如 1ms)。结果任务切换开销比实际干活还多,CPU 利用率反而下降了。建议时间片至少 5-10 个 tick,除非你有特殊需求。

时间片轮转的切换点:

  • 当前任务的时间片用完
  • 当前任务主动阻塞或挂起
  • 有更高优先级任务就绪(此时抢占优先于时间片)

4.3 协作式调度(Cooperative Scheduling)

协作式调度,说白了就是「自觉调度」。任务不会被打断,除非它自己主动让出 CPU。

配置方式:

#define configUSE_PREEMPTION          0   // 关闭抢占
#define configUSE_TIME_SLICING        0   // 关闭时间片

协作式调度的典型应用场景:

  • 对时序要求极其严格,不允许任务被随意打断
  • 任务之间共享大量全局变量,不想用锁
  • 系统负载很低,任务都能快速完成

任务主动让出 CPU 的方式:

  • taskYIELD() — 立即触发一次调度
  • vTaskDelay() — 阻塞一段时间
  • vTaskSuspend() — 挂起自己
  • 等待队列或信号量 — 阻塞等待资源

警告: 协作式调度下,如果一个任务死循环了,整个系统就卡死了。我曾经在一个工业控制项目中用过协作式调度,结果一个任务里有个 while 循环忘了加 taskYIELD(),整个系统直接「假死」。从那以后,我只要用协作式调度,就一定会加看门狗。

4.4 调度器启动与挂起

调度器不是一开始就工作的。在启动调度器之前,所有任务都处于「待命」状态,但谁都不跑。你需要手动调用 vTaskStartScheduler() 来启动调度器。

启动调度器的典型流程:

int main(void)
{
    // 1. 硬件初始化
    HAL_Init();
    SystemClock_Config();
    
    // 2. 创建任务(此时任务还没开始运行)
    xTaskCreate(Task1, "Task1", 128, NULL, 1, NULL);
    xTaskCreate(Task2, "Task2", 128, NULL, 2, NULL);
    
    // 3. 启动调度器(从此 main 函数不再返回)
    vTaskStartScheduler();
    
    // 4. 如果跑到这里,说明内存不足,调度器启动失败
    while(1);
}

嗯,这里要注意:vTaskStartScheduler() 一旦调用,就不会返回。如果返回了,说明 FreeRTOS 堆内存不够,创建空闲任务或定时器任务失败了。

挂起调度器:

有时候你需要临时关闭调度,比如在某个关键操作期间不希望任务切换。这时可以用:

vTaskSuspendAll();   // 挂起调度器
// ... 执行临界区代码 ...
xTaskResumeAll();    // 恢复调度器

我个人习惯在操作链表或队列时挂起调度器,而不是用中断屏蔽。因为挂起调度器不会影响中断响应,而屏蔽中断会影响实时性。

挂起调度器的注意事项:

  • 挂起期间,中断仍然可以触发,但中断中不能调用 FreeRTOS API(除非是中断安全版本)
  • 挂起期间,任务不会被切换,但中断服务函数仍然可以执行
  • 不要长时间挂起调度器,否则高优先级任务得不到响应
  • xTaskResumeAll() 返回时,可能会立即发生一次任务切换

我记得有一次调试一个电机控制程序,在挂起调度器期间调用了 vTaskDelay(),结果系统直接崩溃。后来查手册才知道,挂起调度器期间不能调用任何会导致任务阻塞的 API。

4.5 三种调度方式的对比

特性 抢占式 时间片轮转 协作式
任务是否可被打断 是(高优先级可打断低优先级) 是(时间片用完可打断) 否(除非主动让出)
同优先级任务调度 谁先就绪谁先跑 轮流跑固定时间片 谁先就绪谁先跑
实时性 低(依赖任务自觉)
代码复杂度 需要处理资源共享 需要处理资源共享 简单,无需锁
典型应用 大多数嵌入式系统 多传感器采集 极简系统、时序敏感

4.6 调度机制的选择建议

说了这么多,到底该怎么选?我个人的经验是:

  • 90% 的项目: 抢占式 + 时间片轮转。这是最通用的配置,兼顾实时性和公平性。
  • 8% 的项目: 纯抢占式(关掉时间片)。适合任务优先级划分清晰,同优先级任务很少的场景。
  • 2% 的项目: 协作式。除非你对时序有极端要求,或者系统极其简单,否则不建议。

小技巧: 如果你不确定用哪种调度方式,先用默认的抢占式+时间片轮转。等系统跑起来后,用 FreeRTOS 的 trace 工具看看任务切换频率,再决定要不要调整。

最后说一句:调度机制是操作系统的「宪法」,决定了任务之间的「权力分配」。理解透这三种调度方式,你就能在设计系统时做出最合适的选择。


公众号:蓝海资料掘金营,微信deep3321