4、任务间通信机制:信号量、互斥锁、消息队列、事件标志组、邮箱
各位同学,咱们今天聊聊任务间通信。说白了,就是多个任务之间怎么「说话」、怎么「传纸条」。你想想看,一个实时系统里少说十几个任务,多的上百个,它们各自跑各自的,但数据总要共享、动作总要同步吧?
我刚开始做嵌入式那会儿,就吃过这个亏。两个任务同时操作一个全局变量,结果数据乱得一塌糊涂。后来才明白——任务间通信不是「要不要用」的问题,而是「怎么用对」的问题。
4.1 信号量:最基础的同步工具
信号量,我习惯叫它「红绿灯」。它不传递数据,只传递一个信号——「资源可用」或者「事件发生」。
信号量分两种:二值信号量和计数信号量。
- 二值信号量:只有0和1两个状态。适合做互斥或同步。比如一个任务等按键按下,另一个任务检测按键,按下后释放信号量,等待任务就能继续跑。
- 计数信号量:可以累加。适合管理多个同类资源。比如一个串口DMA缓冲区有10个槽位,生产者放一个就give一次,消费者取一个就take一次。
核心要点:信号量只做「通知」,不做「数据搬运」。你要是想传数据,别用信号量。
// 伪代码示例:二值信号量同步
SemaphoreHandle_t semKeyPress;
void Task_KeyScan(void *pvParameters) {
while(1) {
if(KEY_Pressed()) {
xSemaphoreGive(semKeyPress); // 释放信号量
}
vTaskDelay(10);
}
}
void Task_ProcessKey(void *pvParameters) {
while(1) {
xSemaphoreTake(semKeyPress, portMAX_DELAY); // 等待按键
// 处理按键逻辑
}
}
我的习惯:信号量give和take一定要成对出现。我曾经见过一个项目,give了三次只take了两次,最后信号量计数乱掉,整个系统行为变得诡异。排查了两天才找到原因。
4.2 互斥锁:解决优先级反转
互斥锁和信号量长得很像,但有个关键区别——互斥锁自带优先级继承机制。
为什么会需要这个?我给你讲个真实案例。
有一次我做电机控制,三个任务:高优先级的急停任务、中优先级的显示任务、低优先级的参数计算任务。它们共享一个数据结构,我用二值信号量保护。结果呢?低优先级任务拿到锁后,被中优先级任务抢占了CPU,高优先级任务只能干等着。这就是经典的优先级反转。
互斥锁的优先级继承能解决这个问题:当低优先级任务持有锁时,如果有高优先级任务在等锁,系统会临时把低优先级任务的优先级提升到和高优先级一样高,让它尽快跑完释放锁。
| 特性 | 二值信号量 | 互斥锁 |
|---|---|---|
| 优先级继承 | 不支持 | 支持 |
| 适用场景 | 同步、事件通知 | 资源互斥访问 |
| 递归加锁 | 不支持 | 部分OS支持 |
注意:不要在中断服务函数里使用互斥锁!中断上下文不能阻塞等待。如果你需要在中断和任务之间同步,用信号量或者直接发消息队列。
4.3 消息队列:传数据的主力
消息队列是我用得最多的通信方式。它就像一个邮筒——你把信投进去,收信人什么时候来取都行。
消息队列有几个关键参数:消息长度、队列深度、出队入队方式。
- 消息长度:每条消息的字节数。我建议传指针而不是传整个结构体,效率高很多。
- 队列深度:最多能存多少条消息。这个要根据数据产生速率和消费速率来算,别拍脑袋定。
- 出队入队方式:FIFO(先进先出)还是LIFO(后进先出)。大部分场景用FIFO。
// 消息队列典型用法
QueueHandle_t xMsgQueue;
// 发送任务
void Task_Sender(void *pvParameters) {
Msg_t msg;
while(1) {
msg.id = 0x01;
msg.value = sensor_read();
xQueueSend(xMsgQueue, &msg, 0); // 入队
vTaskDelay(100);
}
}
// 接收任务
void Task_Receiver(void *pvParameters) {
Msg_t msg;
while(1) {
if(xQueueReceive(xMsgQueue, &msg, pdMS_TO_TICKS(1000))) {
process_msg(&msg); // 处理消息
}
}
}
避坑指南:我曾经把队列深度设得太小,结果高频发送时消息频繁丢失。后来加了个统计机制——每次发送失败就计数,调试时一看,好家伙,丢包率30%。所以建议你在调试阶段把队列深度设大一点,稳定后再优化。
4.4 事件标志组:一对多的通知
事件标志组,说白了就是一个32位的位图。每一位代表一个事件。一个任务可以等多个事件中的任意一个,或者等所有事件都发生。
这玩意儿特别适合「多条件触发」的场景。比如一个数据采集任务,要等「定时器到」和「缓冲区有空位」两个条件都满足才能启动。
// 事件标志组示例
EventGroupHandle_t xEventGroup;
#define BIT_TIMEOUT (1 << 0)
#define BIT_BUF_READY (1 << 1)
void Task_WaitEvents(void *pvParameters) {
EventBits_t bits;
while(1) {
// 等待两个事件都发生
bits = xEventGroupWaitBits(
xEventGroup,
BIT_TIMEOUT | BIT_BUF_READY,
pdTRUE, // 退出时清除标志位
pdTRUE, // 等待所有位
portMAX_DELAY
);
// 开始采集数据
}
}
我的建议:事件标志组适合「事件驱动」的设计模式。但注意,它不携带数据,只告诉你「发生了什么事」。如果你需要知道「发生了什么事,数据是什么」,请用消息队列。
4.5 邮箱:轻量级消息传递
邮箱,有些RTOS里也叫「消息邮箱」或「Mailbox」。它和消息队列的区别在于——邮箱一次只能存一条消息,而且新消息会覆盖旧消息。
说白了,邮箱就是「最新值」的传递方式。适合那种「我只关心最新状态,不关心历史数据」的场景。比如系统当前温度、电机当前转速。
// 邮箱操作示例(以uC/OS-III为例)
OS_MBOX mboxTemp;
void Task_UpdateTemp(void *p_arg) {
float temp;
while(1) {
temp = read_temperature();
OSMboxPost(&mboxTemp, (void *)&temp);
OSTimeDlyHMSM(0, 0, 1, 0);
}
}
void Task_DisplayTemp(void *p_arg) {
float *p_temp;
while(1) {
p_temp = (float *)OSMboxPend(&mboxTemp, 0, OS_OPT_PEND_BLOCKING, NULL);
display_temperature(*p_temp);
}
}
注意:邮箱传的是指针,你要确保指针指向的内存是有效的。我见过有人传了局部变量的地址,函数返回后指针就变成野指针了。建议用全局变量或者动态分配的内存。
4.6 如何选择?一张图说清楚
下面这张图是我自己总结的选型思路,你照着选基本不会错。
嗯,这张图基本覆盖了90%的场景。剩下的10%是特殊情况,比如你需要同时传数据和做同步,那就消息队列+信号量组合使用。
4.7 避坑总结
最后,我把自己踩过的坑总结一下,你记好了:
- 不要在中断里用阻塞调用——take、pend这些函数在中断里调用会出问题。
- 注意优先级反转——保护共享资源时,用互斥锁而不是二值信号量。
- 消息队列深度要留余量——我一般按理论峰值的1.5倍来设。
- 事件标志组别用太多位——32位够用了,别搞什么64位自定义扩展,移植性会变差。
- 邮箱传指针要小心生命周期——确保发送方的数据在接收方读取前不会被释放。
一句话总结:信号量做同步,互斥锁护资源,消息队列传数据,事件标志组等条件,邮箱存最新。选对了工具,你的系统就稳了一半。
公众号:蓝海资料掘金营,微信deep3321