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