3. 任务调度机制:调度器工作原理、优先级调度算法、时间片轮转调度、抢占式调度与非抢占式调度、调度点与上下文切换

任务调度,说白了就是RTOS的“大脑”。它决定哪个任务该运行,哪个任务该等着。我刚开始接触RTOS时,总觉得调度不就是个排队嘛,后来踩了不少坑才明白——调度策略选不对,系统性能直接腰斩。

这一章,咱们把调度器的五脏六腑拆开看。从工作原理到具体算法,再到上下文切换这个“重头戏”,我都会结合项目中的实际案例来讲。

3.1 调度器工作原理

调度器的核心任务就三个字:选任务。它维护着一张“就绪任务表”,每次需要切换时,就从这张表里挑一个最合适的任务去执行。

我习惯把调度器的工作流程拆成三步:

  1. 查表:扫描就绪任务列表,找出优先级最高的任务
  2. 比较:看选出来的任务是不是当前正在运行的任务
  3. 切换:如果不是,就保存当前任务上下文,加载新任务的上下文

嗯,这里要注意——调度器不是每时每刻都在工作的。它只在特定的“调度点”被触发。后面我们会细说调度点。

核心原则:调度器追求的是“确定性”——在任何时刻,系统都能明确知道该运行哪个任务。这是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 调度机制全景图

下面这张图,把调度器的工作原理、调度算法、调度点和上下文切换串在了一起。你可以把它当作本章的“知识地图”。

RTOS任务调度机制全景图 调度器核心 就绪任务表 调度算法 调度点触发 优先级调度 时间片轮转 抢占式 非抢占式 上下文切换 保存寄存器现场 更新TCB状态 恢复新任务现场 调度点:任务创建/删除、阻塞/唤醒、时间片用完、中断返回、主动让出

从这张图可以看出来,调度器不是孤立工作的。它接收就绪任务表、调度算法和调度点这三个输入,经过核心决策后,触发上下文切换。而上下文切换又细分为保存现场、更新TCB、恢复现场三步。