调度器核心原理:时间片轮转、优先级抢占、协作式调度

做嵌入式系统这么多年,我见过太多工程师一上来就撸代码,结果任务跑飞了、优先级反转了、死锁了,才回头研究调度器。说实话,调度器就是操作系统的"心脏"——它决定了哪个任务在什么时候跑、跑多久、跑完怎么办。

这一章,咱们把三种最经典的调度方式掰开揉碎讲清楚。你想想看,搞懂了它们,你就能根据项目需求选对方案,而不是盲目套用 FreeRTOS 或者自己瞎写一个。

1. 时间片轮转调度(Round-Robin)

时间片轮转,说白了就是"轮流坐庄"。每个任务分到固定时长,时间到了就换下一个。我最早在 8 位单片机上做 LED 扫描和按键检测时,用的就是这种思路。

核心思想:所有任务平等,按顺序轮流执行固定时长。

工作原理

  1. 系统维护一个就绪任务队列(FIFO 顺序)
  2. 定时器产生周期性中断,作为时间片切换信号
  3. 当前任务执行满一个时间片后,被挂起到队尾
  4. 调度器从队首取出下一个任务执行

我曾经在一个智能家居项目里用过纯轮询,结果按键响应延迟高达 200ms。后来改成时间片轮转,每个任务分 5ms,问题就解决了。嗯,这里要注意:时间片太短会导致频繁切换,浪费 CPU;太长又会让紧急任务等太久。

时间片长度选择建议

应用场景 推荐时间片 说明
按键扫描、LED 显示 5-10ms 人眼感知不到延迟
传感器数据采集 10-50ms 匹配传感器采样周期
通信协议处理 1-5ms 避免数据丢包
电机控制 0.5-2ms 需要快速响应

我的经验:时间片别用死值。我习惯在系统初始化时根据任务数量动态计算:time_slice = TICK_PERIOD / task_count,这样增减任务时不用改代码。

2. 优先级抢占调度(Preemptive Scheduling)

优先级抢占,就是"急事先办"。高优先级任务可以打断低优先级任务。这玩意儿在实时系统中太重要了——你总不能让刹车控制等 LED 闪烁跑完吧?

注意:优先级抢占不是万能的。我曾经在一个项目中设了 8 级优先级,结果调试时发现低优先级任务永远得不到 CPU——这就是"饥饿"问题。

抢占规则

  • 每个任务分配一个优先级(数值越小优先级越高)
  • 调度器总是选择就绪态中优先级最高的任务执行
  • 高优先级任务就绪时,立即抢占当前任务
  • 同优先级任务之间,通常配合时间片轮转

优先级反转问题

你想想看,如果低优先级任务持有高优先级任务需要的资源,会发生什么?高优先级任务反而要等低优先级任务——这就是优先级反转。我踩过这个坑:一个串口驱动任务优先级设高了,结果它等一个低优先级任务释放缓冲区,整个系统卡死。

解决办法有三种:

方案 原理 适用场景
优先级继承 低优先级任务临时提升到高优先级 资源竞争不频繁
优先级天花板 资源设定最高可用优先级 资源数量固定
禁止抢占 访问临界区时关中断 临界区极短

避坑指南:我曾经在医疗设备项目里用优先级天花板,结果发现一个传感器驱动把天花板设成了最高级,导致所有任务都被阻塞。后来改成优先级继承,问题解决。记住:天花板方案要谨慎,别设太高。

3. 协作式调度(Cooperative Scheduling)

协作式调度,就是"自觉排队"。每个任务主动让出 CPU,调度器从不强制切换。这听起来很原始,但在资源极度受限的 MCU 上,它反而最可靠。

实现方式

// 协作式调度器核心代码
void task_yield(void) {
    // 保存当前任务上下文
    save_context(current_task);
    // 切换到下一个任务
    current_task = get_next_task();
    // 恢复新任务上下文
    restore_context(current_task);
}

// 每个任务必须主动调用 yield
void task_led(void) {
    while(1) {
        toggle_led();
        task_yield();  // 主动让出 CPU
    }
}

我个人习惯在超低功耗项目里用协作式调度。为什么?因为没有抢占中断,功耗可以压到极低。一个温湿度采集器,用协作式调度,待机电流能到 2μA。

优缺点对比

特性 协作式 抢占式
实时性 依赖任务自觉 硬件保证
代码复杂度 低 高(需处理临界区)
内存开销 极小(每个任务只需一个栈) 较大(每个任务独立栈)
功耗 可做到极低 较高(定时器频繁中断)
调试难度 低(确定性执行) 高(抢占导致非确定性)

我的建议:如果你的任务都是"短平快"类型(每个任务执行时间 < 1ms),协作式调度完全够用。我做过一个 8 路温度采集系统,8 个任务,每个任务 200μs,协作式调度跑得稳稳的。

4. 三种调度方式对比与选型

你可能会问:到底该用哪种?我一般这样选:

  • 时间片轮转:任务平等、无紧急需求、MCU 资源中等(如 STM32F0)
  • 优先级抢占:有硬实时任务、需要快速响应、MCU 资源丰富(如 STM32F4+)
  • 协作式:超低功耗、任务简单、MCU 资源极少(如 8051、PIC)

其实很多商业 RTOS 都是混合调度:同优先级用时间片轮转,不同优先级用抢占。FreeRTOS 就是这么干的。你想想看,这样既保证了实时性,又避免了低优先级任务饿死。

三种调度方式对比图 时间片轮转 任务A → 任务B → 任务C 时间片: 5-10ms 适用: 任务平等场景 优先级抢占 高优先级打断低优先级 优先级: 0(最高) ~ 255(最低) 适用: 硬实时系统 协作式调度 任务主动让出CPU 无抢占中断 适用: 超低功耗系统 选型建议 • 时间片轮转: 任务平等、MCU资源中等 • 优先级抢占: 有硬实时任务、MCU资源丰富 • 协作式: 超低功耗、任务简单、MCU资源极少 • 混合调度: 同优先级轮转 + 不同优先级抢占

5. 调度器核心数据结构

不管哪种调度方式,底层都离不开几个关键数据结构。我习惯用 C 语言的结构体来组织:

// 任务控制块(TCB)
typedef struct {
    uint8_t  id;              // 任务ID
    uint8_t  priority;        // 优先级
    uint32_t stack_size;      // 栈大小
    uint32_t *stack_ptr;      // 栈指针
    void     (*task_func)(void*); // 任务函数
    void     *arg;            // 任务参数
    uint8_t  state;           // 任务状态: READY/RUNNING/BLOCKED/SUSPENDED
    uint32_t time_slice;      // 时间片(仅轮转调度使用)
    uint32_t remaining_ticks; // 剩余时间片
} TCB_t;

// 就绪队列(优先级位图法)
typedef struct {
    uint32_t ready_bitmap;    // 就绪位图,每位代表一个优先级
    TCB_t   *task_list[MAX_PRIORITY]; // 每个优先级对应一个任务链表
} ReadyQueue_t;

注意:就绪队列的实现直接影响调度性能。我见过有人用链表遍历找最高优先级任务,结果在 32 个任务时调度延迟达到 50μs。改用位图法后,延迟降到 0.5μs——差了 100 倍!

6. 上下文切换的代价

上下文切换不是免费的。每次切换都要保存/恢复寄存器、栈指针、状态字。我实测过:

MCU 架构 切换代价(周期数) @72MHz 耗时
ARM Cortex-M0 ~40 周期 0.56μs
ARM Cortex-M4 ~60 周期 0.83μs
8051 ~100 周期 1.39μs
PIC16 ~200 周期 2.78μs

你想想看,如果时间片设为 1ms,切换代价 1μs,那切换开销只占 0.1%,完全可以接受。但如果时间片设为 100μs,切换开销就占到 1% 了——这时候就要考虑减少切换频率。

我的习惯:在项目初期,我会用 GPIO 翻转 + 示波器实测切换时间。别信手册上的理论值,实际跑起来才知道。有一次我发现切换时间比手册多了 30%,原因是编译器优化等级不同导致的。