4、事件驱动调度器实战:基于优先级的抢占式调度器实现,协作式调度器与时间片轮转,空闲任务与低功耗钩子函数
好,咱们今天来点硬核的。调度器这东西,说白了就是操作系统的“大脑”,它决定哪个任务能跑、跑多久、什么时候让出来。但在嵌入式世界里,我们往往不需要跑一个完整的 RTOS,而是自己手撸一个轻量级的调度器——既能满足实时性,又能把功耗压到最低。
我个人习惯把调度器分成三类:抢占式、协作式和时间片轮转。这三者各有各的脾气,咱们一个一个来拆。
4.1 基于优先级的抢占式调度器
抢占式调度,说白了就是“高优先级任务来了,低优先级的立刻滚蛋”。我当年做一款工业控制器时,就遇到过因为中断响应不及时导致设备撞机的事故。从那以后,我对抢占式调度就特别上心。
它的核心逻辑很简单:每个任务有一个优先级,调度器在每次系统滴答(SysTick)或者事件触发时,检查当前就绪的最高优先级任务,如果比正在跑的任务优先级高,就强制切换上下文。
抢占式调度核心原则:
- 优先级数字越小,优先级越高(常见做法)
- 任务切换由外部事件或定时器触发
- 被抢占的任务会保存现场,稍后继续执行
- 必须保证临界区不被破坏(关中断保护)
下面是一个极简的抢占式调度器核心代码,基于 Cortex-M 的 PendSV 实现上下文切换:
// 任务控制块
typedef struct {
uint32_t *sp; // 栈指针
uint8_t priority; // 优先级(0最高)
uint8_t state; // 任务状态
void (*task_func)(void *arg);
} TCB_t;
// 就绪任务表(按优先级分组)
TCB_t *ready_table[MAX_PRIORITY];
uint8_t current_priority;
// SysTick 中断中触发调度
void SysTick_Handler(void) {
// 检查是否有更高优先级任务就绪
for (int i = 0; i < current_priority; i++) {
if (ready_table[i] != NULL && ready_table[i]->state == READY) {
// 触发 PendSV 进行上下文切换
SCB->ICSR |= SCB_ICSR_PENDSVSET_Msk;
break;
}
}
}
// PendSV 处理函数(真正的上下文切换)
void PendSV_Handler(void) {
// 保存当前任务上下文
__asm volatile(
"MRS R0, PSP\n"
"STMDB R0!, {R4-R11}\n"
// ... 保存到当前 TCB
);
// 切换到最高优先级就绪任务
current_task = find_highest_ready_task();
// 恢复新任务上下文
__asm volatile(
// ... 从新 TCB 恢复 R4-R11
"MSR PSP, R0\n"
"BX LR\n"
);
}
避坑指南:我曾经在抢占式调度器里犯过一个低级错误——在临界区里调用了调度函数。结果导致中断嵌套混乱,系统直接跑飞。记住:关中断期间绝对不要触发 PendSV,否则你会怀疑人生。
4.2 协作式调度器
协作式调度器就温和多了。它不抢,而是等任务自己主动让出 CPU。每个任务跑完一个“时间片”或者遇到阻塞操作时,主动调用 yield() 把控制权交回去。
你想想看,这种调度器有什么好处?没有临界区问题,不需要复杂的互斥锁,代码简单到令人发指。但缺点也很明显——如果某个任务死循环不 yield,整个系统就卡死了。
我在做低功耗传感器节点时特别喜欢用协作式调度器。因为传感器任务通常很短,跑完就睡,根本不需要抢占。而且协作式调度器的 RAM 占用极低,适合资源受限的 MCU。
// 协作式调度器核心
typedef struct {
void (*task)(void);
uint32_t interval_ticks; // 执行间隔
uint32_t last_run; // 上次执行时间
} CoopTask_t;
CoopTask_t tasks[MAX_TASKS];
uint8_t task_count = 0;
void scheduler_run(void) {
while (1) {
for (int i = 0; i < task_count; i++) {
if (ticks - tasks[i].last_run >= tasks[i].interval_ticks) {
tasks[i].last_run = ticks;
tasks[i].task(); // 执行任务
// 任务执行完后自动回到调度循环
}
}
// 所有任务都跑完了,进入空闲
idle_hook();
}
}
// 任务主动让出 CPU
void yield(void) {
// 在协作式调度器中,yield 就是直接返回
// 因为调度器在循环中调用任务,返回即让出
return;
}
注意:协作式调度器里,任务函数绝对不能阻塞!不能用 while(1) 死等,不能用 delay_ms(1000) 这种阻塞延时。所有等待都必须用状态机或者回调来实现。否则,整个系统就冻住了。
4.3 时间片轮转调度
时间片轮转,说白了就是“大家轮流来,每人分一段固定时间”。每个任务分配一个时间片(比如 10ms),时间到了就强制切换给下一个任务。所有任务优先级相同,公平轮转。
这种调度器最适合什么场景?多个同等重要的周期性任务。比如一个任务读传感器,一个任务刷显示屏,一个任务处理通信。谁也不比谁急,那就一人一段。
| 调度方式 | 切换时机 | 优先级支持 | 实时性 | 代码复杂度 |
|---|---|---|---|---|
| 抢占式 | 事件/定时器触发 | 是 | 高 | 高 |
| 协作式 | 任务主动 yield | 否 | 低 | 低 |
| 时间片轮转 | 时间片耗尽 | 否(同级) | 中 | 中 |
// 时间片轮转调度器
#define TIME_SLICE_MS 10
#define MAX_TASKS 8
typedef struct {
void (*task)(void);
uint32_t remaining_ticks; // 剩余时间片
} RR_Task_t;
RR_Task_t rr_tasks[MAX_TASKS];
uint8_t current_task_index = 0;
void SysTick_Handler(void) {
rr_tasks[current_task_index].remaining_ticks--;
if (rr_tasks[current_task_index].remaining_ticks == 0) {
// 当前任务时间片用完,切换到下一个
current_task_index = (current_task_index + 1) % task_count;
rr_tasks[current_task_index].remaining_ticks = TIME_SLICE_MS;
// 触发上下文切换
SCB->ICSR |= SCB_ICSR_PENDSVSET_Msk;
}
}
4.4 空闲任务与低功耗钩子函数
好了,调度器跑起来了,任务们轮流转。但问题来了——所有任务都跑完了,CPU 在干嘛?答案是:跑空闲任务。
空闲任务就是调度器里优先级最低的那个任务,只有当没有其他任务就绪时才会执行。而空闲任务的最佳归宿,就是让 CPU 睡觉。
我在做一款电池供电的温湿度记录仪时,把空闲任务里塞了一个 WFI() 指令(Wait For Interrupt)。结果整机功耗从 5mA 降到了 20µA。嗯,这就是低功耗钩子函数的威力。
低功耗钩子函数设计要点:
- 在空闲任务中调用
idle_hook() idle_hook()里执行WFI或WFE指令- 进入休眠前关闭不需要的外设时钟
- 配置唤醒源(RTC、GPIO中断、定时器)
- 唤醒后快速恢复时钟和外设
// 空闲任务与低功耗钩子
void idle_hook(void) {
// 进入休眠前,关闭不用的外设
RCC->AHB1ENR &= ~(RCC_AHB1ENR_GPIOBEN | RCC_AHB1ENR_GPIOCEN);
// 进入深度睡眠模式
PWR->CR |= PWR_CR_LPDS;
SCB->SCR |= SCB_SCR_SLEEPDEEP_Msk;
// 执行 WFI 指令
__WFI();
// 醒来后,恢复外设时钟
RCC->AHB1ENR |= (RCC_AHB1ENR_GPIOBEN | RCC_AHB1ENR_GPIOCEN);
}
// 调度器主循环中调用
void scheduler_main(void) {
while (1) {
// 执行就绪任务
for (int i = 0; i < task_count; i++) {
if (task_ready[i]) {
task_ready[i] = 0;
task_table[i]();
}
}
// 所有任务执行完毕,进入空闲
idle_hook();
}
}
我的经验:低功耗钩子函数里不要做复杂操作。我曾经在 idle_hook 里加了一段日志打印,结果每次休眠前都要等串口发完数据,功耗反而更高了。记住:空闲任务越短越好,最好只有一条 WFI 指令。
4.5 三种调度器与低功耗的协同设计
咱们把三种调度器和低功耗钩子串起来,看看整体架构长什么样。下面这张图是我自己画的一个事件驱动调度器框架,你可以把它当作一个参考模板。
这张图把整个流程串起来了:事件源(定时器、中断、软件事件)触发后,事件进入队列,调度器根据优先级或时间片策略选择任务执行。当所有任务都跑完了,自动进入空闲任务,执行 WFI 指令让 CPU 休眠。下一个事件到来时,中断唤醒 CPU,调度器重新开始工作。
协同设计的关键指标:
- 唤醒延迟:从事件发生到任务开始执行的时间,抢占式最短
- 休眠深度:空闲任务中进入的休眠模式(睡眠/停止/待机),越深功耗越低,但唤醒越慢
- 事件吞吐量:单位时间内能处理的事件数量,时间片轮转在高负载下表现更好
- 上下文切换开销:抢占式最高,协作式几乎为零
嗯,到这里,三种调度器的实现和低功耗协同设计就讲完了。我个人建议你在实际项目中,先评估任务的实时性要求和功耗预算,再选择调度器类型。如果拿不准,就从协作式开始,它最简单、最省电、也最不容易出 bug。