2. FreeRTOS 核心概念:任务、队列、信号量、互斥锁、软件定时器
好,咱们直接进入正题。FreeRTOS 的核心,说白了就是五个东西:任务、队列、信号量、互斥锁、软件定时器。你把这五个玩明白了,ESP32 上跑什么项目都稳。
我记得刚带团队那会儿,有个同事把信号量和互斥锁混着用,结果系统三天两头死锁。排查了两天才找到原因——他拿信号量去保护共享资源,优先级反转把整个调度搞崩了。所以这一章,我会把每个概念的坑和正确用法都讲透。
2.1 任务(Task)——系统的最小执行单元
任务就是 FreeRTOS 里的“线程”。每个任务都是一个无限循环的函数,有自己的栈空间和优先级。调度器根据优先级和时间片来切换任务。
核心要点:任务不是函数调用,它不会返回。一旦创建,要么挂起,要么删除,要么永远循环。
2.1.1 任务状态机
任务有四种状态:运行态、就绪态、阻塞态、挂起态。我画了个图,你一看就明白。
2.1.2 任务创建与删除
创建任务用 xTaskCreate()。我个人习惯把任务栈大小设成 2048 字节起步,调试阶段宁多勿少。
// 任务函数原型
void vTaskFunction(void *pvParameters);
// 创建任务
BaseType_t xTaskCreate(
TaskFunction_t pvTaskCode, // 任务函数指针
const char * const pcName, // 任务名称(调试用)
configSTACK_DEPTH_TYPE usStackDepth, // 栈深度,单位是字(4字节)
void *pvParameters, // 传给任务的参数
UBaseType_t uxPriority, // 优先级(0最低,configMAX_PRIORITIES-1最高)
TaskHandle_t *pxCreatedTask // 返回的任务句柄
);
// 示例:创建一个LED闪烁任务
TaskHandle_t xLedTaskHandle = NULL;
void vLedTask(void *pvParameters) {
while(1) {
GPIO_SetBits(LED_PIN);
vTaskDelay(pdMS_TO_TICKS(500));
GPIO_ResetBits(LED_PIN);
vTaskDelay(pdMS_TO_TICKS(500));
}
}
void main() {
xTaskCreate(vLedTask, "LED", 1024, NULL, 1, &xLedTaskHandle);
vTaskStartScheduler(); // 启动调度器
}
⚠️ 避坑指南:我曾经在项目里把栈大小设成 128 字节,结果任务一运行就栈溢出,系统直接 HardFault。调试了两天才发现是栈不够。建议:任务里调用了 printf 或复杂库函数时,栈至少给 2048。
2.2 队列(Queue)——任务间通信的管道
队列是 FreeRTOS 里最常用的 IPC(进程间通信)机制。一个任务往队列里写数据,另一个任务从队列里读数据。队列是线程安全的,内部用临界区保护。
2.2.1 队列的工作方式
队列本质是一个环形缓冲区,有长度和元素大小两个参数。写队列时,如果队列满了,任务可以选择阻塞等待;读队列时,如果队列空了,任务也可以阻塞等待。
| 操作 | 函数 | 说明 |
|---|---|---|
| 创建队列 | xQueueCreate() |
指定队列长度和每个元素大小 |
| 发送数据 | xQueueSend() |
从队尾写入,队列满则阻塞 |
| 发送数据(中断) | xQueueSendFromISR() |
在中断服务函数中使用 |
| 接收数据 | xQueueReceive() |
从队头读取并移除数据 |
| 窥探数据 | xQueuePeek() |
从队头读取但不移除数据 |
2.2.2 实战:传感器数据采集
我在做智能家居项目时,经常用队列把传感器数据从采集任务传给处理任务。这样采集和处理解耦,互不影响。
// 定义传感器数据结构体
typedef struct {
float temperature;
float humidity;
uint32_t timestamp;
} SensorData_t;
// 创建队列,长度10,每个元素是SensorData_t
QueueHandle_t xSensorQueue = xQueueCreate(10, sizeof(SensorData_t));
// 采集任务
void vSensorTask(void *pvParameters) {
SensorData_t data;
while(1) {
data.temperature = read_temperature();
data.humidity = read_humidity();
data.timestamp = xTaskGetTickCount();
// 发送到队列,超时100ms
if(xQueueSend(xSensorQueue, &data, pdMS_TO_TICKS(100)) != pdPASS) {
// 队列满了,处理异常
vTaskDelay(pdMS_TO_TICKS(10));
}
vTaskDelay(pdMS_TO_TICKS(1000)); // 每秒采集一次
}
}
// 处理任务
void vProcessTask(void *pvParameters) {
SensorData_t received;
while(1) {
// 等待队列数据,阻塞等待
if(xQueueReceive(xSensorQueue, &received, portMAX_DELAY) == pdPASS) {
// 处理传感器数据
process_sensor_data(&received);
}
}
}
💡 小技巧:队列的元素大小不要太大。如果数据量大,建议传指针而不是传整个结构体。我习惯在队列里只传指针,数据放在共享内存里,这样效率高很多。
2.3 信号量(Semaphore)——资源管理与同步
信号量分两种:二值信号量和计数信号量。二值信号量像是一个“是否可用”的开关,计数信号量则记录可用资源的数量。
2.3.1 二值信号量 vs 计数信号量
| 类型 | 最大值 | 典型用途 | 创建函数 |
|---|---|---|---|
| 二值信号量 | 1 | 任务同步、中断通知 | xSemaphoreCreateBinary() |
| 计数信号量 | 可配置 | 资源池管理(如缓冲区数量) | xSemaphoreCreateCounting() |
你想想看,二值信号量最经典的应用场景是什么?中断通知。我在项目中用 ESP32 的 GPIO 中断来唤醒一个处理任务,就是靠二值信号量实现的。
// 创建二值信号量
SemaphoreHandle_t xBinarySemaphore = xSemaphoreCreateBinary();
// 中断服务函数
void IRAM_ATTR gpio_isr_handler(void* arg) {
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
// 在中断中释放信号量
xSemaphoreGiveFromISR(xBinarySemaphore, &xHigherPriorityTaskWoken);
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}
// 处理任务
void vGPIOTask(void *pvParameters) {
while(1) {
// 等待信号量,阻塞
if(xSemaphoreTake(xBinarySemaphore, portMAX_DELAY) == pdTRUE) {
// 处理GPIO事件
handle_gpio_event();
}
}
}
关键区别:信号量用于同步和资源计数,不用于保护共享资源。保护共享资源要用互斥锁。这个坑我踩过,后面细说。
2.4 互斥锁(Mutex)——保护共享资源
互斥锁和二值信号量长得很像,但有一个关键区别:互斥锁支持优先级继承。什么意思?就是当一个低优先级任务持有互斥锁时,如果有高优先级任务也在等这个锁,系统会临时提升低优先级任务的优先级,防止优先级反转。
2.4.1 优先级反转问题
我举个例子你就明白了。假设有三个任务:高优先级 H、中优先级 M、低优先级 L。L 先拿到了互斥锁,然后 H 来了也要这个锁,H 只能等 L 释放。但这时候 M 抢占了 L(因为 M 优先级比 L 高),L 一直没机会释放锁,H 就一直等。这就是优先级反转。
互斥锁的优先级继承机制会临时把 L 的优先级提升到 H 的级别,这样 M 就抢不过 L 了,L 能尽快释放锁,H 就能拿到锁继续运行。
// 创建互斥锁
SemaphoreHandle_t xMutex = xSemaphoreCreateMutex();
// 共享资源访问
void vTaskA(void *pvParameters) {
while(1) {
// 获取互斥锁
if(xSemaphoreTake(xMutex, portMAX_DELAY) == pdTRUE) {
// 访问共享资源(比如修改全局变量)
shared_variable++;
// 释放互斥锁
xSemaphoreGive(xMutex);
}
vTaskDelay(pdMS_TO_TICKS(100));
}
}
⚠️ 重要提醒:千万不要在中断服务函数里使用互斥锁!互斥锁的释放必须在同一个任务中完成。中断里只能用二值信号量或队列。我曾经见过有人把互斥锁用在中断里,结果系统直接崩溃。
2.5 软件定时器(Software Timer)——轻量级定时任务
软件定时器是 FreeRTOS 提供的一种轻量级定时机制。它不像硬件定时器那么精确,但胜在灵活——你可以创建任意多个,每个定时器到期时执行一个回调函数。
2.5.1 单次定时器 vs 周期定时器
| 类型 | 行为 | 创建函数 |
|---|---|---|
| 单次定时器 | 到期执行一次,然后停止 | xTimerCreate() + pdFALSE |
| 周期定时器 | 到期执行一次,然后自动重新开始 | xTimerCreate() + pdTRUE |
2.5.2 实战:LED 呼吸灯
我在智能家居项目里用软件定时器做 LED 呼吸灯效果,比用任务延时省资源得多。
// 定时器回调函数
void vLEDTimerCallback(TimerHandle_t xTimer) {
static uint8_t brightness = 0;
static int8_t direction = 1;
// 调整PWM占空比
set_led_pwm(brightness);
brightness += direction;
if(brightness == 0 || brightness == 255) {
direction = -direction;
}
}
void main() {
// 创建周期定时器,周期50ms
TimerHandle_t xLEDTimer = xTimerCreate(
"LED Timer", // 定时器名称
pdMS_TO_TICKS(50), // 周期50ms
pdTRUE, // 周期定时器
NULL, // 定时器ID
vLEDTimerCallback // 回调函数
);
if(xTimerStart(xLEDTimer, 0) != pdPASS) {
// 启动失败处理
}
vTaskStartScheduler();
}
💡 注意:软件定时器的回调函数是在定时器服务任务中执行的,不是在你的任务里。所以回调函数里不能调用阻塞函数(比如 vTaskDelay),也不能做耗时操作。我一般只在回调里发信号量或往队列里写数据,真正的处理逻辑放在其他任务里。
2.6 五个核心概念的关系
最后,我用一张图总结这五个概念的关系,方便你理解它们在系统里的角色。
嗯,这一章的内容就到这儿。五个概念,每个都有它的定位和用法。你写代码的时候,先想清楚:我要同步还是通信?要保护资源还是计数?选对了工具,系统就稳了。