3. 任务间通信:队列、信号量、互斥量、事件标志组、消息邮箱

各位同学,咱们今天聊点实在的。任务间通信,说白了就是让多个任务能“好好说话”。

我刚开始做RTOS移植那会儿,总觉得任务就是各跑各的,互不干扰多好。后来发现,现实世界哪有这么简单?一个传感器采集数据,一个任务处理数据,一个任务控制输出——它们之间必须交换信息。没有通信机制,系统就是一盘散沙。

嗯,咱们一个一个来看。

3.1 队列——最常用的“快递通道”

队列是什么?我习惯把它想象成一个“快递传送带”。一个任务往上面放包裹(发送数据),另一个任务从上面取包裹(接收数据)。先进先出,规矩得很。

核心要点:队列是RTOS中最基础、最灵活的任务间通信方式。它支持多个任务同时发送和接收,数据是拷贝传递的,不是指针传递。

我在项目中遇到过一个问题:两个任务同时往一个队列里写数据,结果数据乱了。后来才发现,队列本身是线程安全的,但如果你在中断里操作队列,就得小心了——有些RTOS的队列API在中断里不能用。

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

// 任务A:发送数据
int data = 100;
xQueueSend(xQueue, &data, portMAX_DELAY);

// 任务B:接收数据
int received;
if (xQueueReceive(xQueue, &received, pdMS_TO_TICKS(100)) == pdPASS) {
    // 成功接收到数据
    printf("Received: %d\n", received);
}

我的小技巧:队列深度别设太大,够用就行。我曾经为了“保险”设了个1000的深度,结果内存爆了。一般10-20就够用,除非你明确知道数据量很大。

3.2 信号量——轻量级的“资源令牌”

信号量,说白了就是个计数器。它不传递数据,只传递“有没有资源可用”这个信息。

你想想看,一个任务要访问共享资源,它先拿信号量。拿到了,说明资源空闲;拿不到,就等着。用完了,释放信号量。

信号量分两种:

  • 二值信号量:只有0和1两种状态。适合做“互斥锁”的轻量替代,或者做“事件通知”。
  • 计数信号量:可以计数到N。适合管理多个同类资源,比如5个缓冲区。

注意:信号量不能用于中断服务程序和任务之间的同步?其实可以,但要注意——在中断中释放信号量,任务中获取信号量,这是标准用法。反过来就不行了。

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

// 任务中等待信号量
if (xSemaphoreTake(xSemaphore, portMAX_DELAY) == pdPASS) {
    // 拿到信号量,执行操作
}

// 中断中释放信号量
BaseType_t xHigherPriorityTaskWoken = pdFALSE;
xSemaphoreGiveFromISR(xSemaphore, &xHigherPriorityTaskWoken);
portYIELD_FROM_ISR(xHigherPriorityTaskWoken);

3.3 互斥量——解决“谁先谁后”的问题

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

为什么会这样?我举个例子。假设低优先级任务拿了互斥量,高优先级任务来抢,结果高优先级任务被阻塞了。这时候,低优先级任务会临时“继承”高优先级任务的优先级,尽快执行完释放互斥量。这就是优先级继承,能有效避免“优先级反转”。

我曾经在一个项目中,三个任务共享一个串口。没用互斥量,结果高优先级任务被低优先级任务堵死,系统响应慢得像蜗牛。换成互斥量后,问题立刻解决。

记住:互斥量只能在任务中使用,不能在中断中使用。而且,谁拿了谁释放,不能跨任务释放。

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

// 任务中获取互斥量
if (xSemaphoreTake(xMutex, portMAX_DELAY) == pdPASS) {
    // 访问共享资源(比如串口)
    printf("Critical section\n");
    // 释放互斥量
    xSemaphoreGive(xMutex);
}

3.4 事件标志组——多条件触发的“信号灯”

事件标志组,我管它叫“多路信号灯”。一个任务可以等待多个事件,只要其中某个事件发生(或者全部发生),任务就被唤醒。

你想想看,一个控制任务要等“温度过高”和“压力过大”两个条件都满足才报警。用事件标志组,一个位代表一个条件,两个位都置1,任务就执行。

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

  • 逻辑与:所有指定的事件位都置1,才唤醒任务。
  • 逻辑或:任意一个指定的事件位置1,就唤醒任务。
// 创建事件标志组
EventGroupHandle_t xEventGroup;
xEventGroup = xEventGroupCreate();

// 任务中等待事件
EventBits_t uxBits;
uxBits = xEventGroupWaitBits(
    xEventGroup,      // 事件组句柄
    BIT_0 | BIT_1,    // 等待位0和位1
    pdTRUE,           // 退出时清除位
    pdTRUE,           // 逻辑与模式(两个位都置1才唤醒)
    portMAX_DELAY     // 无限等待
);

// 其他任务或中断中设置事件
xEventGroupSetBits(xEventGroup, BIT_0);

避坑指南:我曾经在中断里用事件标志组唤醒任务,结果忘了清除事件位,导致任务被反复唤醒。记得在等待时设置pdTRUE来清除位,或者在处理完后手动清除。

3.5 消息邮箱——点对点的“专属信箱”

消息邮箱,说白了就是“一对一”的通信方式。一个任务往邮箱里放消息,另一个任务从邮箱里取消息。邮箱里只能存一条消息,新的会覆盖旧的。

我习惯把消息邮箱用在“状态更新”的场景。比如一个任务不断采集温度,另一个任务只关心最新温度。用队列的话,旧数据会堆积;用邮箱,永远只保留最新值。

消息邮箱的实现方式:

  • 有些RTOS直接提供邮箱API(如uC/OS-II)
  • 有些RTOS没有邮箱,可以用长度为1的队列模拟
// 用长度为1的队列模拟消息邮箱
QueueHandle_t xMailbox;
xMailbox = xQueueCreate(1, sizeof(int));

// 发送方:覆盖写入
int newValue = 42;
xQueueOverwrite(xMailbox, &newValue);

// 接收方:读取最新值
int latestValue;
if (xQueuePeek(xMailbox, &latestValue, 0) == pdPASS) {
    // 读取但不移除
    printf("Latest: %d\n", latestValue);
}

3.6 如何选择?一张表说清楚

通信方式 传递数据 通信关系 典型场景
队列 是(拷贝数据) 多对多 数据流、命令传递
信号量 否(仅计数) 多对多 资源管理、事件通知
互斥量 否(仅锁) 多对一 共享资源保护
事件标志组 否(仅标志位) 多对一 多条件触发
消息邮箱 是(覆盖写入) 一对一 状态更新、最新值传递

3.7 知识体系图

下面这张图,是我自己总结的任务间通信知识体系。你看一眼,心里就有数了。

任务间通信机制 队列 数据流传递 多对多通信 先进先出 信号量 资源计数 事件通知 二值/计数 互斥量 资源保护 优先级继承 不可在中断使用 事件标志组 多条件触发 逻辑与/或 位标志操作 消息邮箱 一对一通信 覆盖写入 最新值保留 选择原则 传递数据用队列或邮箱 | 资源保护用互斥量 | 事件通知用信号量或事件标志组

嗯,这张图把五种通信方式的关系和适用场景都串起来了。你保存下来,以后选型时拿出来看一眼,基本不会选错。

最后说一句:没有最好的通信方式,只有最合适的。我个人的习惯是:能用队列解决的,优先用队列;需要保护资源的,用互斥量;只是通知一下的,用信号量。简单粗暴,但好用。


公众号:蓝海资料掘金营,微信deep3321