4. FreeRTOS 基础与任务管理:任务创建与删除、任务优先级与调度、任务通知与队列、信号量与互斥锁

好,我们进入第四章。这一章是嵌入式开发的基石——FreeRTOS 任务管理。说实话,很多刚接触 ESP-IDF 的朋友,上来就写业务逻辑,结果系统跑起来各种卡顿、死机、内存泄漏。说白了,就是没把任务管理搞明白。

我个人习惯,在开始任何低功耗项目之前,先把任务模型画清楚。你想想看,任务就像公司里的员工,谁该干活、谁该休息、谁有最高权限,都得安排明白。这一章我们就把这些规则讲透。

4.1 任务创建与删除:让代码跑起来

在 FreeRTOS 里,任务就是一个无限循环的函数。创建任务很简单,核心就一个 API:xTaskCreate()。

// 任务函数原型
void my_task(void *pvParameters)
{
    while(1) {
        // 你的业务逻辑
        vTaskDelay(pdMS_TO_TICKS(1000)); // 延时1秒
    }
}

// 创建任务
xTaskCreate(
    my_task,          // 任务函数
    "my_task",        // 任务名称(调试用)
    2048,             // 栈深度(单位:字,32位系统1字=4字节)
    NULL,             // 参数
    5,                // 优先级(0-31,数值越大优先级越高)
    NULL              // 任务句柄
);
我的习惯:栈深度给 2048 字(8KB)起步。我在项目中遇到过栈溢出导致系统随机重启的坑,排查了两天才发现是某个任务栈给小了。ESP32 的 RAM 虽然够用,但别太抠门。

删除任务用 vTaskDelete()。注意,任务不能自己把自己删干净,得在外部或任务末尾调用。

// 在任务内部删除自己
vTaskDelete(NULL);  // 传入NULL表示删除当前任务

// 在外部删除指定任务
TaskHandle_t xHandle = NULL;
xTaskCreate(my_task, "task", 2048, NULL, 5, &xHandle);
// ... 某个时刻
vTaskDelete(xHandle);
我曾经踩过的坑:任务删除后,它占用的堆内存不会立即释放给其他任务,而是由 FreeRTOS 的堆管理机制回收。如果你频繁创建和删除任务,内存碎片会越来越严重。低功耗场景下,我建议用任务挂起/恢复(vTaskSuspend/vTaskResume)代替删除。

4.2 任务优先级与调度:谁先跑?

FreeRTOS 是抢占式调度器。什么意思?高优先级的任务随时可以打断低优先级的任务。优先级数值越大,优先级越高。

优先级范围 典型用途 说明
0(最低) 空闲任务 系统自动创建,CPU没事干时跑它
1-5 后台任务 数据采集、日志输出、非实时处理
6-15 普通业务 Wi-Fi 配网、传感器读取、协议栈处理
16-24 实时任务 按键响应、控制环路、低延迟通信
25-31 中断级任务 慎用!会阻塞低优先级任务和空闲任务

调度规则其实就两条:

  • 同优先级任务:时间片轮转(Round-Robin),每个任务跑一个 tick(默认 10ms)就切换。
  • 不同优先级任务:高优先级任务就绪时,立即抢占低优先级任务。

关键点:低功耗设计里,优先级别设太高。高优先级任务会阻止 CPU 进入深度睡眠。我一般把 Wi-Fi 协议栈任务设为 10,配网任务设为 8,传感器读取设为 5,这样既能保证响应,又不耽误睡眠。

4.3 任务通知与队列:任务间怎么聊天?

任务之间不能直接访问对方的局部变量,得用 IPC(进程间通信)机制。FreeRTOS 提供了两种最常用的方式:任务通知和队列。

4.3.1 任务通知:轻量级信号

任务通知是 FreeRTOS 特有的机制,比信号量快 30% 左右。每个任务都有一个 32 位的通知值,可以直接用 xTaskNotifyGive() 发送。

// 发送方
xTaskNotifyGive(xTaskToNotify);  // 给指定任务发通知

// 接收方
uint32_t ulNotifiedValue;
ulNotifiedValue = ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
// pdTRUE 表示清除通知值,portMAX_DELAY 表示一直等

我建议:任务通知适合「一对多」或「多对一」的简单信号场景。比如按键按下通知主任务处理。但注意,任务通知只能发给一个任务,不能广播。

4.3.2 队列:结构化数据传递

队列可以传递任意长度的数据,支持多个发送者和多个接收者。创建队列时指定每个元素的大小和队列深度。

// 创建队列,每个元素4字节(比如一个int),深度10
QueueHandle_t xQueue = xQueueCreate(10, sizeof(int));

// 发送数据
int data = 42;
xQueueSend(xQueue, &data, portMAX_DELAY);

// 接收数据
int received;
xQueueReceive(xQueue, &received, portMAX_DELAY);

队列在低功耗场景下有个好处:接收任务可以阻塞等待,不消耗 CPU。发送任务发完数据后,接收任务自动唤醒。

我曾经犯过的错:队列深度设太小,导致数据丢失。Wi-Fi 配网时,Wi-Fi 事件来得很快,队列满了就把旧数据覆盖了。后来我把队列深度设为 20,问题解决。记住:队列满时 xQueueSend 会阻塞,如果不想阻塞,用 xQueueOverwrite 覆盖旧数据。

4.4 信号量与互斥锁:资源保护的艺术

多个任务访问同一个资源(比如 SPI 总线、全局变量)时,必须加锁。FreeRTOS 提供了两种锁:信号量(Semaphore)和互斥锁(Mutex)。

4.4.1 二值信号量 vs 计数信号量

类型 初始值 用途 典型场景
二值信号量 0 任务同步 中断通知任务:数据准备好了
计数信号量 N 资源计数 管理 N 个缓冲区,可用数量
// 创建二值信号量
SemaphoreHandle_t xSem = xSemaphoreCreateBinary();

// 中断中给出信号量
xSemaphoreGiveFromISR(xSem, NULL);

// 任务中获取信号量
xSemaphoreTake(xSem, portMAX_DELAY);

4.4.2 互斥锁:解决优先级反转

互斥锁和信号量最大的区别:互斥锁支持优先级继承。什么意思?

假设任务 A(优先级 10)和任务 B(优先级 5)共享一个资源。如果 B 先拿到了锁,A 想拿就得等 B 释放。但 B 可能被更低优先级的任务 C 打断,导致 A 一直等——这就是优先级反转。

互斥锁的优先级继承机制:当高优先级任务 A 等待锁时,持有锁的低优先级任务 B 会临时提升到 A 的优先级,直到释放锁。这样 B 不会被 C 打断,A 就能尽快拿到锁。

// 创建互斥锁
SemaphoreHandle_t xMutex = xSemaphoreCreateMutex();

// 使用
xSemaphoreTake(xMutex, portMAX_DELAY);
// 访问共享资源
xSemaphoreGive(xMutex);

我的建议:保护共享资源时,优先用互斥锁而不是二值信号量。虽然互斥锁有少量额外开销,但能避免优先级反转带来的诡异问题。我在一个项目中用二值信号量保护 I2C 总线,结果 Wi-Fi 任务优先级高,传感器任务优先级低,Wi-Fi 任务等传感器释放锁时被其他任务打断,导致 Wi-Fi 超时。换成互斥锁后,问题消失。

4.5 知识体系总览

下面这张图把本章的核心关系画清楚了。你可以把它当作任务管理的「地图」。

FreeRTOS 任务管理知识体系 FreeRTOS 任务管理核心结构 FreeRTOS 任务 任务生命周期 创建 → 就绪 → 运行 → 阻塞 → 删除 调度机制 抢占式 + 时间片轮转 IPC 通信 任务通知 / 队列 同步与互斥 信号量 / 互斥锁 低功耗设计要点:任务优先级不宜过高,避免阻止 CPU 进入深度睡眠 互斥锁优先于二值信号量,防止优先级反转

4.6 避坑指南:我踩过的那些坑

  • 栈溢出检测:开启 configCHECK_FOR_STACK_OVERFLOW 宏,FreeRTOS 会在任务切换时检查栈指针是否越界。我习惯设为 2,这样每次切换都检查,虽然慢一点但安全。
  • 中断中不要用互斥锁:互斥锁的优先级继承机制依赖任务调度,中断上下文不能调用。中断中只能用二值信号量的 GiveFromISR 版本。
  • 队列深度别设 1:除非你确定每次只处理一个事件。Wi-Fi 事件、按键事件都可能连续触发,队列深度至少 5 起步。
  • 任务优先级别超过 20:ESP-IDF 的 Wi-Fi 协议栈任务优先级是 10 左右,你设个 25 的任务会抢占 Wi-Fi 处理,导致连接不稳定。

嗯,这一章的内容就到这里。任务管理是 FreeRTOS 的核心,也是低功耗设计的基础。把任务模型画清楚、优先级排明白、IPC 选对工具,你的系统就成功了一半。