3. 任务调度机制:调度器工作原理、优先级调度算法、时间片轮转调度、抢占式调度与非抢占式调度、调度点与上下文切换
任务调度,说白了就是RTOS的“大脑”。它决定哪个任务该运行,哪个任务该等着。我刚开始接触RTOS时,总觉得调度不就是个排队嘛,后来踩了不少坑才明白——调度策略选不对,系统性能直接腰斩。
这一章,咱们把调度器的五脏六腑拆开看。从工作原理到具体算法,再到上下文切换这个“重头戏”,我都会结合项目中的实际案例来讲。
3.1 调度器工作原理
调度器的核心任务就三个字:选任务。它维护着一张“就绪任务表”,每次需要切换时,就从这张表里挑一个最合适的任务去执行。
我习惯把调度器的工作流程拆成三步:
- 查表:扫描就绪任务列表,找出优先级最高的任务
- 比较:看选出来的任务是不是当前正在运行的任务
- 切换:如果不是,就保存当前任务上下文,加载新任务的上下文
嗯,这里要注意——调度器不是每时每刻都在工作的。它只在特定的“调度点”被触发。后面我们会细说调度点。
核心原则:调度器追求的是“确定性”——在任何时刻,系统都能明确知道该运行哪个任务。这是RTOS和Linux这种通用OS最大的区别。
3.2 优先级调度算法
优先级调度,是RTOS里最常用的算法。每个任务被赋予一个优先级数字,数字越小优先级越高(也有反着来的,看具体RTOS实现)。
我曾经在一个无人机飞控项目里用过FreeRTOS,当时有个传感器采集任务优先级设得太高,结果低优先级的控制任务老是抢不到CPU,飞机差点翻跟头。从那以后,我对优先级分配就特别谨慎。
3.2.1 静态优先级 vs 动态优先级
| 特性 | 静态优先级 | 动态优先级 |
|---|---|---|
| 赋值时机 | 任务创建时固定 | 运行时可以调整 |
| 实现复杂度 | 低 | 高 |
| 可预测性 | 强 | 弱 |
| 典型应用 | 硬实时系统 | 软实时系统 |
| 典型算法 | 固定优先级 | EDF、优先级继承 |
我个人习惯:在硬实时场景下用静态优先级,因为可预测性强。动态优先级虽然灵活,但容易引入“优先级反转”这类问题。
3.2.2 优先级反转与解决方案
优先级反转是个经典问题。简单说就是:高优先级任务等着低优先级任务释放资源,而低优先级任务又被中等优先级任务抢占了CPU。结果高优先级任务反而被“晾”在一边。
我曾经在一个工业控制器项目里遇到过这个问题。一个紧急停止任务(高优先级)因为等待一个共享缓冲区,被一个普通IO任务(中等优先级)活活拖了200ms。200ms在工业控制里,足够出事故了。
解决方案有三种:
- 优先级继承:低优先级任务在持有锁时,临时“继承”等待该锁的最高优先级
- 优先级天花板:锁被分配一个优先级上限,持有锁的任务自动提升到该上限
- 禁止抢占:持有锁的任务不允许被抢占,直到释放锁
我的建议:如果RTOS支持优先级继承,优先用它。如果资源紧张,用禁止抢占更简单。优先级天花板容易导致优先级“膨胀”,慎用。
3.3 时间片轮转调度
时间片轮转,就是让同优先级的任务轮流“上台”。每个任务分到一个时间片,用完就换下一个。你想想看,这不就是CPU版的“分蛋糕”吗?
时间片的大小很关键。太短了,上下文切换开销大;太长了,轮转效果不明显。我一般设成1-10ms,具体看任务的计算量。
// 伪代码:时间片轮转调度
void scheduler_tick() {
current_task->time_slice--;
if (current_task->time_slice == 0) {
current_task->time_slice = DEFAULT_TIME_SLICE;
// 把当前任务移到就绪队列末尾
move_to_end_of_ready_queue(current_task);
// 触发调度
yield();
}
}
注意,时间片轮转只对同优先级的任务有效。不同优先级之间,还是优先级调度说了算。
3.4 抢占式调度与非抢占式调度
这是调度器设计的两个流派。我分别说说。
3.4.1 抢占式调度
抢占式调度,就是高优先级任务可以“抢”低优先级任务的CPU。只要高优先级任务就绪了,调度器立刻切换过去。
优点很明显:实时性好。缺点也很明显:共享数据需要加锁保护,否则容易出竞态条件。
我在一个多传感器融合项目里用过抢占式调度。一个高优先级的IMU读取任务,每1ms就要读一次数据。如果不用抢占,数据会丢失。但用了抢占,就得小心处理IMU数据和GPS数据之间的共享缓冲区。
3.4.2 非抢占式调度
非抢占式调度,就是任务主动让出CPU。要么任务自己调用延时或等待函数,要么任务执行完毕。
这种调度方式实现简单,不需要复杂的同步机制。但实时性差——如果有个任务死循环,整个系统就挂了。
| 对比项 | 抢占式 | 非抢占式 |
|---|---|---|
| 实时性 | 高 | 低 |
| 实现复杂度 | 高 | 低 |
| 同步需求 | 需要锁/信号量 | 基本不需要 |
| 典型场景 | 硬实时、多传感器 | 简单控制、低功耗 |
避坑指南:我曾经在一个低功耗物联网设备里用了抢占式调度,结果频繁的上下文切换导致功耗飙升。后来换成非抢占式,配合任务主动休眠,功耗降了40%。选哪种调度方式,一定要看应用场景。
3.5 调度点与上下文切换
调度点,就是调度器被触发“干活”的时刻。上下文切换,就是调度器“干活”的具体动作。
3.5.1 调度点
调度点通常发生在以下几种情况:
- 任务创建/删除:就绪任务表变了,需要重新调度
- 任务阻塞/唤醒:比如任务等待信号量、消息队列
- 时间片用完:时间片轮转调度触发
- 中断返回:中断处理完后,可能有更高优先级的任务就绪
- 任务主动让出:调用task_yield()或类似API
嗯,这里有个细节——中断返回时的调度点特别重要。很多RTOS会在中断退出时检查是否需要调度,这叫“中断级调度”。
3.5.2 上下文切换
上下文切换,就是保存当前任务的“现场”,恢复新任务的“现场”。这个“现场”包括:
- CPU寄存器(通用寄存器、程序计数器、栈指针)
- 浮点寄存器(如果有FPU)
- 任务控制块(TCB)中的状态信息
上下文切换的代码通常用汇编写,因为要直接操作寄存器。我贴一段简化版的切换代码:
; 简化版上下文切换(ARM Cortex-M架构)
; 保存当前任务上下文
PUSH {R4-R11} ; 保存通用寄存器
MRS R0, PSP ; 获取当前栈指针
STR R0, [R1] ; 保存到TCB
; 加载新任务上下文
LDR R0, [R2] ; 从新任务TCB加载栈指针
MSR PSP, R0 ; 设置栈指针
POP {R4-R11} ; 恢复通用寄存器
BX LR ; 返回新任务
性能提示:上下文切换的开销一般在几微秒到几十微秒。如果切换太频繁(比如时间片设得太短),CPU大部分时间都在“换人”而不是“干活”。我一般建议上下文切换频率不要超过10kHz。
3.6 调度机制全景图
下面这张图,把调度器的工作原理、调度算法、调度点和上下文切换串在了一起。你可以把它当作本章的“知识地图”。
从这张图可以看出来,调度器不是孤立工作的。它接收就绪任务表、调度算法和调度点这三个输入,经过核心决策后,触发上下文切换。而上下文切换又细分为保存现场、更新TCB、恢复现场三步。