3. 任务调度算法(一):时间片轮转调度(Round-Robin)
调度算法是RTOS的“大脑”。你想想看,多个任务都在那等着跑,谁先上CPU?谁后上?这就是调度器要干的事。
今天咱们先聊最基础、也最直观的一种——时间片轮转调度(Round-Robin,简称RR)。我个人习惯把它叫做“排队轮流上”。
3.1 原理:说白了就是“一人一票,轮流上车”
RR算法的核心思想特别简单:
- 每个任务分配一个固定的时间片(Time Slice),比如5ms、10ms。
- 任务按就绪队列的先后顺序,挨个上CPU跑。
- 时间片用完了,任务还没跑完?强制挂起,换下一个任务上。
- 被换下来的任务排到队尾,等着下一轮。
嗯,这里要注意:RR是抢占式调度。时间片一到,不管任务跑没跑完,调度器直接“抢”走CPU。我在项目中遇到过新手把RR当成协作式调度来写,结果任务死循环,整个系统就挂了。
核心公式: 响应时间 ≈ 时间片 × 就绪任务数
举个例子:假设时间片是10ms,系统里有5个任务在跑。那么最坏情况下,一个任务要等4×10ms=40ms才能再次拿到CPU。这个延迟,就是RR的“公平代价”。
3.2 实现机制:就绪队列 + 定时器中断
RR的实现,说白了就两个东西:
- 就绪队列:FIFO结构,新来的任务插队尾,被换下的也回队尾。
- 系统定时器(SysTick):每个时间片到期触发一次中断,调度器在中断里做上下文切换。
我画了一张流程图,帮你理清整个调度流程:
3.3 优缺点分析
RR不是万能的。我用一张表帮你把它的优缺点说清楚:
| 维度 | 优点 | 缺点 |
|---|---|---|
| 公平性 | 所有任务平等,没有“饿死”现象 | 无法区分任务优先级,紧急任务也得排队 |
| 实现复杂度 | 代码简单,就绪队列+定时器即可 | 时间片大小需要反复调参 |
| 响应时间 | 可预测,最坏情况可计算 | 任务数多时,响应延迟线性增长 |
| 上下文切换 | 切换时机固定,便于分析 | 时间片太短→频繁切换→开销大 |
| 适用场景 | 任务重要性相近的通用系统 | 不适合硬实时、高优先级抢占场景 |
避坑指南: 我曾经在一个电机控制项目里把时间片设成1ms,结果上下文切换占了30%的CPU时间。后来改成5ms,系统才稳定下来。时间片不是越小越好,要结合任务执行时间和切换开销来选。
3.4 实战代码示例:手写一个迷你RR调度器
下面是我写的一个简化版RR调度器。它跑在Cortex-M3/M4上,用SysTick做时间片中断。代码去掉了一些硬件细节,保留了核心逻辑。
/* ============================================================
* 迷你RR调度器核心代码(基于Cortex-M3 SysTick)
* ============================================================ */
#define MAX_TASKS 8
#define TIME_SLICE_MS 10 // 每个任务10ms时间片
typedef struct {
uint32_t *sp; // 栈指针
uint32_t stack[256]; // 任务栈
uint8_t state; // 0=就绪, 1=运行, 2=阻塞
uint32_t slice_remain; // 剩余时间片(ticks)
} TCB_t;
TCB_t task_pool[MAX_TASKS];
volatile int current_task = 0;
int task_count = 0;
/* 创建任务:初始化栈帧,插入就绪队列 */
int task_create(void (*entry)(void *), void *arg) {
if (task_count >= MAX_TASKS) return -1;
TCB_t *tcb = &task_pool[task_count];
uint32_t *stk = &tcb->stack[255];
/* 模拟Cortex-M3压栈顺序:xPSR, PC, LR, R12, R3-R0 */
*(--stk) = 0x01000000; // xPSR
*(--stk) = (uint32_t)entry; // PC
*(--stk) = 0xFFFFFFFD; // LR (返回线程模式)
*(--stk) = 0x12121212; // R12
*(--stk) = 0x03030303; // R3
*(--stk) = 0x02020202; // R2
*(--stk) = 0x01010101; // R1
*(--stk) = (uint32_t)arg; // R0 (参数)
tcb->sp = stk;
tcb->state = 0; // 就绪
tcb->slice_remain = TIME_SLICE_MS;
task_count++;
return task_count - 1;
}
/* SysTick中断服务函数:时间片管理 */
void SysTick_Handler(void) {
TCB_t *tcb = &task_pool[current_task];
tcb->slice_remain--;
if (tcb->slice_remain > 0) {
return; // 时间片还没用完,继续跑
}
/* 时间片用完:保存当前任务上下文,切换到下一个 */
tcb->sp = (uint32_t*)__get_PSP(); // 获取当前栈指针
tcb->state = 0; // 变为就绪
/* 轮转到下一个就绪任务 */
int next = (current_task + 1) % task_count;
while (task_pool[next].state != 0) {
next = (next + 1) % task_count;
if (next == current_task) {
// 所有任务都阻塞?死循环保护
break;
}
}
current_task = next;
task_pool[current_task].state = 1;
task_pool[current_task].slice_remain = TIME_SLICE_MS;
__set_PSP((uint32_t)task_pool[current_task].sp); // 切换栈
}
/* 启动调度器 */
void scheduler_start(void) {
if (task_count == 0) return;
current_task = 0;
task_pool[0].state = 1;
task_pool[0].slice_remain = TIME_SLICE_MS;
/* 配置SysTick:每1ms中断一次 */
SysTick_Config(SystemCoreClock / 1000);
/* 切换到第一个任务的栈 */
__set_PSP((uint32_t)task_pool[0].sp);
__set_CONTROL(0x03); // 使用PSP,特权模式
__ISB();
/* 触发PendSV做第一次上下文切换 */
*(volatile uint32_t *)0xE000ED04 = 0x10000000;
}
个人经验: 这段代码里有个细节——
slice_remain是递减到0才切换。我见过有人用递增比较,每次中断都要做一次比较,浪费几个周期。在嵌入式里,减法到0的硬件标志位判断比比较指令快,能省则省。
3.5 时间片怎么选?我的调参经验
时间片大小是RR调度里最头疼的事。我总结了一个经验公式:
时间片 ≥ 2 × (上下文切换时间 + 任务平均执行时间)
为什么是2倍?因为切换本身有开销,如果时间片比切换时间还短,那CPU全在切任务,实际干活的时间就少了。
我一般这样选:
- I/O密集型任务(比如按键扫描、串口收发):时间片设短一点,5~10ms,提高响应速度。
- 计算密集型任务(比如FFT、PID运算):时间片设长一点,20~50ms,减少切换次数。
- 混合场景:先设10ms,用逻辑分析仪抓一下任务切换频率,再微调。
一句话总结: RR调度就是“公平但平庸”。它保证了每个任务都有机会跑,但没法保证紧急任务优先跑。如果你的系统里任务重要性差不多,RR是最稳妥的选择。