3. 嵌入式系统架构与RTOS选型

调度器,说白了就是 RTOS 的大脑。它决定「下一个该谁跑」。很多初学者觉得调度很神秘,其实拆开来看,核心就三件事:什么时候切、怎么切、切给谁。

我早年在一个工业控制项目里,就因为调度点没设计好,导致电机控制任务频繁丢步。那次排查了整整三天,最后发现是 Tick 中断频率和任务挂起逻辑打架了。嗯,从那以后我对调度器的敬畏心就上来了。

调度器的工作原理

调度器本质上是一个优先级驱动的状态机。它维护一张「就绪任务表」,每次调度发生时,从表中挑出优先级最高的任务,把 CPU 交给它。

核心调度逻辑(伪代码):

void Scheduler(void) {
    // 1. 关中断,保护就绪表
    EnterCritical();

    // 2. 从就绪表中找到最高优先级任务
    task_t *next = GetHighestReadyTask();

    // 3. 如果 next 不是当前任务,就切换
    if (next != current_task) {
        // 保存当前任务上下文
        // 恢复 next 任务上下文
        ContextSwitch(current_task, next);
        current_task = next;
    }

    // 4. 开中断
    ExitCritical();
}

你想想看,这个函数每次执行的时间必须极短——微秒级。我在一个航空电子项目中,调度器本身的开销被严格限制在 5 微秒以内,超过就算故障。

调度器组件 作用 典型实现
就绪表 记录所有可运行的任务 位图 + 链表(uC/OS-III 风格)
优先级决策 选出最高优先级任务 查找前导零指令(CLZ)
上下文切换 保存/恢复寄存器现场 PendSV 异常(Cortex-M)

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

调度不会凭空发生。必须有「事件」触发调度器去查就绪表。这些事件就是调度点。常见的调度点有三种:

1. Tick 中断

系统定时器周期性产生中断,每次 Tick 中断里,内核会:

  • 更新时间片计数
  • 检查是否有任务延时到期
  • 如果到期任务优先级更高,触发调度

我个人习惯把 Tick 频率设在 100-1000 Hz 之间。频率太低,任务响应慢;频率太高,中断开销吃掉 CPU。我曾经在一个项目中把 Tick 设到 10 kHz,结果调度器本身占了 30% 的 CPU——得不偿失。

经验值参考:

  • 低功耗 IoT 设备:100 Hz(省电优先)
  • 工业控制:500-1000 Hz(响应优先)
  • 音频处理:1000-2000 Hz(低抖动需求)

2. 任务主动挂起

任务调用 delay()、wait()、pend() 等 API 时,会主动让出 CPU。这是最干净的调度点——任务自己知道现在不需要跑了。

void Task_ReadSensor(void *arg) {
    while (1) {
        sensor_data = ReadADC();
        Queue_Send(&data_queue, sensor_data);
        // 主动挂起,让出 CPU
        Task_Delay(10);  // 挂起 10 个 Tick
    }
}

注意:主动挂起时,任务状态从「就绪」变为「阻塞」。调度器会立刻重新选一个任务来跑。

3. 中断退出时

中断服务程序(ISR)执行完毕后,如果它唤醒了一个更高优先级的任务,调度器会在中断返回前做一次调度。这就是「中断级调度」。

我曾经踩过的坑: 在 ISR 里调用 Task_Delay()。这是绝对禁止的!ISR 里不能阻塞,只能发信号或消息。否则调度器会崩溃,系统直接 HardFault。

上下文切换机制

上下文切换,就是「保存现场 + 恢复现场」。听起来简单,但实现细节极其敏感。

在 Cortex-M 内核上,上下文切换通常借助 PendSV 异常 来完成。为什么不用 SVC?因为 PendSV 优先级最低,可以等所有高优先级中断处理完再执行,避免抢占冲突。

上下文切换的典型步骤:

  1. 触发 PendSV 异常(写 ICSR 寄存器)
  2. 硬件自动压栈:xPSR、PC、LR、R12、R3-R0
  3. 软件手动压栈:R4-R11、LR(EXC_RETURN)
  4. 更新当前任务的 SP 指针到 TCB
  5. 从 TCB 中恢复下一个任务的 SP 指针
  6. 软件手动出栈:R4-R11、LR
  7. 硬件自动出栈:R0-R3、R12、LR、PC、xPSR
  8. 返回任务模式,继续执行

我建议你记住一个数字:上下文切换时间。在 72 MHz 的 STM32F103 上,一次完整的切换大约 1-2 微秒。如果超过 5 微秒,说明你的压栈/出栈代码有优化空间。

调度点与上下文切换的关系图

下面这张图展示了调度点如何触发上下文切换的完整流程:

调度点与上下文切换流程 调度点与上下文切换流程 Tick 中断 任务主动挂起 中断退出 调度器:选最高优先级任务 需要切换? 继续当前任务 上下文切换 保存当前任务现场 → 恢复新任务现场 恢复新任务执行 切换时间:1-5 µs(典型值)

调度延迟:一个容易被忽视的指标

调度延迟 = 从调度点触发到新任务开始执行的时间。它包括:

  • 中断响应时间(硬件延迟)
  • 调度器决策时间
  • 上下文切换时间

我见过一个项目,调度延迟高达 50 微秒,原因是 Tick 中断里做了大量浮点运算。后来把浮点运算挪到任务里,延迟降到 3 微秒。所以,中断里只做最必要的事,这是铁律。

快速自查清单:

  • ☐ Tick 中断处理时间 < 10% 的 Tick 周期
  • ☐ 上下文切换时间 < 5 µs
  • ☐ 没有在 ISR 里调用阻塞 API
  • ☐ 就绪表查找使用位图或硬件指令

调度器的工作原理,说白了就是「事件驱动 + 优先级裁决」。调度点决定了系统何时响应,上下文切换决定了响应有多快。这两者配合好了,你的 RTOS 才能跑得稳、跑得快。