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