4. 调度算法实战:时间片轮转与优先级抢占

调度算法,说白了就是决定“下一个该谁跑”的规则。我见过不少新手,任务一多就乱套,其实根子就在调度策略没选对。今天咱们把两种最经典的算法掰开揉碎讲清楚。

4.1 时间片轮转(Round-Robin)

时间片轮转,我习惯叫它“排队上车法”。每个任务分一个固定时间片,轮流执行。谁也别插队,谁也别多占。

核心思想:就绪队列中的每个任务,按顺序轮流获得一个固定长度的时间片。时间片用完,强制切换给下一个。

4.1.1 时间片大小怎么定?

这个很关键。时间片太短,切换开销占比大;太长,又失去了轮转的意义。我在一个电机控制项目里踩过坑——时间片设了5ms,结果切换开销占了CPU的15%,系统响应反而变慢。

场景 推荐时间片 说明
高实时性(按键、通信) 1~5 ms 响应快,但切换频繁
普通控制(传感器采集) 10~50 ms 平衡响应与开销
批量处理(数据记录) 50~200 ms 减少切换,提高吞吐

我的经验:时间片至少是系统时钟节拍的整数倍。比如节拍是1ms,时间片可以设10ms、20ms。否则计时会不准。

4.1.2 代码实现

下面是一个简化版的时间片轮转调度器。我故意去掉了复杂的数据结构,让你看清本质。

/* 任务控制块 */
typedef struct {
    void (*task_func)(void);
    uint32_t remaining_ticks;  /* 剩余时间片 */
    uint8_t  state;            /* 0:就绪 1:运行 2:阻塞 */
} TCB_t;

TCB_t task_table[MAX_TASKS];
uint8_t current_task = 0;

/* 系统时钟节拍中断 */
void SysTick_Handler(void) {
    /* 递减当前任务的时间片 */
    if (task_table[current_task].state == 1) {
        task_table[current_task].remaining_ticks--;
    }
    
    /* 时间片用完,触发调度 */
    if (task_table[current_task].remaining_ticks == 0) {
        schedule();
    }
}

/* 调度器:轮转切换 */
void schedule(void) {
    uint8_t next = current_task;
    
    /* 找下一个就绪任务 */
    do {
        next = (next + 1) % MAX_TASKS;
        if (task_table[next].state == 0) {
            break;
        }
    } while (next != current_task);
    
    /* 切换任务 */
    if (next != current_task) {
        task_table[current_task].state = 0;
        current_task = next;
        task_table[current_task].state = 1;
        task_table[current_task].remaining_ticks = TIME_SLICE;
        
        /* 这里触发上下文切换(通常用 PendSV) */
        trigger_context_switch();
    }
}

注意:上面的代码是示意用的。实际项目中,schedule() 不能在中断里直接调用,否则会破坏中断嵌套。我一般用 PendSV 做“延迟切换”。

4.2 优先级抢占(Preemptive)

优先级抢占,说白了就是“急事先办”。高优先级的任务随时可以打断低优先级的任务。你想想看,如果火灾报警器和LED闪烁抢CPU,谁该先跑?

4.2.1 抢占的触发条件

抢占不是随便发生的。我总结了一下,主要有三种情况:

  1. 高优先级任务就绪:比如外部中断唤醒了一个高优先级任务。
  2. 时间片用完:当前任务时间片耗尽,调度器重新评估优先级。
  3. 任务主动让出:任务调用 task_yield() 或进入阻塞。

4.2.2 优先级怎么分配?

这个没有标准答案。我个人的习惯是:

  • 0~31:留给硬实时任务(中断、通信、控制回路)
  • 32~63:普通周期性任务(传感器采集、状态更新)
  • 64~127:后台任务(日志、统计、自检)

避坑指南:我曾经在一个项目中,把两个通信任务都设成了最高优先级。结果它们互相抢占,数据包一直重传,系统直接卡死。记住:同优先级任务用时间片轮转,不要全设成抢占。

4.2.3 优先级反转问题

这个坑我踩过,而且踩得很惨。低优先级任务拿着锁,高优先级任务等锁,中优先级任务趁机抢跑——结果高优先级任务反而被低优先级任务“拖死”。

解决方案有两种:

方案 原理 适用场景
优先级继承 低优先级任务临时提升到高优先级 锁持有时间短
优先级天花板 锁设定一个最高优先级,谁拿锁谁升到那个优先级 锁数量少,静态配置

我的建议:小项目用优先级天花板,简单可靠。大项目用优先级继承,更灵活但实现复杂。

4.3 两种算法的对比

我把两种算法的核心差异整理成了一张表,方便你对照选择。

维度 时间片轮转 优先级抢占
调度依据 时间片 + 队列顺序 优先级数值
实时性 弱(所有任务平等) 强(高优先级优先)
公平性 高(每个任务都有机会) 低(低优先级可能饿死)
实现复杂度 低 中(需处理优先级反转)
典型场景 任务重要性相近 任务重要性差异大

4.4 混合调度策略

实际项目中,我很少只用一种算法。更常见的做法是:同优先级内轮转,不同优先级间抢占。说白了就是“先看优先级,再看时间片”。

下面这张图展示了混合调度的核心流程:

混合调度策略流程图 任务就绪 检查优先级 高优先级 → 抢占 同优先级 → 轮转 执行任务 时间片到? 说明 先判断优先级 同优先级再轮转

核心原则:优先级决定“谁先跑”,时间片决定“跑多久”。两者结合,既能保证实时性,又能避免任务饿死。

4.5 实战中的避坑指南

最后,我把自己这些年踩过的坑整理一下,希望能帮你少走弯路。

  • 不要把所有任务都设成最高优先级。我曾经这么干过,结果系统一直在切换,实际干活的时间不到30%。
  • 时间片不要设得太小。小于1ms的时间片,切换开销会吃掉大量CPU。我一般不低于5ms。
  • 注意优先级反转。用互斥锁的地方,一定要考虑优先级继承或天花板。否则高优先级任务可能被低优先级任务“拖死”。
  • 中断里不要直接调用调度器。用PendSV做延迟切换,这是ARM Cortex-M的推荐做法。

我的习惯:每个新项目,我都会先画一张“任务优先级-时间片”分配表。把每个任务的实时性要求、执行时间、周期都列出来,再决定调度策略。这个习惯帮我避免了很多后期返工。