第三章 任务调度机制:调度器工作原理、优先级抢占调度算法实现、时间片轮转调度、调度点与临界区保护

3.1 调度器到底在干什么?

说实话,很多初学者把调度器想得太神秘了。我当年刚接触RTOS时,也觉得调度器是个高深莫测的东西。后来自己动手写了一个,才发现——说白了,调度器就是个「选人干活」的机制。

你想想看,系统里同时有多个任务在等待运行。谁先跑?谁后跑?跑多久?这就是调度器要解决的问题。我习惯把调度器比作一个「交通指挥员」——它不负责具体干活,但它决定谁在什么时候通过路口。

调度器的工作流程其实很简单:

  • 任务就绪队列:所有能跑的任务都在这里排队
  • 当前任务指针:指向正在运行的那个任务
  • 调度决策:根据某种规则,选出下一个要跑的任务
  • 上下文切换:保存当前任务的状态,恢复新任务的状态

嗯,这里要注意——调度器本身不是一个独立的任务,它是在特定时机被调用的函数。这个时机,就是我们后面要讲的「调度点」。

3.2 优先级抢占调度:谁急谁先跑

这是RTOS最核心的调度算法。我在项目中遇到过很多次,新手最容易搞混的就是「抢占」和「时间片」的区别。

优先级抢占调度,核心思想就一句话:高优先级的任务一旦就绪,立即打断低优先级任务的执行

来看一个典型的实现代码:

// 调度器核心函数
void Scheduler_Schedule(void)
{
    TaskControlBlock_t *pNewTask;
    TaskControlBlock_t *pCurrentTask = g_pCurrentTask;
    
    // 找到就绪队列中优先级最高的任务
    pNewTask = GetHighestPriorityTask();
    
    // 如果新任务不是当前任务,就切换
    if (pNewTask != pCurrentTask)
    {
        // 保存当前任务上下文
        if (pCurrentTask != NULL)
        {
            SaveContext(&pCurrentTask->stackPointer);
            pCurrentTask->state = TASK_READY;
        }
        
        // 恢复新任务上下文
        pNewTask->state = TASK_RUNNING;
        g_pCurrentTask = pNewTask;
        RestoreContext(&pNewTask->stackPointer);
    }
}

这段代码看起来简单,但有几个坑我踩过:

我曾经踩过的坑:
  • GetHighestPriorityTask() 必须保证O(1)时间复杂度,否则调度开销会拖死系统。我早期用链表遍历,结果在128个任务时调度延迟直接爆表
  • SaveContext/RestoreContext 必须用汇编实现,C语言无法直接操作CPU寄存器
  • 切换前要关中断,否则保存到一半被中断打断,上下文就乱了

优先级抢占的典型场景是这样的:

任务 优先级 行为
Task_Low 10(低) 正在运行,突然被抢占
Task_High 5(高) 从外设中断中唤醒,立即抢占
Task_Mid 8(中) 永远没机会跑,因为Task_High一直占着CPU

你发现没?Task_Mid 永远跑不了。这就是优先级反转的雏形。我建议在设计任务优先级时,一定要留出足够的余量,别把所有任务都挤在相邻优先级上。

3.3 时间片轮转调度:人人有份

优先级抢占有个问题——如果两个任务优先级相同,谁先跑?总不能一直让一个任务霸占CPU吧?

时间片轮转就是解决这个问题的。说白了,就是给每个任务分配一个固定时间片,时间到了就强制切换给下一个同优先级的任务。

我个人习惯把时间片轮转叫做「公平调度」——虽然RTOS里没有绝对的公平,但至少让同优先级的任务都有机会跑一跑。

// 时间片轮转调度实现
void Scheduler_TimeSliceTick(void)
{
    TaskControlBlock_t *pCurrent = g_pCurrentTask;
    
    // 当前任务时间片减1
    if (pCurrent->timeSlice > 0)
    {
        pCurrent->timeSlice--;
    }
    
    // 时间片用完,强制调度
    if (pCurrent->timeSlice == 0)
    {
        // 重置时间片
        pCurrent->timeSlice = DEFAULT_TIME_SLICE;
        
        // 将当前任务移到就绪队列末尾
        MoveToEndOfReadyQueue(pCurrent);
        
        // 触发调度
        Scheduler_Schedule();
    }
}
我的经验:时间片大小怎么定?我一般取系统时钟节拍的整数倍。比如系统节拍是1ms,时间片就设5-10个节拍。太短了切换开销大,太长了轮转效果差。我曾经在一个项目中把时间片设成1个节拍,结果切换开销占了CPU的30%,血泪教训。

时间片轮转和优先级抢占是配合使用的:

  • 不同优先级:优先级抢占生效,高优先级任务随时打断低优先级
  • 相同优先级:时间片轮转生效,大家轮流跑
  • 高优先级任务休眠:低优先级任务才有机会跑

3.4 调度点:什么时候触发调度?

调度器不会自己主动跑,它需要被「叫醒」。这个叫醒的时机,就是调度点。

常见的调度点有这几个:

  1. 任务创建/删除时:任务状态变了,需要重新评估谁该跑
  2. 任务阻塞/唤醒时:比如任务调用 delay() 或等待信号量
  3. 时间片用完时:SysTick 中断里检查时间片
  4. 中断退出时:中断可能唤醒了高优先级任务
  5. 主动让出CPU时:任务调用 yield()

我建议在实现时,把这些调度点统一封装成一个宏或函数:

// 调度点触发宏
#define SCHEDULER_POINT()                    \
    do {                                     \
        if (g_bNeedReschedule)               \
        {                                    \
            g_bNeedReschedule = 0;           \
            Scheduler_Schedule();            \
        }                                    \
    } while(0)

为什么要用宏?因为调度点可能在内核的各个角落被调用,用函数调用会增加栈开销。嗯,嵌入式开发里,能省一点是一点。

3.5 临界区保护:别让调度乱了套

这是整个调度机制里最容易出问题的地方。我见过太多因为临界区没处理好导致的死机、数据错乱。

临界区,就是一段不能被打断的代码。在调度器里,临界区主要保护的是:

  • 就绪队列的操作(增删改查)
  • 任务状态切换
  • 调度器自身的状态

保护临界区最常用的方法就是关中断

// 进入临界区
void EnterCritical(void)
{
    // 保存当前中断状态,然后关中断
    g_criticalNesting++;
    if (g_criticalNesting == 1)
    {
        DisableInterrupts();
    }
}

// 退出临界区
void ExitCritical(void)
{
    g_criticalNesting--;
    if (g_criticalNesting == 0)
    {
        EnableInterrupts();
        // 退出时检查是否需要调度
        SCHEDULER_POINT();
    }
}
注意:临界区嵌套计数非常重要!我曾经在一个项目中忘了加嵌套计数,结果两次 EnterCritical 后一次 ExitCritical 就把中断打开了,导致调度器状态被破坏,系统随机死机。排查了整整两天才找到这个bug。

临界区保护有几个原则:

  1. 尽量短:临界区里只做必要的操作,别在里面做复杂计算
  2. 不能调用可能触发调度的函数:比如在临界区里别调用 delay()
  3. 中断服务程序里也要考虑临界区:中断里操作共享数据时,同样需要保护

3.6 综合实战:一个完整的调度场景

我们来走一遍完整的调度流程,看看这些机制是怎么配合的:

// 假设有三个任务:
// Task_A: 优先级5,负责采集传感器数据
// Task_B: 优先级3,负责数据处理
// Task_C: 优先级5,负责通信

// 1. 系统启动,Task_A 先运行
// 2. Task_A 调用 delay(10ms) 进入阻塞
//    调度点触发,Task_C 开始运行(同优先级时间片轮转)
// 3. 10ms后,Task_A 被唤醒
//    调度点触发,发现 Task_A 优先级5 > Task_C 优先级5?
//    同优先级,不抢占,继续跑 Task_C 的时间片
// 4. Task_C 时间片用完,轮到 Task_A
// 5. 此时中断来了,唤醒 Task_B(优先级3)
//    中断退出时触发调度,Task_B 优先级3 > Task_A 优先级5
//    抢占!Task_B 开始运行
// 6. Task_B 处理完数据,主动 yield()
//    调度点触发,Task_A 继续运行

你发现没?整个调度过程就像一场精心编排的舞蹈——每个任务在合适的时机上场,调度器就是那个指挥家。而临界区保护,就是确保这场舞蹈不会因为意外干扰而乱套。

核心要点总结:
  • 优先级抢占保证实时性,时间片轮转保证公平性
  • 调度点要选对时机,别漏掉也别过度调度
  • 临界区保护是调度器稳定的基石,嵌套计数不能忘
  • 调度器代码要尽量精简,O(1)时间复杂度是基本要求

好了,调度机制的核心内容就这些。下一章我们会深入任务切换的汇编实现,那才是真正考验功底的地方。到时候我会分享一些我在ARM Cortex-M上移植调度器的实战经验,包括那些让你抓狂的坑。