FreeRTOS 基础概念:任务、队列、信号量、互斥锁

好,咱们正式开始啃 FreeRTOS 的核心。很多初学者一上来就被各种术语搞懵了——任务、队列、信号量、互斥锁……说实话,我当年学的时候也晕过一阵子。但这些东西说白了,就是帮你在 ESP32 上管理多件事情的工具。

你想想看,ESP32 有两个核,你要同时处理传感器读取、屏幕刷新、WiFi 通信、按键扫描。如果没有一套机制,代码很快就会变成一团乱麻。FreeRTOS 就是来帮你理清这团乱麻的。

任务(Task)—— 程序里的"独立线程"

任务,是 FreeRTOS 里最基本的执行单元。每个任务就像是一个独立的"小程序",有自己的栈空间、优先级和运行状态。

核心要点:任务不是函数调用,它是无限循环的独立执行流。

我刚开始用 FreeRTOS 时犯过一个低级错误——在任务里写了个 return。结果任务直接挂了,系统卡死。后来才明白,任务函数必须是个死循环,要么用 vTaskDelete() 主动结束自己。

任务的状态机

一个任务在生命周期里会经历四种状态:

状态 含义 典型场景
运行态(Running) 正在占用 CPU 当前正在执行的任务
就绪态(Ready) 可以运行,但 CPU 被别的任务占着 高优先级任务在等低优先级任务让出 CPU
阻塞态(Blocked) 等待某个事件或延时 调用 vTaskDelay() 或等待队列数据
挂起态(Suspended) 被显式暂停,不参与调度 调试时临时暂停某个任务

这里有个经验:不要让任务在运行态空转。我见过有人用 while(1); 做延时,结果 CPU 被吃满,其他任务全饿死了。正确的做法是用 vTaskDelay() 让任务主动进入阻塞态。

创建任务的 API

// 任务函数原型
void vSensorTask(void *pvParameters);

// 创建任务
xTaskCreate(
    vSensorTask,       // 任务函数
    "Sensor Task",     // 任务名称(调试用)
    2048,              // 栈深度(单位:字)
    NULL,              // 参数
    2,                 // 优先级(0~configMAX_PRIORITIES-1)
    NULL               // 任务句柄
);

我的习惯:栈深度先给 2048(即 8KB),跑起来后用 uxTaskGetStackHighWaterMark() 看实际使用量,再调小。别一开始就给太大,ESP32 的 RAM 总共才 520KB,省着点用。

队列(Queue)—— 任务间的"快递通道"

任务之间怎么传数据?用全局变量?那得加锁,麻烦还容易出 bug。FreeRTOS 提供了队列,说白了就是一个线程安全的 FIFO 缓冲区。

我在做多传感器采集时,就用队列把 I2C 读取到的数据从采集任务发给处理任务。一个任务往里写,一个任务往外读,完全不用操心同步问题。

队列的工作机制

  • 写操作:xQueueSend() 把数据拷贝到队列尾部
  • 读操作:xQueueReceive() 从队列头部取出数据
  • 阻塞等待:队列空时读任务可以阻塞,直到有数据进来
  • 超时机制:可以设置等待超时,避免死等
// 创建队列,每个元素 4 字节(int),最多存 10 个
QueueHandle_t xSensorQueue = xQueueCreate(10, sizeof(int32_t));

// 发送任务
int32_t sensorValue = readSensor();
xQueueSend(xSensorQueue, &sensorValue, portMAX_DELAY);

// 接收任务
int32_t receivedValue;
if (xQueueReceive(xSensorQueue, &receivedValue, pdMS_TO_TICKS(100)) == pdPASS) {
    // 处理数据
}

注意:队列传的是数据拷贝,不是指针。如果传结构体,确保结构体大小别太大,否则频繁拷贝会消耗 CPU。大数据建议传指针,但要保证指针指向的内存生命周期有效。

信号量(Semaphore)—— 资源占用的"通行证"

信号量分两种:二值信号量和计数信号量。名字听着玄乎,其实理解起来不难。

二值信号量

就像一个"是否可用"的旗子。只有 0 和 1 两个状态。典型用法是任务同步——比如 ISR(中断服务函数)里发信号量,任务里等信号量。

我记得第一次用 ESP32 做外部中断触发采集时,就是在 GPIO 中断里给信号量,然后采集任务等信号量来了再去读 I2C。这样中断处理极快,不会阻塞其他中断。

// 创建二值信号量
SemaphoreHandle_t xSemaphore = xSemaphoreCreateBinary();

// ISR 中给信号量
void IRAM_ATTR gpio_isr_handler(void* arg) {
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    xSemaphoreGiveFromISR(xSemaphore, &xHigherPriorityTaskWoken);
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

// 任务中等待信号量
void vSensorTask(void *pvParameters) {
    while(1) {
        if (xSemaphoreTake(xSemaphore, portMAX_DELAY) == pdPASS) {
            // 执行传感器读取
        }
    }
}

计数信号量

可以理解成"资源池"。比如你有 5 个 I2C 总线句柄,最多允许 5 个任务同时访问。计数信号量的初始值就是 5,每 Take 一次减 1,每 Give 一次加 1。

类型 最大值 典型场景
二值信号量 1 中断通知任务、一次性的同步
计数信号量 N 有限资源池、多任务限流

互斥锁(Mutex)—— 共享资源的"门禁"

互斥锁和二值信号量很像,但有个关键区别:互斥锁有优先级继承机制。什么意思?

假设低优先级任务拿了锁,高优先级任务在等这把锁。如果没有优先级继承,低优先级任务可能被中优先级任务抢占,导致高优先级任务一直等不到锁——这就是经典的优先级反转问题。

互斥锁会自动把低优先级任务的优先级临时提升到和高优先级任务一样,等释放锁后再降回去。这个机制能有效避免优先级反转。

经验之谈:如果只是任务间同步,用二值信号量就够了。但如果多个任务要访问同一个共享资源(比如 I2C 总线、全局变量),一定要用互斥锁。我曾经在项目里用二值信号量保护 I2C 总线,结果高优先级任务偶尔被卡住,查了两天才发现是优先级反转的问题。

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

// 访问共享资源前
xSemaphoreTake(xI2CMutex, portMAX_DELAY);

// 操作 I2C 总线
i2c_master_write_to_device(I2C_NUM_0, addr, data, len, 100);

// 释放互斥锁
xSemaphoreGive(xI2CMutex);

核心关系一览

我把这四个概念的关系画了张图,方便你理解它们在系统中的位置:

FreeRTOS 核心机制关系图 任务 A(采集) 任务 B(处理) 任务 C(输出) 队列 数据传递 FIFO 缓冲区 线程安全 信号量 任务同步 中断通知 资源计数 互斥锁 共享资源保护 优先级继承 避免反转 💡 使用建议 • 任务间传数据 → 用队列 • 中断通知任务 → 用二值信号量 • 保护共享资源(I2C/SPI/全局变量)→ 用互斥锁

避坑指南

最后分享几个我踩过的坑,你遇到了能少走弯路:

  • 不要在 ISR 里调用 vTaskDelay()——中断上下文不能阻塞,会直接崩掉。用 xSemaphoreGiveFromISR() 代替。
  • 互斥锁不能在 ISR 里使用——因为优先级继承机制依赖任务调度器,中断里没有这个环境。ISR 里用二值信号量。
  • 队列大小要合理——太小容易丢数据,太大浪费内存。我一般根据传感器采样率和处理速度估算,留 20% 余量。
  • 注意栈溢出——任务栈默认 2048 字,如果任务里有大数组或深层递归,记得调大。用 uxTaskGetStackHighWaterMark() 监控。