2. FreeRTOS 核心概念:任务、队列、信号量、互斥锁、软件定时器

好,咱们直接进入正题。FreeRTOS 的核心,说白了就是五个东西:任务、队列、信号量、互斥锁、软件定时器。你把这五个玩明白了,ESP32 上跑什么项目都稳。

我记得刚带团队那会儿,有个同事把信号量和互斥锁混着用,结果系统三天两头死锁。排查了两天才找到原因——他拿信号量去保护共享资源,优先级反转把整个调度搞崩了。所以这一章,我会把每个概念的坑和正确用法都讲透。

2.1 任务(Task)——系统的最小执行单元

任务就是 FreeRTOS 里的“线程”。每个任务都是一个无限循环的函数,有自己的栈空间和优先级。调度器根据优先级和时间片来切换任务。

核心要点:任务不是函数调用,它不会返回。一旦创建,要么挂起,要么删除,要么永远循环。

2.1.1 任务状态机

任务有四种状态:运行态、就绪态、阻塞态、挂起态。我画了个图,你一看就明白。

FreeRTOS 任务状态转换图 运行态 就绪态 阻塞态 挂起态 调度器选择 时间片用完 等待事件/延时 事件到达 vTaskSuspend vTaskResume 💡 说明:只有就绪态的任务才能被调度器选中进入运行态。阻塞态通常由延时或等待队列/信号量引起。

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 五个核心概念的关系

最后,我用一张图总结这五个概念的关系,方便你理解它们在系统里的角色。

FreeRTOS 五大核心概念关系图 任务 (Task) 队列 (Queue) 信号量 (Semaphore) 互斥锁 (Mutex) 软件定时器 (Timer) 任务间通信 同步/通知 保护共享资源 定时触发 💡 任务是最小执行单元,队列和信号量用于任务间通信,互斥锁保护共享资源,软件定时器提供定时触发。

嗯,这一章的内容就到这儿。五个概念,每个都有它的定位和用法。你写代码的时候,先想清楚:我要同步还是通信?要保护资源还是计数?选对了工具,系统就稳了。