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的「点火」动作。它从第一个任务开始,然后不断切换。嗯,这里要注意,启动流程有严格的顺序。
调度器启动步骤:
- 初始化系统时钟、中断控制器
- 创建至少一个任务(通常是空闲任务)
- 设置第一个任务的栈指针和PC指针
- 触发PendSV异常,进入任务上下文
- 开始调度循环
// 调度器启动核心代码
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"
);
}
我的经验:调试调度器时,先跑一个任务,确认切换正常。再加第二个任务。别一上来就创建十几个任务,出了问题你根本不知道是哪个环节的锅。
最后说一句,调度算法的选择没有银弹。时间片轮转适合公平场景,优先级抢占适合实时场景,协作式适合简单场景。我建议你根据项目需求,甚至可以混合使用。比如高优先级任务用抢占式,低优先级任务用时间片轮转。嗯,灵活运用才是王道。