第4章 任务间同步与通信:信号量、互斥量、消息队列、事件标志组、邮箱

多任务系统里,任务之间不是孤立的。它们需要互相配合,也需要交换数据。我见过不少新手,一上来就用全局变量加延时来做同步,结果系统跑着跑着就出各种诡异问题。说白了,RTOS 提供的那套同步通信机制,就是帮你把这些问题规范化、工程化。

这一章,我们把这五个核心机制讲透。你学完就能知道什么场景该用哪个。

4.1 信号量(Semaphore)

信号量是最基础的同步工具。它本质上是一个计数器,控制着对共享资源的访问数量。

4.1.1 二值信号量 vs 计数信号量

类型 计数值范围 典型场景
二值信号量 0 或 1 任务同步、中断与任务通信
计数信号量 0 ~ N 资源池管理、生产者-消费者

二值信号量,你可以把它想象成一个停车位。有车停进去,位子就是 0;车开走,位子变回 1。我早期做的一个数据采集项目,ADC 中断用二值信号量通知处理任务,简单可靠,从来没出过问题。

计数信号量则像停车场有多个车位。每来一辆车,信号量减一;每走一辆,加一。当信号量为 0 时,后来的车就得排队等。

核心要点:信号量只关心「有没有资源」,不关心「资源是什么」。它传递的是状态,不是数据。

4.1.2 典型用法:中断与任务同步

// 中断服务函数
void UART_IRQHandler(void) {
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    // 接收数据...
    xSemaphoreGiveFromISR(xSemaphore, &xHigherPriorityTaskWoken);
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

// 处理任务
void vDataProcessTask(void *pvParameters) {
    while(1) {
        if(xSemaphoreTake(xSemaphore, portMAX_DELAY) == pdTRUE) {
            // 处理接收到的数据
        }
    }
}

这段代码我用了很多年。中断里只做最轻量的事——给信号量。真正的数据处理交给任务。这样既保证了中断的快速响应,又避免了在中断里做复杂操作。

小技巧:中断中释放信号量一定要用带 FromISR 后缀的 API。普通版本不能在中断上下文调用,否则会触发断言。

4.2 互斥量(Mutex)

互斥量和二值信号量看起来很像,但有个本质区别——互斥量有优先级继承机制。这是为了解决优先级反转问题而设计的。

4.2.1 优先级反转与优先级继承

为什么会发生优先级反转?我举个例子你就明白了。

假设有三个任务:高优先级任务 H、中优先级任务 M、低优先级任务 L。L 先拿到了互斥量,然后 H 来了,想拿同一个互斥量,只能等。这时候 M 开始运行,它不需要互斥量,但因为它优先级比 L 高,所以 L 被抢占了。结果就是:H 在等 L,L 在等 M,H 的优先级被 M 反转了。

互斥量的优先级继承机制会临时把 L 的优先级提升到 H 的级别,这样 M 就无法抢占 L 了。L 赶紧释放互斥量,H 拿到后继续运行,一切恢复正常。

我曾经踩过的坑:在一个多传感器融合的项目里,我用了二值信号量保护共享数据,结果高优先级的控制任务经常超时。查了两天才发现是优先级反转。换成互斥量后,问题立刻消失。记住:保护共享资源,请用互斥量,别用二值信号量。

4.2.2 互斥量的使用规范

  • 谁拿谁放,不能跨任务释放
  • 持有时间尽量短,别在里面做复杂计算
  • 不要中断中使用互斥量(中断没有任务上下文)
  • 小心死锁:两个任务互相等待对方释放互斥量

4.3 消息队列(Message Queue)

消息队列是任务间传递数据的标准方式。它本质上是 FIFO 缓冲区,发送方把数据拷贝进队列,接收方从队列取出。

4.3.1 队列的工作机制

队列内部维护了一个环形缓冲区。每个消息有固定大小,队列深度决定了最多能存多少条消息。

// 创建队列,每条消息 4 字节,最多 10 条
QueueHandle_t xQueue = xQueueCreate(10, sizeof(uint32_t));

// 发送任务
void vSenderTask(void *pvParameters) {
    uint32_t ulValueToSend = 100;
    while(1) {
        xQueueSend(xQueue, &ulValueToSend, 0);
        ulValueToSend++;
        vTaskDelay(pdMS_TO_TICKS(100));
    }
}

// 接收任务
void vReceiverTask(void *pvParameters) {
    uint32_t ulReceivedValue;
    while(1) {
        if(xQueueReceive(xQueue, &ulReceivedValue, portMAX_DELAY) == pdTRUE) {
            // 处理接收到的值
        }
    }
}

你想想看,如果不用队列,用全局变量加标志位,发送方和接收方就得靠延时来「碰运气」。队列让这件事变得确定、可靠。

4.3.2 队列的变体

  • 队列集(Queue Set): 同时等待多个队列,哪个有数据就处理哪个
  • 邮件(Mailbox): 覆盖式队列,新消息会覆盖旧消息

我的经验:队列消息大小不要太大。如果数据量很大,可以考虑传递指针。但要注意指针指向的内存必须保证在接收方读取时仍然有效。

4.4 事件标志组(Event Group)

事件标志组适合「多条件同步」的场景。一个任务需要等待多个事件同时发生,或者任意一个事件发生。

4.4.1 与或逻辑

事件标志组支持两种等待模式:

  • 与模式: 所有指定的事件位都置位,任务才被唤醒
  • 或模式: 任意一个事件位置位,任务就被唤醒
#define EVENT_BIT_0 (1 << 0)
#define EVENT_BIT_1 (1 << 1)
#define EVENT_BIT_2 (1 << 2)

EventGroupHandle_t xEventGroup = xEventGroupCreate();

// 等待事件位 0 和 事件位 1 同时发生
EventBits_t uxBits = xEventGroupWaitBits(
    xEventGroup,
    EVENT_BIT_0 | EVENT_BIT_1,  // 等待的位
    pdTRUE,                      // 退出时清除这些位
    pdTRUE,                      // 与模式(全部满足)
    portMAX_DELAY
);

我在一个多传感器融合的项目里用过事件标志组。GPS、IMU、磁力计各自的数据准备好后,分别置位对应的事件位。融合任务等待三个位都置位后才开始计算。干净利落。

注意:事件标志组的事件位数量有限(通常是 24 位或 32 位)。别把每个小状态都映射成事件位,否则很快就不够用了。

4.5 邮箱(Mailbox)

邮箱和消息队列很像,但有个关键区别:邮箱只存最新的一条消息。新消息会覆盖旧消息。

4.5.1 什么时候用邮箱

  • 你只关心最新的状态,不关心历史数据
  • 数据更新频率很高,接收方来不及处理每条消息
  • 典型的场景:传感器最新读数、系统状态快照

举个例子,温度传感器每秒采集 10 次数据,但显示任务每秒只刷新一次屏幕。用消息队列的话,队列里会积压 9 条过时的数据。用邮箱就完美了——显示任务每次读取的都是最新温度值。

4.5.2 邮箱的实现方式

很多 RTOS 没有独立的邮箱 API,但可以用长度为 1 的消息队列来模拟。或者直接用全局变量加互斥量保护。我个人习惯用长度为 1 的队列,因为 API 统一,代码可读性更好。

4.6 知识体系总览

下面这张图把五种同步通信机制的关系和适用场景梳理清楚了。

任务间同步与通信机制总览 RTOS 同步通信 信号量 互斥量 消息队列 事件标志组 邮箱 核心维度:同步 vs 通信 | 数据传递 vs 状态传递 | 多条件 vs 单条件 机制对比 机制 传递内容 适用场景 特殊能力 注意事项 信号量 计数值(状态) 中断→任务同步 计数能力 无优先级继承 互斥量 所有权(状态) 保护共享资源 优先级继承 不可在中断使用 消息队列 数据(FIFO) 任务间数据传递 多消息缓存 消息大小固定 事件标志组 事件位(状态) 多条件同步 与/或逻辑 事件位数量有限 邮箱 最新数据 最新状态快照 覆盖写入 可能丢失中间数据

4.7 选型指南

说了这么多,到底怎么选?我总结了一个简单的判断流程:

  1. 需要传递数据吗? 是 → 消息队列或邮箱;否 → 信号量或事件标志组
  2. 需要保护共享资源吗? 是 → 互斥量(别用信号量)
  3. 需要等待多个条件吗? 是 → 事件标志组
  4. 只关心最新数据? 是 → 邮箱;否 → 消息队列
  5. 中断中需要同步? 是 → 信号量或消息队列(带 FromISR 后缀)

我曾经犯过的错:在一个电机控制项目里,我用消息队列传递控制指令,结果因为队列满了,新的指令发不进去,电机响应延迟。后来改成邮箱,覆盖旧指令,问题解决。记住:控制类场景,最新指令比历史指令重要得多。

这五种机制是 RTOS 同步通信的基石。你不需要一开始就全部精通,但至少要清楚每种机制解决什么问题。遇到具体场景时,能快速选出最合适的工具。这才是嵌入式工程师的核心能力。