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 // 任务句柄
);
删除任务用 vTaskDelete()。注意,任务不能自己把自己删干净,得在外部或任务末尾调用。
// 在任务内部删除自己
vTaskDelete(NULL); // 传入NULL表示删除当前任务
// 在外部删除指定任务
TaskHandle_t xHandle = NULL;
xTaskCreate(my_task, "task", 2048, NULL, 5, &xHandle);
// ... 某个时刻
vTaskDelete(xHandle);
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 知识体系总览
下面这张图把本章的核心关系画清楚了。你可以把它当作任务管理的「地图」。
4.6 避坑指南:我踩过的那些坑
- 栈溢出检测:开启
configCHECK_FOR_STACK_OVERFLOW宏,FreeRTOS 会在任务切换时检查栈指针是否越界。我习惯设为 2,这样每次切换都检查,虽然慢一点但安全。 - 中断中不要用互斥锁:互斥锁的优先级继承机制依赖任务调度,中断上下文不能调用。中断中只能用二值信号量的
GiveFromISR版本。 - 队列深度别设 1:除非你确定每次只处理一个事件。Wi-Fi 事件、按键事件都可能连续触发,队列深度至少 5 起步。
- 任务优先级别超过 20:ESP-IDF 的 Wi-Fi 协议栈任务优先级是 10 左右,你设个 25 的任务会抢占 Wi-Fi 处理,导致连接不稳定。
嗯,这一章的内容就到这里。任务管理是 FreeRTOS 的核心,也是低功耗设计的基础。把任务模型画清楚、优先级排明白、IPC 选对工具,你的系统就成功了一半。