4. FreeRTOS 任务同步与通信:队列、信号量、互斥量、事件组、任务通知

多任务系统里,任务之间不是孤立的。它们需要交换数据,需要互相知会状态,也需要协调对共享资源的访问。这就是同步与通信要解决的问题。

FreeRTOS 提供了五种主要机制:队列、信号量、互斥量、事件组和任务通知。我刚开始用 RTOS 时,总觉得这些概念差不多,选哪个都行。后来踩过坑才明白——选错了,系统可能跑得磕磕绊绊,甚至死锁。

这一章,我把它们掰开揉碎讲清楚。

4.1 队列(Queue)——任务间传递数据的核心

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

核心特性:

  • 数据传递是拷贝式的——发送方把数据拷进队列,接收方从队列拷走数据
  • 支持多个发送者和多个接收者
  • 读空队列或写满队列时,任务可以阻塞等待

我在项目中遇到过一个问题:两个任务之间传递一个结构体,结构体里有个指针。发送方把指针地址发过去了,接收方拿着地址去读数据——结果发送方早就把数据释放了。这就是拷贝式传递的陷阱。记住,队列只拷贝指针本身,不拷贝指针指向的内容。

创建队列的 API 很简单:

// 创建一个能存放 5 个 uint32_t 类型数据的队列
QueueHandle_t xQueue;
xQueue = xQueueCreate(5, sizeof(uint32_t));

// 发送数据(在任务A中)
uint32_t data = 100;
xQueueSend(xQueue, &data, portMAX_DELAY);

// 接收数据(在任务B中)
uint32_t received;
xQueueReceive(xQueue, &received, portMAX_DELAY);

注意 portMAX_DELAY 这个参数。它表示如果队列满或空,任务会一直等下去。实际项目中我建议给个超时时间,别死等——万一出问题,整个系统就卡死了。

4.2 信号量(Semaphore)——轻量级的同步工具

信号量分两种:二值信号量和计数信号量。

类型 用途 典型场景
二值信号量 任务间或中断与任务间的同步 外设数据准备好,通知任务处理
计数信号量 资源计数或事件计数 管理有限数量的缓冲区

二值信号量其实就是一个只有 0 和 1 的队列。你想想看,如果只是为了通知任务“有数据来了”,用队列传一个空数据包是不是有点浪费?信号量就是干这个的。

我的习惯:中断服务程序里只用 xSemaphoreGiveFromISR(),绝对不用 xSemaphoreGive()。后者会导致调度器在中断上下文中被调用,后果很严重。

计数信号量的用法:

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

// 任务A:生产资源
xSemaphoreGive(xSemaphore);

// 任务B:消费资源
if (xSemaphoreTake(xSemaphore, pdMS_TO_TICKS(100)) == pdTRUE) {
    // 拿到了资源,开始处理
}

4.3 互斥量(Mutex)——解决资源竞争

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

为什么会这样?假设低优先级任务拿了锁,高优先级任务在等锁。如果没有优先级继承,低优先级任务可能被中优先级任务抢占,导致高优先级任务一直等下去——这就是优先级反转。

互斥量的优先级继承机制会临时提升低优先级任务的优先级,让它尽快释放锁。

我曾经踩过的坑:在中断里调用 xSemaphoreGive() 给互斥量。互斥量不能在中断中使用!中断里只能用二值信号量或计数信号量。互斥量的优先级继承机制依赖任务调度,中断上下文里没有这个条件。

互斥量的使用示例:

// 创建互斥量
SemaphoreHandle_t xMutex;
xMutex = xSemaphoreCreateMutex();

// 任务A:访问共享资源
void vTaskA(void *pvParameters) {
    for (;;) {
        xSemaphoreTake(xMutex, portMAX_DELAY);
        // 操作共享资源(比如写UART)
        UART_Write("Hello");
        xSemaphoreGive(xMutex);
        vTaskDelay(pdMS_TO_TICKS(100));
    }
}

4.4 事件组(Event Group)——多事件同步

有时候一个任务需要等待多个条件同时满足,或者等待任意一个条件满足。用信号量实现会很麻烦——你得创建多个信号量,然后轮询。事件组就是专门解决这个问题的。

事件组里的每个位代表一个事件。你可以设置位、清除位,也可以等待多个位同时置位或任意位置位。

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

// 任务A:设置事件位
xEventGroupSetBits(xEventGroup, BIT_0 | BIT_1);

// 任务B:等待 BIT_0 和 BIT_1 同时置位
EventBits_t uxBits;
uxBits = xEventGroupWaitBits(
    xEventGroup,       // 事件组句柄
    BIT_0 | BIT_1,     // 等待的位
    pdTRUE,            // 退出时清除这些位
    pdTRUE,            // 等待所有位都置位
    portMAX_DELAY      // 超时时间
);

第三个参数 pdTRUE 表示退出时自动清除这些位。我建议一般设为 pdTRUE,否则你得手动清除,容易漏掉。

事件组 vs 信号量:

  • 信号量适合“单一事件”的同步
  • 事件组适合“多个事件组合”的同步
  • 事件组可以在中断中设置位,但不能在中断中等待

4.5 任务通知(Task Notification)——最高效的通信方式

任务通知是 FreeRTOS 里最轻量级的同步机制。每个任务内部都有一个 32 位的通知值和 8 位的通知状态。你可以直接给某个任务发通知,不需要创建队列或信号量。

它的优势很明显:

  • 不需要额外的内存分配
  • 速度比信号量快约 45%
  • 支持多种操作:设置位、增加值、覆写值等

但有个限制:只能一对一通信。你不能用任务通知实现多个任务等待同一个事件。

// 任务A:给任务B发通知
TaskHandle_t xTaskBHandle;

void vTaskA(void *pvParameters) {
    for (;;) {
        // 做一些工作
        xTaskNotifyGive(xTaskBHandle);
        vTaskDelay(pdMS_TO_TICKS(100));
    }
}

void vTaskB(void *pvParameters) {
    uint32_t ulNotifiedValue;
    for (;;) {
        // 等待通知,超时时间 500ms
        ulNotifiedValue = ulTaskNotifyTake(pdTRUE, pdMS_TO_TICKS(500));
        if (ulNotifiedValue > 0) {
            // 收到了通知,开始处理
        }
    }
}

我个人习惯在中断里优先使用任务通知。它比信号量更快,而且不需要创建额外的对象。但要注意,任务通知的 32 位值如果用作计数,最大只能到 2^32 - 1,别溢出了。

4.6 如何选择?一张图说清楚

下面这张图总结了五种机制的适用场景:

FreeRTOS 任务同步与通信机制选择指南 任务需要什么? 需要传递数据?→ 队列 只需要同步?→ 信号量/任务通知 保护共享资源?→ 互斥量 数据量大?考虑用流缓冲区 一对一?→ 任务通知(最快) 一对多?→ 信号量/事件组 中断中保护?用临界区 选择建议 • 能选任务通知就别选信号量——更快、更省内存 • 保护资源用互斥量,别用二值信号量——优先级继承很重要 • 多事件等待用事件组,别自己写轮询——代码更清晰

4.7 避坑指南

最后,分享几个我实际项目中遇到的坑:

  • 队列长度设太小:发送方写数据比接收方读数据快,队列满了,发送方阻塞——结果整个系统被拖慢。我建议根据数据峰值速率计算队列长度,留 20% 余量。
  • 互斥量死锁:两个任务各自持有一个互斥量,又在等对方释放。解决办法是:统一锁的获取顺序,或者用 xSemaphoreTake() 加超时。
  • 中断里调用阻塞 API:中断里绝对不能调用 portMAX_DELAY 的等待函数。中断上下文没有任务调度,一调用就死机。
  • 任务通知值溢出:如果频繁发通知但不消费,32 位值会溢出。我习惯在接收方用 ulTaskNotifyTake(pdTRUE, 0) 轮询,确保通知值不会堆积。

嗯,这五种机制用好了,你的 FreeRTOS 系统会非常稳健。选对工具,事半功倍。