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 三种调度器与低功耗的协同设计

咱们把三种调度器和低功耗钩子串起来,看看整体架构长什么样。下面这张图是我自己画的一个事件驱动调度器框架,你可以把它当作一个参考模板。

事件驱动调度器与低功耗协同架构 定时器事件 外部中断事件 软件事件 事件队列(FIFO / 优先级队列) 事件驱动调度器核心 抢占式 / 协作式 / 时间片轮转 任务1(高优先级) 任务2(中优先级) 任务3(低优先级) 空闲任务 → WFI(低功耗休眠) 中断唤醒 说明: 事件驱动调度器 空闲时自动休眠 事件唤醒后继续调度

这张图把整个流程串起来了:事件源(定时器、中断、软件事件)触发后,事件进入队列,调度器根据优先级或时间片策略选择任务执行。当所有任务都跑完了,自动进入空闲任务,执行 WFI 指令让 CPU 休眠。下一个事件到来时,中断唤醒 CPU,调度器重新开始工作。

协同设计的关键指标:

  • 唤醒延迟:从事件发生到任务开始执行的时间,抢占式最短
  • 休眠深度:空闲任务中进入的休眠模式(睡眠/停止/待机),越深功耗越低,但唤醒越慢
  • 事件吞吐量:单位时间内能处理的事件数量,时间片轮转在高负载下表现更好
  • 上下文切换开销:抢占式最高,协作式几乎为零

嗯,到这里,三种调度器的实现和低功耗协同设计就讲完了。我个人建议你在实际项目中,先评估任务的实时性要求和功耗预算,再选择调度器类型。如果拿不准,就从协作式开始,它最简单、最省电、也最不容易出 bug。