4. 任务调度机制:抢占式调度、时间片轮转、空闲任务与钩子函数

任务调度,说白了就是操作系统决定「哪个任务先跑、跑多久、下一个换谁」的一套规则。FreeRTOS 的调度策略并不复杂,但理解透了,你写出来的固件才稳。我在早期项目里吃过亏——任务优先级没规划好,结果传感器数据采集被 Wi-Fi 任务活活饿死,板子跑起来跟抽风似的。

这一节,我把抢占式调度、时间片轮转、空闲任务和钩子函数这几个核心概念串起来讲。你跟着走一遍,心里就有底了。

4.1 抢占式调度:高优先级任务「插队」的规则

抢占式调度是 FreeRTOS 的默认行为。它的核心逻辑就一句话:只要有一个优先级更高的任务就绪,当前任务立刻被挂起,CPU 让给高优先级任务。

举个例子。你家里有个智能灯控系统:

  • 任务 A:按键检测(优先级 3)
  • 任务 B:LED 呼吸灯效果(优先级 1)
  • 任务 C:温度上报(优先级 2)

当任务 B 正在跑呼吸灯时,你按了一下按键。按键中断触发,任务 A 就绪。调度器一看——任务 A 优先级 3,比当前跑的 B(优先级 1)高,立即切换。任务 B 被挂起,任务 A 开始执行按键逻辑。等任务 A 处理完主动阻塞(比如等待下一个按键),调度器再挑一个优先级最高的就绪任务继续跑。

关键点:抢占只发生在「就绪态」任务优先级高于「运行态」任务时。如果高优先级任务一直不阻塞,低优先级任务永远没机会跑——这就是「优先级反转」和「任务饿死」的根源。

我在做智能窗帘项目时遇到过一个问题:电机控制任务优先级设得太高,结果它每次执行完不主动延时,直接循环检查位置传感器。Wi-Fi 通信任务根本抢不到 CPU,设备连不上网。后来我把电机任务里加了个 vTaskDelay(10),问题就解决了。

4.2 时间片轮转:同优先级任务的「轮流坐庄」

抢占式调度解决的是不同优先级的问题。那如果两个任务优先级相同呢?

FreeRTOS 的做法是——时间片轮转。每个任务跑一个固定长度的时间片(默认一个 tick,通常是 1 ms),时间到了就切换到下一个同优先级任务。

你可以把它想象成几个人轮流用一台电脑。每人用 1 秒,时间一到就换下一个人。谁都不许赖着不走。

特性 抢占式调度 时间片轮转
触发条件 高优先级任务就绪 同优先级任务时间片用完
适用场景 不同优先级任务共存 多个同等重要的任务
是否可配置 默认开启 通过 configUSE_TIME_SLICING 控制
风险点 高优先级任务饿死低优先级 时间片太短导致频繁切换开销大
我的习惯:同优先级任务尽量控制在 3 个以内。太多的话,切换开销会吃掉不少 CPU。而且时间片轮转不能保证实时性——如果一个任务刚好在时间片末尾被抢占,它下次轮到可能要等好几个时间片。

4.3 空闲任务:系统「没事干」时的保底机制

当所有任务都阻塞了(比如都在等延时、等队列、等信号量),调度器怎么办?

FreeRTOS 会自动创建一个空闲任务(Idle Task),优先级为 0(最低)。只要没有其他任务就绪,空闲任务就一直在跑。

空闲任务主要干三件事:

  1. 回收任务删除后的内存——调用 vTaskDelete() 后,TCB 和栈空间由空闲任务统一释放。
  2. 进入低功耗模式——配合 configUSE_IDLE_HOOK,可以在空闲任务里执行 esp_light_sleep_start() 省电。
  3. 统计 CPU 利用率——通过测量空闲任务运行时间占比,反推系统负载。

你想想看,如果空闲任务被阻塞了,系统就没办法回收资源。所以 FreeRTOS 规定:空闲任务里不能调用任何可能导致阻塞的 API,比如 vTaskDelay()、xQueueReceive() 带超时参数等。

我曾经踩过的坑:在空闲任务钩子里调了 vTaskDelay(10),结果系统跑着跑着内存泄漏。查了两天才发现——空闲任务被阻塞后,删除任务的内存一直没人回收。记住:空闲任务钩子里只做非阻塞操作。

4.4 钩子函数:在空闲任务里「偷时间」

钩子函数(Hook Function)是 FreeRTOS 提供的一种扩展机制。你可以在空闲任务每次循环时插入一段自定义代码。

启用方式很简单:

  1. 在 FreeRTOSConfig.h 里把 configUSE_IDLE_HOOK 设为 1。
  2. 实现一个名为 vApplicationIdleHook(void) 的函数。
// FreeRTOSConfig.h
#define configUSE_IDLE_HOOK    1

// 在你的代码里
void vApplicationIdleHook(void)
{
    // 不能阻塞!
    // 可以做的事:喂看门狗、统计CPU利用率、进入浅睡眠
    static uint32_t idle_ticks = 0;
    idle_ticks++;
    
    // 每1000个空闲tick打印一次CPU利用率
    if (idle_ticks % 1000 == 0) {
        uint32_t total_ticks = xTaskGetTickCount();
        float cpu_usage = 100.0f - (100.0f * idle_ticks / total_ticks);
        printf("CPU 利用率: %.1f%%\n", cpu_usage);
    }
}

钩子函数最常见的用途:

  • 喂独立看门狗——防止系统在空闲时误触发复位。
  • 进入低功耗模式——ESP32 的 esp_light_sleep_start() 放在这里最合适。
  • CPU 利用率统计——上面代码就是个例子。
  • 调试信息输出——只在空闲时打印,不影响任务实时性。
我的建议:钩子函数里的代码执行时间要短。如果钩子函数跑太久,会影响下一个就绪任务的响应。我一般控制在几十微秒以内。

4.5 三种调度策略的协同工作

实际项目中,这三种机制是同时起作用的。我画了一张图帮你理清关系:

FreeRTOS 任务调度机制协同工作图 FreeRTOS 调度器 抢占式调度 不同优先级任务切换 时间片轮转 同优先级任务轮流执行 空闲任务 优先级 0,保底运行 空闲任务钩子函数 喂看门狗 非阻塞操作 低功耗模式 esp_light_sleep CPU利用率统计 空闲tick计数 调试信息输出 不影响实时性 抢占式调度决定「谁先跑」,时间片轮转决定「同优先级怎么换」,空闲任务兜底,钩子函数在空闲时「偷时间」做杂活 三者配合,才能让 ESP32 在智能家居场景下既实时又省电

从图上你能看到:调度器是核心,它根据任务优先级和就绪状态,在三种策略间自动切换。你作为开发者,只需要做两件事:

  1. 合理分配优先级——实时性要求高的(如按键响应、传感器中断处理)给高优先级;后台任务(如日志上传、LED 显示)给低优先级。
  2. 确保任务适时阻塞——每个任务里都要有 vTaskDelay()、等待队列或信号量的操作,否则高优先级任务会独占 CPU。
实战经验:我在智能门锁项目里,把指纹识别任务设为最高优先级(4),Wi-Fi 通信设为中等(2),LED 指示设为最低(1)。指纹识别完成后主动阻塞等待下一次触发,Wi-Fi 任务在发送间隙延时 50ms,LED 任务每 20ms 刷新一次。这样既保证了开锁响应速度,又不会让网络断连。

4.6 配置项速查表

最后给你一张配置速查表,方便你在 FreeRTOSConfig.h 里快速定位:

宏定义 默认值 作用 建议
configUSE_PREEMPTION 1 开启抢占式调度 保持默认,不要关
configUSE_TIME_SLICING 1 开启时间片轮转 保持默认
configUSE_IDLE_HOOK 0 启用空闲任务钩子 需要低功耗或统计时设为 1
configIDLE_SHOULD_YIELD 1 空闲任务是否让出 CPU 保持默认,提高同优先级任务响应
configUSE_TICKLESS_IDLE 0 无 tick 空闲模式(省电) 电池供电项目建议开启