4、调度算法实现:基于优先级的抢占式调度、时间片轮转调度、空闲任务设计、调度点与上下文切换

调度算法,说白了就是操作系统里那个「谁先上CPU干活」的裁判。我做了这么多年RTOS内核,发现很多初学者一上来就盯着调度算法看,觉得这东西高深莫测。其实没那么玄乎,你把它想成一个会议室里只有一个座位,一堆人等着用,你得有个规矩决定谁先坐、坐多久。

今天咱们就把这摊事彻底讲透。我会从最基础的优先级抢占式调度讲起,再到时间片轮转,最后聊聊空闲任务和上下文切换那些坑。嗯,都是我在项目里踩过的坑。

4.1 基于优先级的抢占式调度

先说说最常见的调度方式——优先级抢占式调度。它的核心逻辑就一句话:谁优先级高,谁先跑

但这里有个细节很多人会忽略:什么时候检查优先级?不是随时都在检查,那样CPU就光干这事了。而是在特定的「调度点」才检查。调度点有哪些?我列一下:

  • 任务创建时
  • 任务主动让出CPU(比如调用delay或等待信号量)
  • 中断服务程序退出时
  • 时间片用完时

我个人习惯把调度点设计成统一的入口函数,比如叫 schedule()。这样代码结构清晰,调试也方便。

核心要点:抢占式调度不是「随时抢占」,而是「在调度点检查是否需要抢占」。这个区别很重要,我见过有人把调度器写成了死循环,CPU全耗在检查上了。

举个例子,假设有三个任务:TaskA(优先级5)、TaskB(优先级3)、TaskC(优先级1)。数字越小优先级越高。TaskC正在运行,突然TaskA就绪了。这时候不会立刻切换,而是等到下一个调度点,比如Tick中断到来,调度器发现TaskA优先级更高,于是保存TaskC的上下文,加载TaskA的上下文,切换过去。

我曾经在一个项目中遇到过这样的问题:一个高优先级任务频繁就绪,导致低优先级任务永远得不到CPU。这就是所谓的「优先级反转」和「饥饿」问题。解决办法?后面章节会讲,这里先留个悬念。

4.2 时间片轮转调度

优先级抢占式调度有个天然缺陷:如果两个任务优先级相同,谁先跑?按优先级调度的话,先就绪的先跑,但跑多久?如果不做限制,它可能一直占着CPU不放。

这时候就需要时间片轮转调度了。说白了就是:同等优先级的任务,每人分一个时间片,轮流上CPU

时间片多长合适?我一般设成系统Tick周期的整数倍,比如5个Tick。太短了切换开销大,太长了实时性差。你想想看,如果时间片是100ms,一个紧急任务最多要等100ms才能上CPU,这还叫实时系统吗?

我的经验:时间片通常设为1~10个Tick。对于大多数MCU应用,5个Tick(假设Tick=1ms,就是5ms)是个不错的起点。你可以根据实际负载调整。

时间片轮转的实现其实不复杂。每个任务控制块(TCB)里加一个字段,比如 time_slice,记录剩余时间片。每次Tick中断到来,当前任务的时间片减1。减到0了,就触发调度,把CPU让给同优先级的下一个任务。

代码大概长这样:

// Tick中断处理函数
void tick_handler(void) {
    current_task->time_slice--;
    if (current_task->time_slice == 0) {
        // 时间片用完,触发调度
        need_schedule = 1;
    }
    // 其他Tick相关处理...
}

注意,这里只是设置了一个标志 need_schedule,真正的上下文切换是在中断退出时做的。为什么?因为中断里直接做切换容易出问题,后面会细说。

4.3 空闲任务设计

系统里所有任务都阻塞了怎么办?比如都在等信号量、等消息队列。这时候CPU不能闲着,得有个「兜底」的任务——空闲任务。

空闲任务的设计其实很有讲究。很多人觉得空闲任务就是死循环,啥也不干。嗯,从功能上讲确实是这样,但实际项目中,空闲任务往往承担着重要职责:

  • 统计CPU利用率:记录空闲任务运行的时间占比,反过来就是CPU负载
  • 低功耗管理:空闲时让CPU进入休眠模式,省电
  • 系统健康检查:比如看门狗喂狗、内存碎片整理

我一般把空闲任务的优先级设成最低,通常是255(假设优先级范围0~255)。这样只要有其他任务就绪,空闲任务立刻被抢走CPU。

注意:空闲任务里不能调用任何可能导致阻塞的API,比如等待信号量、延时等。否则系统就死锁了。我曾经见过有人把延时函数写进了空闲任务,结果整个系统卡死,查了两天才找到原因。

空闲任务的典型实现:

void idle_task(void *param) {
    while (1) {
        // 统计CPU空闲时间
        idle_count++;
        // 进入低功耗模式(可选)
        // __WFI();  // ARM Cortex-M的等待中断指令
    }
}

4.4 调度点与上下文切换

调度点,就是调度器被触发执行的地方。前面提到了几个,这里再系统梳理一下:

调度点 触发条件 说明
任务创建 新任务就绪 如果新任务优先级高于当前任务,立即切换
任务主动让出 调用delay、等待资源 当前任务主动放弃CPU
中断退出 中断服务程序结束 检查是否有更高优先级任务就绪
时间片用完 Tick中断 同优先级任务轮转

上下文切换,就是保存当前任务的寄存器状态,恢复下一个任务的寄存器状态。这是调度器最核心、也最容易出bug的地方。

我习惯把上下文切换分成两步:

  1. 保存现场:把当前任务的CPU寄存器压入栈中,栈指针保存到TCB
  2. 恢复现场:从下一个任务的TCB取出栈指针,弹出寄存器,然后跳转执行

这部分通常用汇编实现,因为要直接操作CPU寄存器。C语言做不到这么精细。举个例子,ARM Cortex-M的PendSV中断就是专门做上下文切换的:

; PendSV中断处理函数
PendSV_Handler:
    ; 保存当前任务上下文
    MRS     R0, PSP          ; 获取当前进程栈指针
    STMDB   R0!, {R4-R11}    ; 保存R4~R11到栈中
    LDR     R1, =current_tcb
    LDR     R2, [R1]
    STR     R0, [R2]         ; 更新TCB中的栈指针

    ; 选择下一个任务
    BL      select_next_task

    ; 恢复下一个任务上下文
    LDR     R1, =current_tcb
    LDR     R2, [R1]
    LDR     R0, [R2]         ; 获取新任务的栈指针
    LDMIA   R0!, {R4-R11}    ; 恢复R4~R11
    MSR     PSP, R0          ; 更新进程栈指针
    BX      LR               ; 返回,自动恢复其他寄存器

避坑指南:我曾经在上下文切换时忘记保存R4~R11,结果任务切换后数据全乱了。记住,C编译器默认使用R0~R3作为临时寄存器,R4~R11是调用者保存的,必须手动处理。

还有一个容易忽略的点:中断嵌套时的上下文切换。如果在中断里触发了调度,不能立刻切换,要等到所有中断都退出后再做。否则中断嵌套层数一多,栈就乱了。解决办法是用PendSV做「延迟切换」——在中断里只设置标志,真正的切换在PendSV里完成,而PendSV的优先级设成最低。

嗯,说到这,调度算法的核心内容基本就这些了。你想想看,其实调度器没那么神秘,无非就是「谁优先级高谁先跑,同优先级轮流跑,没人跑就空闲跑」。但每个环节的细节处理,才是真正考验功力的地方。

下一章咱们聊聊任务同步与通信,信号量、互斥量、消息队列这些。到时候我会分享一个我在无人机项目里遇到的优先级反转案例,保证让你印象深刻。