4、任务调度算法:时间片轮转调度、优先级抢占调度、协作式调度、调度器启动与切换流程

调度算法,说白了就是RTOS的「大脑」。它决定哪个任务先跑,哪个任务后跑。我做了这么多年嵌入式,见过太多系统因为调度策略选错而翻车。今天咱们就把这几种调度算法掰开揉碎了讲清楚。

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

时间片轮转,是最公平的调度方式。每个任务轮流跑,跑完一个时间片就换人。你想想看,就像几个人分蛋糕,每人吃一口就传给下一个人。

核心机制:

  • 每个任务分配固定长度的时间片(通常1-10ms)
  • 时间片用完,强制切换下一个就绪任务
  • 所有任务优先级相同,轮流执行

适用场景:多个同等重要的任务,谁也不能饿死。比如数据采集、界面刷新、日志输出这些后台任务。

我在项目中遇到过一个问题:某个传感器采集任务需要每5ms读一次数据,但时间片轮转下,如果其他任务处理时间过长,采集就会丢数据。后来我给它提了优先级,才解决。

// 时间片轮转调度核心代码(简化版)
void scheduler_rr(void) {
    while (1) {
        task_t *current = get_next_ready_task();
        if (current) {
            // 设置定时器,时间片到后触发SysTick中断
            set_timer(TIME_SLICE_MS);
            // 切换到该任务
            context_switch(current);
        }
    }
}

我的建议:时间片别设太短。太短会导致频繁切换,上下文开销大。我一般设5-10ms,具体看你的CPU主频和任务量。

4.2 优先级抢占调度(Priority Preemptive)

这是RTOS最常用的调度方式。每个任务有优先级,高优先级任务可以随时打断低优先级任务。说白了就是「官大一级压死人」。

调度规则:

  • 就绪队列中,优先级最高的任务先运行
  • 高优先级任务就绪时,立即抢占当前任务
  • 同优先级任务之间,可以配合时间片轮转

我曾经踩过的坑:优先级反转!低优先级任务持有高优先级任务需要的资源,导致高优先级任务被阻塞。解决方法是优先级继承协议,或者用互斥量代替信号量。

// 优先级抢占调度核心逻辑
void scheduler_preemptive(void) {
    task_t *current = get_current_task();
    task_t *highest = get_highest_ready_task();
    
    if (highest->priority > current->priority) {
        // 保存当前任务上下文
        save_context(current);
        // 切换到高优先级任务
        context_switch(highest);
    }
}
调度方式 优点 缺点
时间片轮转 公平,无饥饿 实时性差,不适合紧急任务
优先级抢占 实时性好,响应快 可能产生优先级反转、饥饿

4.3 协作式调度(Cooperative)

协作式调度,任务自己决定什么时候让出CPU。它不像抢占式那样强制切换,而是靠任务主动调用task_yield()或等待事件。

特点:

  • 任务不会被强制打断,除非自己让出
  • 不需要定时器中断,资源开销小
  • 适合简单系统,比如超低功耗设备

注意:协作式调度下,如果一个任务死循环或长时间不释放CPU,整个系统就卡死了。我见过有人把while(1)写在任务里,结果其他任务永远跑不了。

// 协作式调度示例
void task_a(void) {
    while (1) {
        do_something();
        // 主动让出CPU
        task_yield();
    }
}

void task_b(void) {
    while (1) {
        do_other_thing();
        // 等待事件时自动让出
        wait_event(EVENT_FLAG);
    }
}

我个人习惯:协作式调度适合任务少、逻辑简单的场景。比如一个按键扫描+LED闪烁的小项目,用协作式就够了,省去抢占式的复杂逻辑。

4.4 调度器启动与切换流程

调度器启动,是整个RTOS的「点火」动作。它从第一个任务开始,然后不断切换。嗯,这里要注意,启动流程有严格的顺序。

调度器启动步骤:

  1. 初始化系统时钟、中断控制器
  2. 创建至少一个任务(通常是空闲任务)
  3. 设置第一个任务的栈指针和PC指针
  4. 触发PendSV异常,进入任务上下文
  5. 开始调度循环
// 调度器启动核心代码
void scheduler_start(void) {
    // 1. 获取最高优先级任务
    task_t *first_task = get_highest_ready_task();
    
    // 2. 设置PSP为任务栈指针
    __set_PSP(first_task->stack_ptr);
    
    // 3. 设置CONTROL寄存器,使用PSP
    __set_CONTROL(0x02);
    
    // 4. 触发PendSV,切换到任务
    SCB->ICSR |= SCB_ICSR_PENDSVSET_Msk;
    
    // 5. 进入任务(不会返回)
    while (1);
}

任务切换流程:

  • 保存当前任务上下文(寄存器、栈指针)
  • 更新任务状态(运行→就绪)
  • 选择下一个要运行的任务
  • 恢复新任务的上下文
  • 跳转到新任务继续执行

我曾经犯过的错:在中断服务函数里直接调用调度器,导致嵌套中断下上下文损坏。正确做法是只在PendSV异常里做切换,或者用临界区保护。

切换流程中,最关键的其实是上下文保存和恢复。你想想看,如果寄存器没保存全,任务恢复时就会跑飞。我一般会保存R4-R11、LR、PC、PSP这些关键寄存器。

// PendSV异常处理(任务切换入口)
void PendSV_Handler(void) {
    // 保存当前任务上下文
    __asm volatile(
        "MRS R0, PSP\n"
        "STMDB R0!, {R4-R11}\n"
        "STR R0, [R1]\n"  // 保存栈指针到TCB
    );
    
    // 选择下一个任务
    task_t *next = scheduler_pick_next();
    
    // 恢复新任务上下文
    __asm volatile(
        "LDR R0, [R1]\n"  // 从TCB加载栈指针
        "LDMIA R0!, {R4-R11}\n"
        "MSR PSP, R0\n"
        "BX LR\n"
    );
}

我的经验:调试调度器时,先跑一个任务,确认切换正常。再加第二个任务。别一上来就创建十几个任务,出了问题你根本不知道是哪个环节的锅。

最后说一句,调度算法的选择没有银弹。时间片轮转适合公平场景,优先级抢占适合实时场景,协作式适合简单场景。我建议你根据项目需求,甚至可以混合使用。比如高优先级任务用抢占式,低优先级任务用时间片轮转。嗯,灵活运用才是王道。