4、调度算法实现:基于优先级的抢占式调度、时间片轮转调度、空闲任务设计、调度点与上下文切换
调度算法,说白了就是操作系统里那个「谁先上CPU干活」的裁判。我做了这么多年RTOS内核,发现很多初学者一上来就盯着调度算法看,觉得这东西高深莫测。其实没那么玄乎,你把它想成一个会议室里只有一个座位,一堆人等着用,你得有个规矩决定谁先坐、坐多久。
今天咱们就把这摊事彻底讲透。我会从最基础的优先级抢占式调度讲起,再到时间片轮转,最后聊聊空闲任务和上下文切换那些坑。嗯,都是我在项目里踩过的坑。
4.1 基于优先级的抢占式调度
先说说最常见的调度方式——优先级抢占式调度。它的核心逻辑就一句话:谁优先级高,谁先跑。
但这里有个细节很多人会忽略:什么时候检查优先级?不是随时都在检查,那样CPU就光干这事了。而是在特定的「调度点」才检查。调度点有哪些?我列一下:
- 任务创建时
- 任务主动让出CPU(比如调用delay或等待信号量)
- 中断服务程序退出时
- 时间片用完时
我个人习惯把调度点设计成统一的入口函数,比如叫 schedule()。这样代码结构清晰,调试也方便。
核心要点:抢占式调度不是「随时抢占」,而是「在调度点检查是否需要抢占」。这个区别很重要,我见过有人把调度器写成了死循环,CPU全耗在检查上了。
举个例子,假设有三个任务:TaskA(优先级5)、TaskB(优先级3)、TaskC(优先级1)。数字越小优先级越高。TaskC正在运行,突然TaskA就绪了。这时候不会立刻切换,而是等到下一个调度点,比如Tick中断到来,调度器发现TaskA优先级更高,于是保存TaskC的上下文,加载TaskA的上下文,切换过去。
我曾经在一个项目中遇到过这样的问题:一个高优先级任务频繁就绪,导致低优先级任务永远得不到CPU。这就是所谓的「优先级反转」和「饥饿」问题。解决办法?后面章节会讲,这里先留个悬念。
4.2 时间片轮转调度
优先级抢占式调度有个天然缺陷:如果两个任务优先级相同,谁先跑?按优先级调度的话,先就绪的先跑,但跑多久?如果不做限制,它可能一直占着CPU不放。
这时候就需要时间片轮转调度了。说白了就是:同等优先级的任务,每人分一个时间片,轮流上CPU。
时间片多长合适?我一般设成系统Tick周期的整数倍,比如5个Tick。太短了切换开销大,太长了实时性差。你想想看,如果时间片是100ms,一个紧急任务最多要等100ms才能上CPU,这还叫实时系统吗?
我的经验:时间片通常设为1~10个Tick。对于大多数MCU应用,5个Tick(假设Tick=1ms,就是5ms)是个不错的起点。你可以根据实际负载调整。
时间片轮转的实现其实不复杂。每个任务控制块(TCB)里加一个字段,比如 time_slice,记录剩余时间片。每次Tick中断到来,当前任务的时间片减1。减到0了,就触发调度,把CPU让给同优先级的下一个任务。
代码大概长这样:
// Tick中断处理函数
void tick_handler(void) {
current_task->time_slice--;
if (current_task->time_slice == 0) {
// 时间片用完,触发调度
need_schedule = 1;
}
// 其他Tick相关处理...
}
注意,这里只是设置了一个标志 need_schedule,真正的上下文切换是在中断退出时做的。为什么?因为中断里直接做切换容易出问题,后面会细说。
4.3 空闲任务设计
系统里所有任务都阻塞了怎么办?比如都在等信号量、等消息队列。这时候CPU不能闲着,得有个「兜底」的任务——空闲任务。
空闲任务的设计其实很有讲究。很多人觉得空闲任务就是死循环,啥也不干。嗯,从功能上讲确实是这样,但实际项目中,空闲任务往往承担着重要职责:
- 统计CPU利用率:记录空闲任务运行的时间占比,反过来就是CPU负载
- 低功耗管理:空闲时让CPU进入休眠模式,省电
- 系统健康检查:比如看门狗喂狗、内存碎片整理
我一般把空闲任务的优先级设成最低,通常是255(假设优先级范围0~255)。这样只要有其他任务就绪,空闲任务立刻被抢走CPU。
注意:空闲任务里不能调用任何可能导致阻塞的API,比如等待信号量、延时等。否则系统就死锁了。我曾经见过有人把延时函数写进了空闲任务,结果整个系统卡死,查了两天才找到原因。
空闲任务的典型实现:
void idle_task(void *param) {
while (1) {
// 统计CPU空闲时间
idle_count++;
// 进入低功耗模式(可选)
// __WFI(); // ARM Cortex-M的等待中断指令
}
}
4.4 调度点与上下文切换
调度点,就是调度器被触发执行的地方。前面提到了几个,这里再系统梳理一下:
| 调度点 | 触发条件 | 说明 |
|---|---|---|
| 任务创建 | 新任务就绪 | 如果新任务优先级高于当前任务,立即切换 |
| 任务主动让出 | 调用delay、等待资源 | 当前任务主动放弃CPU |
| 中断退出 | 中断服务程序结束 | 检查是否有更高优先级任务就绪 |
| 时间片用完 | Tick中断 | 同优先级任务轮转 |
上下文切换,就是保存当前任务的寄存器状态,恢复下一个任务的寄存器状态。这是调度器最核心、也最容易出bug的地方。
我习惯把上下文切换分成两步:
- 保存现场:把当前任务的CPU寄存器压入栈中,栈指针保存到TCB
- 恢复现场:从下一个任务的TCB取出栈指针,弹出寄存器,然后跳转执行
这部分通常用汇编实现,因为要直接操作CPU寄存器。C语言做不到这么精细。举个例子,ARM Cortex-M的PendSV中断就是专门做上下文切换的:
; PendSV中断处理函数
PendSV_Handler:
; 保存当前任务上下文
MRS R0, PSP ; 获取当前进程栈指针
STMDB R0!, {R4-R11} ; 保存R4~R11到栈中
LDR R1, =current_tcb
LDR R2, [R1]
STR R0, [R2] ; 更新TCB中的栈指针
; 选择下一个任务
BL select_next_task
; 恢复下一个任务上下文
LDR R1, =current_tcb
LDR R2, [R1]
LDR R0, [R2] ; 获取新任务的栈指针
LDMIA R0!, {R4-R11} ; 恢复R4~R11
MSR PSP, R0 ; 更新进程栈指针
BX LR ; 返回,自动恢复其他寄存器
避坑指南:我曾经在上下文切换时忘记保存R4~R11,结果任务切换后数据全乱了。记住,C编译器默认使用R0~R3作为临时寄存器,R4~R11是调用者保存的,必须手动处理。
还有一个容易忽略的点:中断嵌套时的上下文切换。如果在中断里触发了调度,不能立刻切换,要等到所有中断都退出后再做。否则中断嵌套层数一多,栈就乱了。解决办法是用PendSV做「延迟切换」——在中断里只设置标志,真正的切换在PendSV里完成,而PendSV的优先级设成最低。
嗯,说到这,调度算法的核心内容基本就这些了。你想想看,其实调度器没那么神秘,无非就是「谁优先级高谁先跑,同优先级轮流跑,没人跑就空闲跑」。但每个环节的细节处理,才是真正考验功力的地方。
下一章咱们聊聊任务同步与通信,信号量、互斥量、消息队列这些。到时候我会分享一个我在无人机项目里遇到的优先级反转案例,保证让你印象深刻。