任务调度机制详解

调度器,说白了就是操作系统的「大脑」。它决定哪个任务该运行,哪个任务该等着。我刚开始接触 RT-Thread 时,总觉得调度是个很玄乎的东西。后来自己动手移植了一遍,才明白它其实就是一套规则加一套切换机制。

这一章,我们就把调度器的底裤扒开看看。

调度器工作原理

RT-Thread 的调度器核心逻辑,其实就一句话:谁优先级高谁先跑,同等优先级轮流跑。

调度器内部维护了一个就绪任务列表。每次调度发生时,它从列表里挑出优先级最高的任务,然后切过去执行。这个动作,我们叫「任务切换」。

核心要点:调度器本身不是一个独立的任务,它是由一系列函数调用触发的。换句话说,调度器是被「叫醒」的,不是自己在那一直转。

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

  1. 查表 —— 从就绪优先级位图中找到最高优先级的任务
  2. 取任务 —— 从对应优先级的就绪队列中取出任务控制块
  3. 切换 —— 保存当前任务上下文,恢复新任务的上下文

嗯,这里要注意:查表这一步,RT-Thread 用了位图算法,速度非常快。不管系统里有多少个任务,查最高优先级的时间都是固定的。这一点在实时系统中很关键。

基于优先级的抢占式调度

抢占式调度,是 RT-Thread 的默认模式。它的规则很简单:只要有一个更高优先级的任务就绪了,当前任务就得让位。

我在项目中遇到过一个问题:一个低优先级任务正在写 Flash,突然被高优先级任务抢占了。结果高优先级任务也要写 Flash,两个任务同时操作 Flash 控制器,数据全乱了。这就是抢占带来的资源竞争问题。

避坑指南:我曾经在一个电机控制项目里,把中断优先级和任务优先级搞混了。中断里释放信号量唤醒高优先级任务,结果高优先级任务抢占了中断返回后的低优先级任务,导致低优先级任务的时序完全乱掉。记住:中断返回后一定会触发一次调度,这是抢占的关键时机。

抢占式调度的触发时机,主要有这几个:

触发点 说明
任务创建/恢复 新任务就绪,可能比当前任务优先级高
任务释放资源 如释放信号量、消息队列,可能唤醒高优先级任务
任务主动挂起 当前任务让出 CPU,调度器选下一个
中断返回 中断里可能让高优先级任务就绪
时间片用完 同等优先级任务轮转

时间片轮转调度

你想想看,如果两个任务优先级一样,谁先跑?答案是:谁先来谁先跑,但每人只能跑一个时间片。

时间片轮转,就是解决同等优先级任务之间的公平问题。每个任务可以连续运行的时间,叫做一个时间片。时间片用完了,调度器就把这个任务放到队列尾部,让下一个任务上。

RT-Thread 的时间片单位是系统时钟节拍(tick)。默认一个 tick 是 1 毫秒,但你可以改。我个人习惯把时间片设成 5~10 个 tick,太短了切换开销大,太长了响应不及时。

经验之谈:时间片不是越大越好。我做过一个数据采集项目,三个同等优先级的任务分别负责采集、处理、发送。时间片设成 50ms,结果采集任务每次都要等 100ms 才能再次运行,数据丢包严重。后来改成 5ms,问题解决。

时间片轮转的代码逻辑,其实就在调度器里:

// 简化版时间片处理逻辑
if (当前任务的时间片 > 0) {
    当前任务的时间片--;
} else {
    // 时间片用完,移到队列尾部
    rt_list_remove(&当前任务->tlist);
    rt_list_insert_before(&就绪队列, &当前任务->tlist);
    // 重置时间片
    当前任务->remaining_tick = 当前任务->init_tick;
    // 触发调度
    需要调度 = 1;
}

调度点与上下文切换

调度点,就是调度器被触发执行的地方。RT-Thread 的调度点分布在系统的各个关键位置。

我画了一张图,帮你理清调度点和上下文切换的关系:

调度点与上下文切换流程 任务A(优先级5) 调度点:释放信号量 调度器 查表 → 选任务B 上下文切换 保存A → 恢复B 任务B(优先级3) 关键说明: 调度点触发调度器,调度器选出最高优先级任务,然后执行上下文切换。 上下文切换是汇编代码实现的,主要操作是保存/恢复CPU寄存器和栈指针。 常见调度点: ① 任务主动调用 rt_thread_delay/rt_thread_sleep ② 任务释放IPC资源(信号量、互斥量、消息队列等)

上下文切换,是调度器最底层的操作。它由汇编代码完成,主要做两件事:

  • 保存现场:把当前任务的 CPU 寄存器(R4-R11、PC、LR、PSP 等)压入当前任务的栈
  • 恢复现场:从新任务的栈里弹出寄存器值,然后跳转到新任务继续执行

说白了,上下文切换就是「换栈」。每个任务有自己的栈空间,切换时只要把栈指针(PSP)一换,CPU 就跑到了另一个任务的执行路径上。

调试技巧:我调试任务切换问题时,最喜欢在 PendSV_Handler 里加断点。每次触发 PendSV 中断,就说明要发生上下文切换了。看栈指针的变化,就能判断切换是否正常。

空闲线程与钩子函数

空闲线程,是 RT-Thread 自动创建的、优先级最低的线程。它的优先级是 255(RT-Thread 里优先级数值越大,优先级越低)。

空闲线程平时干什么?啥也不干,就在那死循环。但它的存在有两个重要意义:

  • 占位:保证就绪队列里永远至少有一个任务可运行
  • 回收:当任务被删除时,空闲线程负责回收任务占用的资源

空闲线程的钩子函数,是 RT-Thread 提供的一个扩展机制。你可以注册一个函数,让空闲线程每次循环时调用它。

// 注册空闲线程钩子函数
void my_idle_hook(void)
{
    static uint32_t count = 0;
    count++;
    // 可以在这里做低功耗处理
    // 或者统计CPU空闲率
}

rt_thread_idle_sethook(my_idle_hook);

我在项目中用过空闲钩子做两件事:

  1. CPU 空闲率统计:在钩子里累加计数器,通过计数器值估算 CPU 负载
  2. 低功耗模式:当系统空闲时,让 MCU 进入睡眠模式,等中断唤醒

注意:空闲钩子函数里不能调用会导致任务挂起的函数(如 rt_thread_delay、获取信号量等)。因为空闲线程本身不能挂起,一旦挂起系统就没人兜底了。我曾经犯过这个错,在钩子里调了 rt_thread_mdelay,结果系统直接死机。

空闲线程的代码逻辑,其实很简单:

// 空闲线程的简化实现
void rt_thread_idle_entry(void *parameter)
{
    while (1) {
        // 执行所有注册的钩子函数
        for (i = 0; i < idle_hook_count; i++) {
            idle_hook_list[i]();
        }
        // 回收已删除任务的资源
        rt_thread_defunct_reclaim();
    }
}

嗯,到这里,调度器的核心机制就讲完了。从调度器怎么选任务,到抢占怎么发生,再到时间片怎么轮转,最后是空闲线程怎么兜底。这些知识,是理解 RT-Thread 运行机制的基础。