4. FreeRTOS内核分析(下):队列、信号量、互斥量、事件组

好,咱们接着往下聊。上一章我们把任务管理和调度讲透了,这一章要解决的是任务之间怎么“说话”的问题。

你想想看,一个系统里十几个任务各跑各的,数据怎么传?资源怎么抢?同步怎么搞?FreeRTOS 给了我们四件套:队列、信号量、互斥量、事件组。我做了这么多年嵌入式,可以说这四样东西用好了,90% 的多任务通信问题都能搞定。

核心观点:这四种机制本质上都是“任务间通信”的不同表现形式。队列传数据,信号量传状态,互斥量管资源,事件组等条件。

FreeRTOS 任务间通信机制总览 任务间通信 队列 Queue 信号量 Semaphore 互斥量 Mutex 事件组 Event Group 核心差异 队列:传递数据块 | 信号量:传递计数/状态 | 互斥量:带优先级继承的锁 | 事件组:多条件位掩码 底层实现:队列是基础,信号量和互斥量都是基于队列实现的

4.1 队列(Queue)—— 任务间的数据管道

队列是 FreeRTOS 里最基础的通信机制。说白了,就是一个先进先出的数据缓冲区。一个任务往里写,另一个任务往外读。

我记得刚入行那会儿,有个同事把全局数组当共享缓冲区用,结果两个任务同时读写,数据全乱了。从那以后,我但凡看到任务间传数据,第一反应就是用队列。

4.1.1 队列的工作原理

队列内部维护了一个环形缓冲区。写数据叫“发送”,读数据叫“接收”。关键点在于:

  • 阻塞机制:队列满时,发送任务可以等;队列空时,接收任务可以等
  • 超时控制:等多久由你定,等不到就返回超时错误
  • 数据拷贝:FreeRTOS 传的是数据副本,不是指针。这点很重要

我的建议:队列传数据是“值传递”,不是“引用传递”。如果你传的是大结构体,记得把队列项设小一点,或者传指针。但传指针要小心——指针指向的内存必须保证在接收方读取时仍然有效。

4.1.2 队列 API 实战

来看一个典型的使用场景:一个传感器采集任务把数据发给处理任务。

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

// 发送任务
void vSensorTask(void *pvParameters) {
    int sensorValue;
    while(1) {
        sensorValue = readSensor();
        // 发送,最多等 100ms
        if(xQueueSend(xSensorQueue, &sensorValue, pdMS_TO_TICKS(100)) != pdPASS) {
            // 发送失败处理
        }
        vTaskDelay(pdMS_TO_TICKS(50));
    }
}

// 接收任务
void vProcessTask(void *pvParameters) {
    int receivedValue;
    while(1) {
        // 接收,一直等
        if(xQueueReceive(xSensorQueue, &receivedValue, portMAX_DELAY) == pdPASS) {
            processData(receivedValue);
        }
    }
}

这里有个细节:xQueueSend 和 xQueueSendToBack 是一样的,都是往队尾放。还有个 xQueueSendToFront,插队用的,紧急数据可以用它。

4.1.3 队列集(Queue Set)

一个任务要同时等多个队列怎么办?FreeRTOS 提供了队列集。我在做车载网关项目时用过这个——一个任务要同时监听 CAN 总线、以太网和诊断三个队列。

// 创建队列集
QueueSetHandle_t xQueueSet = xQueueCreateSet(3);

// 把三个队列加入集合
xQueueAddToSet(xCanQueue, xQueueSet);
xQueueAddToSet(xEthQueue, xQueueSet);
xQueueAddToSet(xDiagQueue, xQueueSet);

// 等待任意一个队列有数据
QueueSetMemberHandle_t xActivatedQueue;
xActivatedQueue = xQueueSelectFromSet(xQueueSet, portMAX_DELAY);

// 从激活的队列中读取
if(xActivatedQueue == xCanQueue) {
    xQueueReceive(xCanQueue, &data, 0);
}

4.2 信号量(Semaphore)—— 轻量级的“有/没有”通知

信号量本质上是一个队列,只不过队列项大小为 0,只存计数。它分两种:

类型 最大计数 典型用途
二值信号量 1 任务同步、中断通知
计数信号量 N 资源池管理、事件计数

4.2.1 二值信号量——中断与任务的桥梁

这是我最常用的场景。中断里不能调用阻塞 API,但可以用信号量通知任务来处理。

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

// 中断服务函数
void vTimerISR(void) {
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    // 给出信号量
    xSemaphoreGiveFromISR(xSemaphore, &xHigherPriorityTaskWoken);
    // 如果接收任务优先级更高,触发上下文切换
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

// 处理任务
void vProcessTask(void *pvParameters) {
    while(1) {
        // 等待信号量
        if(xSemaphoreTake(xSemaphore, portMAX_DELAY) == pdPASS) {
            // 处理中断事件
        }
    }
}

我曾经踩过的坑:二值信号量创建后默认是“已给”状态。如果你希望它一开始是“未给”状态,记得创建后先调用一次 xSemaphoreTake 把它吃掉。或者用 xSemaphoreCreateBinaryStatic 配合初始化。

4.2.2 计数信号量——管理有限资源

比如系统里有 5 个 DMA 通道,多个任务要用。用计数信号量来管理:

// 创建计数信号量,初始值 5,最大值 5
SemaphoreHandle_t xDmaSemaphore = xSemaphoreCreateCounting(5, 5);

// 任务申请 DMA 通道
void vTaskUseDMA(void *pvParameters) {
    if(xSemaphoreTake(xDmaSemaphore, pdMS_TO_TICKS(1000)) == pdPASS) {
        // 拿到一个 DMA 通道,开始传输
        useDMAChannel();
        // 用完归还
        xSemaphoreGive(xDmaSemaphore);
    } else {
        // 超时,没拿到资源
    }
}

4.3 互斥量(Mutex)—— 带“优先级继承”的锁

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

为什么要这个机制?我给你讲个真实案例。有一次我在调试一个车载系统,低优先级任务拿了锁,高优先级任务在等锁,中间还有个中优先级任务在跑。结果中优先级任务一直占着 CPU,低优先级任务没法释放锁,高优先级任务就一直等——这就是经典的优先级反转。

互斥量的优先级继承能解决这个问题:当高优先级任务等锁时,持有锁的低优先级任务会临时“继承”高优先级,跑完了再降回去。

4.3.1 互斥量使用要点

  • 不能用在中断里:互斥量的优先级继承需要任务上下文,中断里用会出问题
  • 谁拿谁放:只有拿了锁的任务才能释放,不能跨任务操作
  • 递归互斥量:同一个任务可以多次拿锁,适合递归函数
// 创建互斥量
SemaphoreHandle_t xMutex = xSemaphoreCreateMutex();

void vTaskA(void *pvParameters) {
    while(1) {
        // 拿锁
        if(xSemaphoreTake(xMutex, portMAX_DELAY) == pdPASS) {
            // 访问共享资源
            writeSharedData();
            // 放锁
            xSemaphoreGive(xMutex);
        }
    }
}

关键对比:互斥量 vs 二值信号量

  • 互斥量有优先级继承,二值信号量没有
  • 互斥量只能用于任务间,二值信号量可用于中断
  • 互斥量必须由同一任务释放,二值信号量可以跨任务
  • 互斥量适合保护共享资源,二值信号量适合同步

4.4 事件组(Event Group)—— 多条件组合等待

事件组是 FreeRTOS 里一个比较“高级”的机制。它用 24 位(或更少)的位掩码来表示事件状态。一个任务可以等多个事件,而且可以指定“与”还是“或”的关系。

我做过一个车载诊断系统,需要等三个条件都满足才能开始诊断:CAN 总线就绪、诊断会话激活、ECU 唤醒。用事件组就特别合适。

4.4.1 事件组 API

// 定义事件位
#define EVENT_CAN_READY    (1 << 0)
#define EVENT_DIAG_ACTIVE  (1 << 1)
#define EVENT_ECU_AWAKE    (1 << 2)

// 创建事件组
EventGroupHandle_t xEventGroup = xEventGroupCreate();

// 设置事件(可以在中断或任务中调用)
xEventGroupSetBits(xEventGroup, EVENT_CAN_READY);

// 等待多个事件(与逻辑)
EventBits_t uxBits = xEventGroupWaitBits(
    xEventGroup,                    // 事件组句柄
    EVENT_CAN_READY | EVENT_DIAG_ACTIVE | EVENT_ECU_AWAKE,  // 要等的事件
    pdTRUE,                         // 清除已等到的位
    pdTRUE,                         // 与逻辑(pdFALSE 为或逻辑)
    portMAX_DELAY                   // 超时
);

if((uxBits & (EVENT_CAN_READY | EVENT_DIAG_ACTIVE | EVENT_ECU_AWAKE)) == 
   (EVENT_CAN_READY | EVENT_DIAG_ACTIVE | EVENT_ECU_AWAKE)) {
    // 三个条件都满足了,开始诊断
    startDiagnostic();
}

4.4.2 事件组的优势

特性 事件组 多个信号量
等待多个条件 原生支持,一行代码搞定 需要嵌套或轮询
与/或逻辑 通过参数切换 需要自己实现
清除事件 自动或手动 需要单独调用
性能 一次系统调用 多次系统调用

我的经验:事件组特别适合“状态机 + 多条件触发”的场景。比如汽车上的电源管理——需要等钥匙信号、CAN 唤醒、定时器超时等多个条件组合才能决定进入休眠还是唤醒。

4.5 避坑指南与性能考量

做了这么多年,我总结了几条关于 IPC 的“血泪教训”:

  • 队列深度别设太大:每个队列项都要分配内存,设大了浪费 RAM。嵌入式环境 RAM 金贵
  • 中断里只用 FromISR 版本:xQueueSendFromISR、xSemaphoreGiveFromISR,别搞混了
  • 互斥量不是万能的:优先级继承会增加调度开销。如果确定没有优先级反转风险,用二值信号量更轻量
  • 事件组位不够用:标准版只有 24 位,如果事件超过 24 个,考虑用多个事件组或者自己封装
  • 超时时间要合理:portMAX_DELAY 是死等,除非你确定事件一定会来,否则设个超时防止任务挂死

我曾经犯过的错:有一次在中断里用了 xSemaphoreGive 而不是 xSemaphoreGiveFromISR,结果系统随机死机。查了两天才发现是中断上下文调用了非 ISR 版本的 API。从那以后,我写中断代码第一件事就是检查 API 后缀。