3. 实时操作系统(RTOS)原理:任务调度策略与上下文切换

各位同学,咱们今天聊聊RTOS的核心——任务调度。说实话,我见过太多工程师把RTOS当成裸机轮询来用,结果系统跑起来各种抖。其实,搞懂调度策略,你的嵌入式系统性能能翻一倍。

3.1 任务调度策略:优先级抢占 vs 时间片轮转

RTOS调度策略,说白了就是决定「下一个该谁跑」。我习惯把它分成两类:优先级抢占式时间片轮转式。大部分商用RTOS(比如FreeRTOS、uC/OS-III)都支持混合使用。

3.1.1 优先级抢占式调度

这个机制很简单:高优先级的任务就绪后,立刻抢走CPU。我在项目中遇到过一个问题:一个低优先级的任务正在写Flash,高优先级的中断来了,结果Flash操作被中断,数据写了一半——嗯,这就是典型的优先级反转问题。

优先级抢占的核心规则:

  • 每个任务有一个固定的优先级(0~255,数字越小优先级越高)
  • 就绪队列中,优先级最高的任务获得CPU
  • 高优先级任务就绪时,立即抢占当前运行的低优先级任务
  • 同优先级任务之间,按FIFO顺序执行(除非启用时间片)

关键点:优先级抢占能保证最紧急的任务得到最快响应。但要注意,如果高优先级任务一直不阻塞,低优先级任务会「饿死」——我早期做车载ECU时就踩过这个坑。

3.1.2 时间片轮转调度

时间片轮转,就是让同优先级的任务轮流跑。每个任务分到一个时间片(比如1ms),时间到了就切换到下一个。你想想看,如果不用时间片,两个同优先级任务,一个死循环,另一个永远跑不了。

时间片轮转的典型配置:

// 以FreeRTOS为例,配置时间片
#define configUSE_PREEMPTION    1   // 启用抢占
#define configUSE_TIME_SLICING  1   // 启用时间片
#define configTICK_RATE_HZ      1000 // 系统时钟节拍,1ms一个tick

// 创建两个同优先级任务
xTaskCreate(vTask1, "Task1", 256, NULL, 2, NULL);
xTaskCreate(vTask2, "Task2", 256, NULL, 2, NULL);

我个人建议:时间片只用于同优先级任务。不同优先级之间,用抢占就够了。时间片开太多,上下文切换开销会暴涨。

3.2 任务状态机:五种状态的流转

RTOS里的任务,不是一直在跑的。它有五种状态:运行态、就绪态、阻塞态、挂起态、终止态。我画过一张状态机图贴在工位上,每次调试都看两眼。

状态 含义 典型触发条件
运行态(Running) 正在使用CPU 调度器选中该任务
就绪态(Ready) 可以运行,但CPU被别的任务占着 高优先级任务释放CPU
阻塞态(Blocked) 等待某个事件(信号量、队列、延时) 调用vTaskDelay()、获取信号量失败
挂起态(Suspended) 被暂停,不参与调度 调用vTaskSuspend()
终止态(Terminated) 任务执行完毕或被删除 任务函数返回、调用vTaskDelete()

状态流转的典型场景:

  1. 任务A正在运行(运行态)
  2. 任务A调用vTaskDelay(10) → 进入阻塞态
  3. 调度器从就绪队列中选任务B → 任务B进入运行态
  4. 10ms后,任务A延时结束 → 回到就绪态
  5. 如果任务B优先级低于任务A → 任务A立即抢占,回到运行态

避坑指南:我曾经在调试时发现一个任务永远不跑,查了半天,原来是任务函数里没有调用任何阻塞API(比如vTaskDelay),导致它一直在运行态,其他任务根本没机会。记住:任务里一定要有阻塞点,否则就是死循环。

3.3 上下文切换开销分析

上下文切换,就是保存当前任务的寄存器、堆栈指针,再恢复下一个任务的。这个过程有开销,而且不小。我测过一个Cortex-M4平台,一次上下文切换大约需要1.5μs——如果系统时钟节拍是1ms,那1秒内切换1000次,光切换就浪费了1.5ms。

3.3.1 上下文切换到底干了什么?

以ARM Cortex-M为例,一次完整的上下文切换包括:

  • 硬件自动保存:R0-R3、R12、LR、PC、xPSR(共8个寄存器,约32字节)
  • 软件手动保存:R4-R11(共8个寄存器,约32字节)
  • 切换堆栈指针:从当前任务的PSP切换到下一个任务的PSP
  • 恢复下一个任务的寄存器:反向操作

说白了,一次切换要搬动大约64字节的数据,再加上函数调用和返回的开销。为什么?因为CPU要保证任务切换后,代码能「无缝衔接」。

3.3.2 如何测量上下文切换时间?

我常用的方法:在切换函数入口和出口各拉一个GPIO,用示波器看脉宽。

// 伪代码:测量上下文切换时间
void PendSV_Handler(void) {
    GPIO_SetPin(GPIO_PIN_0, HIGH);  // 开始切换
    
    // 保存当前任务上下文
    __asm volatile("MRS R0, PSP");
    __asm volatile("STMDB R0!, {R4-R11}");
    
    // 切换任务控制块指针
    current_tcb = next_tcb;
    
    // 恢复下一个任务上下文
    __asm volatile("LDMIA R0!, {R4-R11}");
    __asm volatile("MSR PSP, R0");
    
    GPIO_SetPin(GPIO_PIN_0, LOW);   // 切换结束
}

用示波器量一下高电平的宽度,就是上下文切换时间。我测过不同RTOS的数据:

RTOS 平台 切换时间(μs) 备注
FreeRTOS Cortex-M4 @ 168MHz 1.2~1.8 默认配置
uC/OS-III Cortex-M4 @ 168MHz 1.5~2.1 含钩子函数
RT-Thread Cortex-M4 @ 168MHz 1.3~1.9 开启线程栈检查

3.3.3 优化上下文切换开销

我总结了三条实战经验:

  1. 减少任务数量:任务越多,切换越频繁。能用信号量解决的,别开新任务。
  2. 合理设置时间片:时间片别太短(比如100μs),否则大部分CPU时间都花在切换上了。
  3. 关中断要谨慎:关中断期间不会发生上下文切换,但关太久会影响实时性。我一般控制在10μs以内。

警告:千万不要在中断服务函数里做上下文切换!我曾经见过有人直接在ISR里调用taskYIELD(),结果导致调度器状态混乱。正确的做法是:在ISR中只设置标志位,让PendSV异常来处理切换。

3.4 实战建议:如何选择调度策略?

最后,我给大家一个选择指南:

  • 强实时性要求(如刹车控制):用纯优先级抢占,高优先级任务必须能立即响应
  • 多任务均衡(如仪表盘显示):同优先级用时间片轮转,保证每个任务都能分到CPU
  • 混合场景(如车载ECU):高优先级任务用抢占,低优先级任务用时间片

嗯,调度策略这块就讲到这里。下一章咱们聊聊中断管理——那才是RTOS实时性的真正命门。