4. 任务间通信机制:信号量、消息队列、共享内存、邮箱

各位好,欢迎来到第四章。前面我们聊了任务怎么创建、怎么调度,但任务之间总得说话吧?就像团队里每个人各干各的,但总得有人递个纸条、喊一嗓子。这一章,我们就来聊聊嵌入式系统里任务间通信的四种核心手段。

我个人习惯把任务间通信比作一个车间里的工人协作。信号量是“红绿灯”,消息队列是“传送带”,共享内存是“黑板”,邮箱是“专用信箱”。各有各的脾气,用错了地方,系统就卡壳。我在项目中遇到过不少因为通信机制选型不当导致的死锁、数据错乱问题,今天一并给大家拆开揉碎了讲。

4.1 信号量:最轻量的同步工具

信号量,说白了就是一个计数器。任务拿它来告诉别人:“我干完了,你可以上了。”或者“资源只有三个,你先等着。”

它分两种:二值信号量和计数信号量。

类型 初始值 典型用途
二值信号量 0 或 1 任务同步、中断与任务握手
计数信号量 N (资源数量) 管理有限资源池(如缓冲区、DMA通道)

举个例子,一个UART接收中断,数据来了,它释放一个二值信号量。等待这个信号量的处理任务立刻就绪,去读数据。这比轮询省电多了,也快得多。

核心要点:信号量不传递数据,只传递“状态”。它解决的是“谁先谁后”的问题,不是“传什么”的问题。

// FreeRTOS 中二值信号量的典型用法
TaskHandle_t xHandlerTask;
SemaphoreHandle_t xBinarySemaphore;

void vISR_Handler(void) {
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    // 在中断中释放信号量
    xSemaphoreGiveFromISR(xBinarySemaphore, &xHigherPriorityTaskWoken);
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

void vHandlerTask(void *pvParameters) {
    while(1) {
        // 等待信号量,阻塞等待
        xSemaphoreTake(xBinarySemaphore, portMAX_DELAY);
        // 收到信号,处理数据
        process_received_data();
    }
}

注意:千万不要在中断服务函数里调用 xSemaphoreTake()!中断里只能 Give,不能 Take。我曾经见过一个新手把 Take 放在中断里,结果系统直接挂掉——因为中断上下文不允许阻塞等待。

4.2 消息队列:带数据的“传送带”

信号量只能传个“OK”信号,但很多时候我们需要传具体的数据。比如传感器任务采集了一组温度值,要发给显示任务。这时候,消息队列就上场了。

消息队列本质上是一个先进先出(FIFO)的缓冲区,每个元素可以是一个结构体、一个指针,甚至是一个整数。任务A往队列里放,任务B从队列里取。放和取的操作都是线程安全的,由内核帮你加锁。

我建议你记住一个原则:队列里放指针,别放大数据块。为什么?因为队列拷贝的是数据本身,如果你放一个 1KB 的结构体,每次发送都要拷贝 1KB,效率很低。放指针的话,只拷贝 4 字节或 8 字节,快得多。当然,要保证指针指向的内存是稳定的。

// 定义消息结构体
typedef struct {
    uint8_t sensor_id;
    float temperature;
    uint32_t timestamp;
} SensorData_t;

// 创建队列,最多容纳 10 个消息
QueueHandle_t xSensorQueue = xQueueCreate(10, sizeof(SensorData_t));

// 发送任务
void vSensorTask(void *pvParameters) {
    SensorData_t data;
    while(1) {
        data.sensor_id = 1;
        data.temperature = read_temperature();
        data.timestamp = get_tick();
        // 发送到队列,如果队列满则等待 100ms
        xQueueSend(xSensorQueue, &data, pdMS_TO_TICKS(100));
        vTaskDelay(pdMS_TO_TICKS(50));
    }
}

// 接收任务
void vDisplayTask(void *pvParameters) {
    SensorData_t received;
    while(1) {
        // 阻塞等待消息
        if(xQueueReceive(xSensorQueue, &received, portMAX_DELAY) == pdPASS) {
            update_display(received.sensor_id, received.temperature);
        }
    }
}

小技巧:队列长度设置要合理。太短容易丢消息,太长浪费内存。我一般根据数据产生速率和处理速率来估算,再留 20% 的余量。比如传感器每秒产生 10 个数据,显示任务每秒处理 8 个,那队列长度至少设 3~5 个,避免瞬时峰值丢数据。

4.3 共享内存:最高效,也最危险

共享内存,就是两个或多个任务直接访问同一块内存区域。没有拷贝,没有中间环节,速度最快。但问题也来了:如果任务A正在写一个结构体,写到一半被任务B抢占了,任务B读到的数据就是半成品——这叫数据竞争。

所以,共享内存必须配合互斥机制使用。最常见的就是互斥量(Mutex),或者关中断、关调度器。

保护方式 开销 适用场景
互斥量 中等 多任务共享,允许阻塞
关中断 低 中断与任务共享,临界区极短
关调度器 低 任务间共享,临界区极短

嗯,这里要注意:关中断的时间绝对不能长,否则会影响系统实时性。我一般控制在几十个指令周期以内,比如就做一个变量赋值或一个标志位翻转。

经验之谈:能用消息队列就别用共享内存。共享内存虽然快,但调试起来非常痛苦。数据错乱了,你根本不知道是哪个任务什么时候写的。消息队列有明确的“生产者-消费者”边界,问题定位容易得多。

4.4 邮箱:轻量级的“专用信箱”

邮箱这个概念,在有些RTOS里也叫“消息邮箱”或“Mailbox”。它和消息队列很像,但有一个关键区别:邮箱只能存一条消息。新消息会覆盖旧消息。

你想想看,什么时候用邮箱合适?比如一个任务需要知道当前的系统状态,状态值只有一个,而且总是最新的才有意义。用队列的话,你还得清空旧数据,麻烦。用邮箱,直接覆盖,读取任务拿到的一定是最新值。

我在一个电机控制项目里用过邮箱。速度环计算任务每 1ms 算出一个新的目标转速,发给电流环任务。电流环只关心最新的目标值,历史值没用。邮箱正好满足这个需求。

// uC/OS-III 中的邮箱示例
OS_MAILBOX MyMailbox;
CPU_INT08U MyMailboxBuffer[sizeof(uint32_t)];

void task_speed_calc(void *p_arg) {
    uint32_t target_speed;
    while(1) {
        target_speed = calculate_target_speed();
        // 发送到邮箱,覆盖旧值
        OSMboxPost(&MyMailbox, (void *)&target_speed);
        OSTimeDlyHMSM(0, 0, 0, 1); // 1ms
    }
}

void task_current_ctrl(void *p_arg) {
    uint32_t *p_speed;
    OS_ERR err;
    while(1) {
        // 等待邮箱消息,阻塞
        p_speed = (uint32_t *)OSMboxPend(&MyMailbox, 0, OS_OPT_PEND_BLOCKING, &err);
        if(err == OS_ERR_NONE) {
            set_target_current(*p_speed);
        }
    }
}

避坑指南:我曾经在一个项目里用邮箱传递结构体指针,结果发送任务在发送后修改了结构体内容,接收任务拿到的数据就错了。记住,邮箱传递的是指针的副本,但指针指向的内容还是同一块内存。如果发送任务后续还会修改这块内存,一定要加锁,或者发送后不再修改。

4.5 四种机制对比与选型

好了,四种手段都讲完了。我们来个总结对比,方便你以后选型。

机制 传递数据 容量 速度 典型场景
信号量 否 计数器 极快 任务同步、资源计数
消息队列 是 多条 快 数据流、事件驱动
共享内存 是 自定义 最快 大数据块、高频访问
邮箱 是 1条 快 最新状态、控制参数

选型的时候,我一般按这个思路走:

  1. 要不要传数据? 不要 → 信号量
  2. 要传数据,数据量多大? 小且频繁 → 邮箱;大或不定量 → 消息队列或共享内存
  3. 对实时性要求多高? 极高且数据量大 → 共享内存 + 互斥量;一般 → 消息队列
  4. 调试方便重要吗? 重要 → 优先消息队列

4.6 知识结构图

下面这张图把四种机制的关系和适用边界画出来了,你可以存下来当参考。

任务间通信机制结构图 任务间通信机制全景图 任务间通信 信号量 消息队列 共享内存 邮箱 特性 不传数据,只传状态 二值/计数两种模式 特性 FIFO,可存多条 数据拷贝或传指针 特性 最快,无拷贝 需互斥保护 特性 只存最新一条 覆盖旧消息 选型建议 同步用信号量 | 数据流用队列 | 高频大数据用共享内存 | 最新状态用邮箱

这张图把四种机制放在一起对比,特性一目了然。你写代码前,先对着这张图想清楚:我到底需要什么?是同步,还是传数据?传的数据是流式的,还是只关心最新的?想清楚了,选型就不会错。

最后说一句:没有银弹。每种机制都有它的适用场景。我的建议是,先从消息队列开始用起,它最通用、最安全。等你对系统有了更深的理解,再根据性能需求去尝试共享内存或邮箱。别一上来就搞共享内存,调试起来真的会让人头秃。