3、任务调度机制:抢占式调度、时间片轮转、协作式调度、调度器挂起与恢复

调度机制,说白了就是操作系统决定「下一个该谁跑」的规则。FreeRTOS 支持三种调度方式,外加一个调度器的挂起与恢复。我做了这么多年嵌入式,发现很多开发者其实只用了默认配置,对背后的机制一知半解。今天咱们就把这块彻底讲透。

3.1 抢占式调度(Preemptive Scheduling)

这是 FreeRTOS 默认的调度方式,也是绝大多数实时系统的首选。它的核心逻辑很简单:谁优先级高,谁就先跑

具体来说,每次系统节拍中断(SysTick)到来时,调度器都会检查一下:当前运行的任务是不是就绪态里优先级最高的?如果不是,立刻切换。嗯,这里要注意——切换发生在中断退出时,不是中断一进来就切,那样会破坏中断上下文。

关键点:抢占式调度保证了高优先级任务能「随时」抢走 CPU。但这个「随时」其实有延迟——最大延迟取决于关中断的时间。

我在项目中遇到过一个问题:一个高优先级的中断服务程序里调用了 taskYIELD(),结果发现任务切换没有立即发生。后来查手册才明白,taskYIELD() 只是设置了一个 PendSV 标志,真正的切换要等到所有中断处理完毕。这个坑,我印象很深。

配置方式

在 FreeRTOSConfig.h 中设置:

#define configUSE_PREEMPTION    1   // 1 开启抢占,0 关闭

典型场景

  • 高优先级任务需要快速响应外部事件(比如按键、传感器中断)
  • 多个任务有明确的优先级分层
  • 对实时性要求严格的系统

避坑指南:我曾经见过一个项目,把所有任务优先级都设成一样,然后开启了抢占式调度。结果任务之间互相抢来抢去,系统响应反而变差了。抢占式调度配合不同优先级才有意义,优先级相同的情况下,它退化成时间片轮转。

3.2 时间片轮转(Round-Robin Scheduling)

时间片轮转,说白了就是「大家轮流用 CPU」。当多个任务优先级相同时,调度器会给每个任务分配一个固定的时间片(默认一个系统节拍),时间到了就切换到下一个同等优先级的任务。

你想想看,如果没有时间片轮转,两个相同优先级的任务会怎样?一个任务一直跑,另一个永远得不到 CPU。这显然不合理。

配置方式

#define configUSE_TIME_SLICING  1   // 1 开启时间片,0 关闭

时间片长度

默认是一个系统节拍(tick)。假设 tick 频率是 1000Hz,那时间片就是 1ms。我个人习惯把 tick 频率设在 100~1000Hz 之间,太高了浪费 CPU 在上下文切换上,太低了响应又不够快。

Tick 频率 时间片长度 适用场景
100 Hz 10 ms 对功耗敏感、实时性要求不高的系统
1000 Hz 1 ms 一般实时系统
10000 Hz 0.1 ms 高实时性要求,但注意上下文切换开销

个人经验:我建议时间片轮转只用于「同等重要性」的任务。比如多个数据采集任务,每个采集一个传感器,优先级相同,用时间片轮转就很合适。但如果任务有明确的优先级差异,就别用时间片了,直接用抢占式。

3.3 协作式调度(Cooperative Scheduling)

协作式调度,也叫非抢占式调度。它的规则是:任务主动让出 CPU,调度器才会切换。任务不主动让,就一直跑下去。

为什么会有人用协作式?我刚开始也觉得这玩意儿过时了。直到有一次做一个超低功耗的项目,发现抢占式调度带来的上下文切换太频繁,功耗压不下去。换成协作式后,任务自己决定什么时候让出 CPU,功耗直接降了 30%。

配置方式

#define configUSE_PREEMPTION    0   // 关闭抢占,即协作式

任务让出 CPU 的方式

  • taskYIELD():主动请求调度
  • vTaskDelay():任务主动阻塞
  • vTaskDelayUntil():周期性阻塞
  • 等待队列、信号量等同步操作

我曾经踩过的坑:一个同事在协作式调度下写了一个死循环任务,没有加任何 taskYIELD()。结果系统直接卡死,其他任务永远得不到运行。协作式调度下,每个任务都要有「良心」,主动让出 CPU。否则,整个系统就崩了。

适用场景

  • 任务数量少,且每个任务执行时间可控
  • 对功耗有严格要求
  • 不需要快速响应外部事件
  • 代码逻辑简单,不容易出现死循环

3.4 调度器挂起与恢复

调度器挂起,就是让调度器「暂停工作」。挂起期间,所有任务都不再被调度,当前任务会一直运行,直到调度器恢复。

这个功能看起来简单,但用不好会出大问题。我记得有一次,一个同事在中断里挂起了调度器,然后去操作一个共享资源。结果另一个中断也来了,也想挂起调度器——死锁了。嗯,调度器挂起不能在中断里用,这是铁律。

API 接口

// 挂起调度器
void vTaskSuspendAll(void);

// 恢复调度器
BaseType_t xTaskResumeAll(void);

使用场景

  • 保护临界区:当需要操作多个任务共享的数据结构时,挂起调度器可以防止任务切换导致数据不一致
  • 批量操作:比如要创建多个任务,挂起调度器可以避免每次创建都触发调度,提高效率
  • 时间同步:某些对时序要求严格的场景,需要确保一段代码不被中断

重要提醒:挂起调度器不会关中断!中断仍然可以响应。只是中断返回后,不会触发任务切换。如果你需要同时关中断和挂起调度器,请使用 taskENTER_CRITICAL()taskEXIT_CRITICAL()

嵌套调用

调度器挂起支持嵌套。也就是说,你可以多次调用 vTaskSuspendAll(),但必须调用相同次数的 xTaskResumeAll() 才能恢复。我习惯用一个计数器来跟踪嵌套深度,虽然 FreeRTOS 内部已经做了,但自己心里要有数。

void myFunction(void)
{
    vTaskSuspendAll();
    // 做一些操作...
    {
        vTaskSuspendAll();  // 嵌套挂起
        // 更关键的操作...
        xTaskResumeAll();   // 恢复一层
    }
    // 继续操作...
    xTaskResumeAll();       // 完全恢复
}

我的建议:挂起调度器的时间越短越好。我一般控制在几十微秒以内。如果挂起时间太长,高优先级任务的实时性就得不到保证。你想想看,一个高优先级任务等着响应外部中断,结果调度器被挂起了,它只能干等——这还叫什么实时系统?

3.5 三种调度方式的对比

特性 抢占式 时间片轮转 协作式
任务切换时机 系统节拍、主动让出 时间片用完、主动让出 仅主动让出
高优先级响应 立即响应 需等待时间片用完 需等待当前任务让出
上下文切换频率
功耗 较高 中等 较低
代码复杂度 低(系统自动管理) 高(任务需主动让出)
典型应用 实时控制、通信协议 数据采集、UI 刷新 超低功耗、简单系统

最后说一句:没有最好的调度方式,只有最合适的。我见过有人把抢占式用得一塌糊涂,也见过协作式跑得飞快的系统。关键是你得理解每种机制背后的代价和收益。嗯,这一章就到这里,下一章咱们聊聊任务状态与状态转换——这块是理解 FreeRTOS 行为的基础。