调度器核心原理:时间片轮转、优先级抢占、协作式调度
做嵌入式系统这么多年,我见过太多工程师一上来就撸代码,结果任务跑飞了、优先级反转了、死锁了,才回头研究调度器。说实话,调度器就是操作系统的"心脏"——它决定了哪个任务在什么时候跑、跑多久、跑完怎么办。
这一章,咱们把三种最经典的调度方式掰开揉碎讲清楚。你想想看,搞懂了它们,你就能根据项目需求选对方案,而不是盲目套用 FreeRTOS 或者自己瞎写一个。
1. 时间片轮转调度(Round-Robin)
时间片轮转,说白了就是"轮流坐庄"。每个任务分到固定时长,时间到了就换下一个。我最早在 8 位单片机上做 LED 扫描和按键检测时,用的就是这种思路。
核心思想:所有任务平等,按顺序轮流执行固定时长。
工作原理
- 系统维护一个就绪任务队列(FIFO 顺序)
- 定时器产生周期性中断,作为时间片切换信号
- 当前任务执行满一个时间片后,被挂起到队尾
- 调度器从队首取出下一个任务执行
我曾经在一个智能家居项目里用过纯轮询,结果按键响应延迟高达 200ms。后来改成时间片轮转,每个任务分 5ms,问题就解决了。嗯,这里要注意:时间片太短会导致频繁切换,浪费 CPU;太长又会让紧急任务等太久。
时间片长度选择建议
| 应用场景 | 推荐时间片 | 说明 |
|---|---|---|
| 按键扫描、LED 显示 | 5-10ms | 人眼感知不到延迟 |
| 传感器数据采集 | 10-50ms | 匹配传感器采样周期 |
| 通信协议处理 | 1-5ms | 避免数据丢包 |
| 电机控制 | 0.5-2ms | 需要快速响应 |
我的经验:时间片别用死值。我习惯在系统初始化时根据任务数量动态计算:time_slice = TICK_PERIOD / task_count,这样增减任务时不用改代码。
2. 优先级抢占调度(Preemptive Scheduling)
优先级抢占,就是"急事先办"。高优先级任务可以打断低优先级任务。这玩意儿在实时系统中太重要了——你总不能让刹车控制等 LED 闪烁跑完吧?
注意:优先级抢占不是万能的。我曾经在一个项目中设了 8 级优先级,结果调试时发现低优先级任务永远得不到 CPU——这就是"饥饿"问题。
抢占规则
- 每个任务分配一个优先级(数值越小优先级越高)
- 调度器总是选择就绪态中优先级最高的任务执行
- 高优先级任务就绪时,立即抢占当前任务
- 同优先级任务之间,通常配合时间片轮转
优先级反转问题
你想想看,如果低优先级任务持有高优先级任务需要的资源,会发生什么?高优先级任务反而要等低优先级任务——这就是优先级反转。我踩过这个坑:一个串口驱动任务优先级设高了,结果它等一个低优先级任务释放缓冲区,整个系统卡死。
解决办法有三种:
| 方案 | 原理 | 适用场景 |
|---|---|---|
| 优先级继承 | 低优先级任务临时提升到高优先级 | 资源竞争不频繁 |
| 优先级天花板 | 资源设定最高可用优先级 | 资源数量固定 |
| 禁止抢占 | 访问临界区时关中断 | 临界区极短 |
避坑指南:我曾经在医疗设备项目里用优先级天花板,结果发现一个传感器驱动把天花板设成了最高级,导致所有任务都被阻塞。后来改成优先级继承,问题解决。记住:天花板方案要谨慎,别设太高。
3. 协作式调度(Cooperative Scheduling)
协作式调度,就是"自觉排队"。每个任务主动让出 CPU,调度器从不强制切换。这听起来很原始,但在资源极度受限的 MCU 上,它反而最可靠。
实现方式
// 协作式调度器核心代码
void task_yield(void) {
// 保存当前任务上下文
save_context(current_task);
// 切换到下一个任务
current_task = get_next_task();
// 恢复新任务上下文
restore_context(current_task);
}
// 每个任务必须主动调用 yield
void task_led(void) {
while(1) {
toggle_led();
task_yield(); // 主动让出 CPU
}
}
我个人习惯在超低功耗项目里用协作式调度。为什么?因为没有抢占中断,功耗可以压到极低。一个温湿度采集器,用协作式调度,待机电流能到 2μA。
优缺点对比
| 特性 | 协作式 | 抢占式 |
|---|---|---|
| 实时性 | 依赖任务自觉 | 硬件保证 |
| 代码复杂度 | 低 | 高(需处理临界区) |
| 内存开销 | 极小(每个任务只需一个栈) | 较大(每个任务独立栈) |
| 功耗 | 可做到极低 | 较高(定时器频繁中断) |
| 调试难度 | 低(确定性执行) | 高(抢占导致非确定性) |
我的建议:如果你的任务都是"短平快"类型(每个任务执行时间 < 1ms),协作式调度完全够用。我做过一个 8 路温度采集系统,8 个任务,每个任务 200μs,协作式调度跑得稳稳的。
4. 三种调度方式对比与选型
你可能会问:到底该用哪种?我一般这样选:
- 时间片轮转:任务平等、无紧急需求、MCU 资源中等(如 STM32F0)
- 优先级抢占:有硬实时任务、需要快速响应、MCU 资源丰富(如 STM32F4+)
- 协作式:超低功耗、任务简单、MCU 资源极少(如 8051、PIC)
其实很多商业 RTOS 都是混合调度:同优先级用时间片轮转,不同优先级用抢占。FreeRTOS 就是这么干的。你想想看,这样既保证了实时性,又避免了低优先级任务饿死。
5. 调度器核心数据结构
不管哪种调度方式,底层都离不开几个关键数据结构。我习惯用 C 语言的结构体来组织:
// 任务控制块(TCB)
typedef struct {
uint8_t id; // 任务ID
uint8_t priority; // 优先级
uint32_t stack_size; // 栈大小
uint32_t *stack_ptr; // 栈指针
void (*task_func)(void*); // 任务函数
void *arg; // 任务参数
uint8_t state; // 任务状态: READY/RUNNING/BLOCKED/SUSPENDED
uint32_t time_slice; // 时间片(仅轮转调度使用)
uint32_t remaining_ticks; // 剩余时间片
} TCB_t;
// 就绪队列(优先级位图法)
typedef struct {
uint32_t ready_bitmap; // 就绪位图,每位代表一个优先级
TCB_t *task_list[MAX_PRIORITY]; // 每个优先级对应一个任务链表
} ReadyQueue_t;
注意:就绪队列的实现直接影响调度性能。我见过有人用链表遍历找最高优先级任务,结果在 32 个任务时调度延迟达到 50μs。改用位图法后,延迟降到 0.5μs——差了 100 倍!
6. 上下文切换的代价
上下文切换不是免费的。每次切换都要保存/恢复寄存器、栈指针、状态字。我实测过:
| MCU 架构 | 切换代价(周期数) | @72MHz 耗时 |
|---|---|---|
| ARM Cortex-M0 | ~40 周期 | 0.56μs |
| ARM Cortex-M4 | ~60 周期 | 0.83μs |
| 8051 | ~100 周期 | 1.39μs |
| PIC16 | ~200 周期 | 2.78μs |
你想想看,如果时间片设为 1ms,切换代价 1μs,那切换开销只占 0.1%,完全可以接受。但如果时间片设为 100μs,切换开销就占到 1% 了——这时候就要考虑减少切换频率。
我的习惯:在项目初期,我会用 GPIO 翻转 + 示波器实测切换时间。别信手册上的理论值,实际跑起来才知道。有一次我发现切换时间比手册多了 30%,原因是编译器优化等级不同导致的。