3. 任务调度算法(一):时间片轮转调度(Round-Robin)

调度算法是RTOS的“大脑”。你想想看,多个任务都在那等着跑,谁先上CPU?谁后上?这就是调度器要干的事。

今天咱们先聊最基础、也最直观的一种——时间片轮转调度(Round-Robin,简称RR)。我个人习惯把它叫做“排队轮流上”。

3.1 原理:说白了就是“一人一票,轮流上车”

RR算法的核心思想特别简单:

  • 每个任务分配一个固定的时间片(Time Slice),比如5ms、10ms。
  • 任务按就绪队列的先后顺序,挨个上CPU跑。
  • 时间片用完了,任务还没跑完?强制挂起,换下一个任务上。
  • 被换下来的任务排到队尾,等着下一轮。

嗯,这里要注意:RR是抢占式调度。时间片一到,不管任务跑没跑完,调度器直接“抢”走CPU。我在项目中遇到过新手把RR当成协作式调度来写,结果任务死循环,整个系统就挂了。

核心公式: 响应时间 ≈ 时间片 × 就绪任务数

举个例子:假设时间片是10ms,系统里有5个任务在跑。那么最坏情况下,一个任务要等4×10ms=40ms才能再次拿到CPU。这个延迟,就是RR的“公平代价”。

3.2 实现机制:就绪队列 + 定时器中断

RR的实现,说白了就两个东西:

  1. 就绪队列:FIFO结构,新来的任务插队尾,被换下的也回队尾。
  2. 系统定时器(SysTick):每个时间片到期触发一次中断,调度器在中断里做上下文切换。

我画了一张流程图,帮你理清整个调度流程:

时间片轮转调度流程图 任务就绪 插入就绪队列队尾 调度器取队首任务 任务运行 时间片到? 任务完成,移除 任务回队尾 是 否,继续 说明

3.3 优缺点分析

RR不是万能的。我用一张表帮你把它的优缺点说清楚:

维度 优点 缺点
公平性 所有任务平等,没有“饿死”现象 无法区分任务优先级,紧急任务也得排队
实现复杂度 代码简单,就绪队列+定时器即可 时间片大小需要反复调参
响应时间 可预测,最坏情况可计算 任务数多时,响应延迟线性增长
上下文切换 切换时机固定,便于分析 时间片太短→频繁切换→开销大
适用场景 任务重要性相近的通用系统 不适合硬实时、高优先级抢占场景
避坑指南: 我曾经在一个电机控制项目里把时间片设成1ms,结果上下文切换占了30%的CPU时间。后来改成5ms,系统才稳定下来。时间片不是越小越好,要结合任务执行时间和切换开销来选。

3.4 实战代码示例:手写一个迷你RR调度器

下面是我写的一个简化版RR调度器。它跑在Cortex-M3/M4上,用SysTick做时间片中断。代码去掉了一些硬件细节,保留了核心逻辑。

/* ============================================================
 *  迷你RR调度器核心代码(基于Cortex-M3 SysTick)
 * ============================================================ */

#define MAX_TASKS       8
#define TIME_SLICE_MS   10      // 每个任务10ms时间片

typedef struct {
    uint32_t    *sp;            // 栈指针
    uint32_t    stack[256];     // 任务栈
    uint8_t     state;          // 0=就绪, 1=运行, 2=阻塞
    uint32_t    slice_remain;   // 剩余时间片(ticks)
} TCB_t;

TCB_t task_pool[MAX_TASKS];
volatile int current_task = 0;
int task_count = 0;

/* 创建任务:初始化栈帧,插入就绪队列 */
int task_create(void (*entry)(void *), void *arg) {
    if (task_count >= MAX_TASKS) return -1;

    TCB_t *tcb = &task_pool[task_count];
    uint32_t *stk = &tcb->stack[255];

    /* 模拟Cortex-M3压栈顺序:xPSR, PC, LR, R12, R3-R0 */
    *(--stk) = 0x01000000;      // xPSR
    *(--stk) = (uint32_t)entry; // PC
    *(--stk) = 0xFFFFFFFD;      // LR (返回线程模式)
    *(--stk) = 0x12121212;      // R12
    *(--stk) = 0x03030303;      // R3
    *(--stk) = 0x02020202;      // R2
    *(--stk) = 0x01010101;      // R1
    *(--stk) = (uint32_t)arg;   // R0 (参数)

    tcb->sp = stk;
    tcb->state = 0;             // 就绪
    tcb->slice_remain = TIME_SLICE_MS;

    task_count++;
    return task_count - 1;
}

/* SysTick中断服务函数:时间片管理 */
void SysTick_Handler(void) {
    TCB_t *tcb = &task_pool[current_task];

    tcb->slice_remain--;
    if (tcb->slice_remain > 0) {
        return;                 // 时间片还没用完,继续跑
    }

    /* 时间片用完:保存当前任务上下文,切换到下一个 */
    tcb->sp = (uint32_t*)__get_PSP();   // 获取当前栈指针
    tcb->state = 0;                     // 变为就绪

    /* 轮转到下一个就绪任务 */
    int next = (current_task + 1) % task_count;
    while (task_pool[next].state != 0) {
        next = (next + 1) % task_count;
        if (next == current_task) {
            // 所有任务都阻塞?死循环保护
            break;
        }
    }

    current_task = next;
    task_pool[current_task].state = 1;
    task_pool[current_task].slice_remain = TIME_SLICE_MS;

    __set_PSP((uint32_t)task_pool[current_task].sp);  // 切换栈
}

/* 启动调度器 */
void scheduler_start(void) {
    if (task_count == 0) return;

    current_task = 0;
    task_pool[0].state = 1;
    task_pool[0].slice_remain = TIME_SLICE_MS;

    /* 配置SysTick:每1ms中断一次 */
    SysTick_Config(SystemCoreClock / 1000);

    /* 切换到第一个任务的栈 */
    __set_PSP((uint32_t)task_pool[0].sp);
    __set_CONTROL(0x03);        // 使用PSP,特权模式
    __ISB();

    /* 触发PendSV做第一次上下文切换 */
    *(volatile uint32_t *)0xE000ED04 = 0x10000000;
}
个人经验: 这段代码里有个细节——slice_remain是递减到0才切换。我见过有人用递增比较,每次中断都要做一次比较,浪费几个周期。在嵌入式里,减法到0的硬件标志位判断比比较指令快,能省则省。

3.5 时间片怎么选?我的调参经验

时间片大小是RR调度里最头疼的事。我总结了一个经验公式:

时间片 ≥ 2 × (上下文切换时间 + 任务平均执行时间)

为什么是2倍?因为切换本身有开销,如果时间片比切换时间还短,那CPU全在切任务,实际干活的时间就少了。

我一般这样选:

  • I/O密集型任务(比如按键扫描、串口收发):时间片设短一点,5~10ms,提高响应速度。
  • 计算密集型任务(比如FFT、PID运算):时间片设长一点,20~50ms,减少切换次数。
  • 混合场景:先设10ms,用逻辑分析仪抓一下任务切换频率,再微调。
一句话总结: RR调度就是“公平但平庸”。它保证了每个任务都有机会跑,但没法保证紧急任务优先跑。如果你的系统里任务重要性差不多,RR是最稳妥的选择。