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 避坑总结

最后,我把自己踩过的坑总结一下,你记好了:

  1. 不要在中断里用阻塞调用——take、pend这些函数在中断里调用会出问题。
  2. 注意优先级反转——保护共享资源时,用互斥锁而不是二值信号量。
  3. 消息队列深度要留余量——我一般按理论峰值的1.5倍来设。
  4. 事件标志组别用太多位——32位够用了,别搞什么64位自定义扩展,移植性会变差。
  5. 邮箱传指针要小心生命周期——确保发送方的数据在接收方读取前不会被释放。

一句话总结:信号量做同步,互斥锁护资源,消息队列传数据,事件标志组等条件,邮箱存最新。选对了工具,你的系统就稳了一半。


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