第三章 任务调度机制:调度器工作原理、优先级抢占调度算法实现、时间片轮转调度、调度点与临界区保护
3.1 调度器到底在干什么?
说实话,很多初学者把调度器想得太神秘了。我当年刚接触RTOS时,也觉得调度器是个高深莫测的东西。后来自己动手写了一个,才发现——说白了,调度器就是个「选人干活」的机制。
你想想看,系统里同时有多个任务在等待运行。谁先跑?谁后跑?跑多久?这就是调度器要解决的问题。我习惯把调度器比作一个「交通指挥员」——它不负责具体干活,但它决定谁在什么时候通过路口。
调度器的工作流程其实很简单:
- 任务就绪队列:所有能跑的任务都在这里排队
- 当前任务指针:指向正在运行的那个任务
- 调度决策:根据某种规则,选出下一个要跑的任务
- 上下文切换:保存当前任务的状态,恢复新任务的状态
嗯,这里要注意——调度器本身不是一个独立的任务,它是在特定时机被调用的函数。这个时机,就是我们后面要讲的「调度点」。
3.2 优先级抢占调度:谁急谁先跑
这是RTOS最核心的调度算法。我在项目中遇到过很多次,新手最容易搞混的就是「抢占」和「时间片」的区别。
优先级抢占调度,核心思想就一句话:高优先级的任务一旦就绪,立即打断低优先级任务的执行。
来看一个典型的实现代码:
// 调度器核心函数
void Scheduler_Schedule(void)
{
TaskControlBlock_t *pNewTask;
TaskControlBlock_t *pCurrentTask = g_pCurrentTask;
// 找到就绪队列中优先级最高的任务
pNewTask = GetHighestPriorityTask();
// 如果新任务不是当前任务,就切换
if (pNewTask != pCurrentTask)
{
// 保存当前任务上下文
if (pCurrentTask != NULL)
{
SaveContext(&pCurrentTask->stackPointer);
pCurrentTask->state = TASK_READY;
}
// 恢复新任务上下文
pNewTask->state = TASK_RUNNING;
g_pCurrentTask = pNewTask;
RestoreContext(&pNewTask->stackPointer);
}
}
这段代码看起来简单,但有几个坑我踩过:
- GetHighestPriorityTask() 必须保证O(1)时间复杂度,否则调度开销会拖死系统。我早期用链表遍历,结果在128个任务时调度延迟直接爆表
- SaveContext/RestoreContext 必须用汇编实现,C语言无法直接操作CPU寄存器
- 切换前要关中断,否则保存到一半被中断打断,上下文就乱了
优先级抢占的典型场景是这样的:
| 任务 | 优先级 | 行为 |
|---|---|---|
| Task_Low | 10(低) | 正在运行,突然被抢占 |
| Task_High | 5(高) | 从外设中断中唤醒,立即抢占 |
| Task_Mid | 8(中) | 永远没机会跑,因为Task_High一直占着CPU |
你发现没?Task_Mid 永远跑不了。这就是优先级反转的雏形。我建议在设计任务优先级时,一定要留出足够的余量,别把所有任务都挤在相邻优先级上。
3.3 时间片轮转调度:人人有份
优先级抢占有个问题——如果两个任务优先级相同,谁先跑?总不能一直让一个任务霸占CPU吧?
时间片轮转就是解决这个问题的。说白了,就是给每个任务分配一个固定时间片,时间到了就强制切换给下一个同优先级的任务。
我个人习惯把时间片轮转叫做「公平调度」——虽然RTOS里没有绝对的公平,但至少让同优先级的任务都有机会跑一跑。
// 时间片轮转调度实现
void Scheduler_TimeSliceTick(void)
{
TaskControlBlock_t *pCurrent = g_pCurrentTask;
// 当前任务时间片减1
if (pCurrent->timeSlice > 0)
{
pCurrent->timeSlice--;
}
// 时间片用完,强制调度
if (pCurrent->timeSlice == 0)
{
// 重置时间片
pCurrent->timeSlice = DEFAULT_TIME_SLICE;
// 将当前任务移到就绪队列末尾
MoveToEndOfReadyQueue(pCurrent);
// 触发调度
Scheduler_Schedule();
}
}
时间片轮转和优先级抢占是配合使用的:
- 不同优先级:优先级抢占生效,高优先级任务随时打断低优先级
- 相同优先级:时间片轮转生效,大家轮流跑
- 高优先级任务休眠:低优先级任务才有机会跑
3.4 调度点:什么时候触发调度?
调度器不会自己主动跑,它需要被「叫醒」。这个叫醒的时机,就是调度点。
常见的调度点有这几个:
- 任务创建/删除时:任务状态变了,需要重新评估谁该跑
- 任务阻塞/唤醒时:比如任务调用 delay() 或等待信号量
- 时间片用完时:SysTick 中断里检查时间片
- 中断退出时:中断可能唤醒了高优先级任务
- 主动让出CPU时:任务调用 yield()
我建议在实现时,把这些调度点统一封装成一个宏或函数:
// 调度点触发宏
#define SCHEDULER_POINT() \
do { \
if (g_bNeedReschedule) \
{ \
g_bNeedReschedule = 0; \
Scheduler_Schedule(); \
} \
} while(0)
为什么要用宏?因为调度点可能在内核的各个角落被调用,用函数调用会增加栈开销。嗯,嵌入式开发里,能省一点是一点。
3.5 临界区保护:别让调度乱了套
这是整个调度机制里最容易出问题的地方。我见过太多因为临界区没处理好导致的死机、数据错乱。
临界区,就是一段不能被打断的代码。在调度器里,临界区主要保护的是:
- 就绪队列的操作(增删改查)
- 任务状态切换
- 调度器自身的状态
保护临界区最常用的方法就是关中断:
// 进入临界区
void EnterCritical(void)
{
// 保存当前中断状态,然后关中断
g_criticalNesting++;
if (g_criticalNesting == 1)
{
DisableInterrupts();
}
}
// 退出临界区
void ExitCritical(void)
{
g_criticalNesting--;
if (g_criticalNesting == 0)
{
EnableInterrupts();
// 退出时检查是否需要调度
SCHEDULER_POINT();
}
}
临界区保护有几个原则:
- 尽量短:临界区里只做必要的操作,别在里面做复杂计算
- 不能调用可能触发调度的函数:比如在临界区里别调用 delay()
- 中断服务程序里也要考虑临界区:中断里操作共享数据时,同样需要保护
3.6 综合实战:一个完整的调度场景
我们来走一遍完整的调度流程,看看这些机制是怎么配合的:
// 假设有三个任务:
// Task_A: 优先级5,负责采集传感器数据
// Task_B: 优先级3,负责数据处理
// Task_C: 优先级5,负责通信
// 1. 系统启动,Task_A 先运行
// 2. Task_A 调用 delay(10ms) 进入阻塞
// 调度点触发,Task_C 开始运行(同优先级时间片轮转)
// 3. 10ms后,Task_A 被唤醒
// 调度点触发,发现 Task_A 优先级5 > Task_C 优先级5?
// 同优先级,不抢占,继续跑 Task_C 的时间片
// 4. Task_C 时间片用完,轮到 Task_A
// 5. 此时中断来了,唤醒 Task_B(优先级3)
// 中断退出时触发调度,Task_B 优先级3 > Task_A 优先级5
// 抢占!Task_B 开始运行
// 6. Task_B 处理完数据,主动 yield()
// 调度点触发,Task_A 继续运行
你发现没?整个调度过程就像一场精心编排的舞蹈——每个任务在合适的时机上场,调度器就是那个指挥家。而临界区保护,就是确保这场舞蹈不会因为意外干扰而乱套。
- 优先级抢占保证实时性,时间片轮转保证公平性
- 调度点要选对时机,别漏掉也别过度调度
- 临界区保护是调度器稳定的基石,嵌套计数不能忘
- 调度器代码要尽量精简,O(1)时间复杂度是基本要求
好了,调度机制的核心内容就这些。下一章我们会深入任务切换的汇编实现,那才是真正考验功底的地方。到时候我会分享一些我在ARM Cortex-M上移植调度器的实战经验,包括那些让你抓狂的坑。