4. 任务间通信机制:全局变量、信号量、消息队列、事件标志组、邮箱的适用场景与实现

做单片机多任务调度,说白了就是让几个任务“搭伙过日子”。但任务之间总得说话吧?你干完了我接着干,你用了资源我得等着,你出了数据我得拿走——这些就是任务间通信要解决的问题。

我刚开始搞 RTOS 那会儿,觉得通信嘛,不就是全局变量一扔,谁用谁拿?后来被坑了几次才明白,每种机制都有自己的脾气。选对了,系统稳如老狗;选错了,bug 能查到你怀疑人生。

这一章,我把五种常用通信机制掰开揉碎讲清楚。你想想看,它们就像工具箱里的五把螺丝刀——没有好坏之分,只有用对地方。

4.1 全局变量:最原始,也最容易翻车

全局变量,说白了就是一块所有任务都能读写的内存。简单粗暴,零开销。

适用场景:

  • 一个任务写,一个任务读(单生产者-单消费者)
  • 数据量小,比如一个标志位、一个计数值
  • 对实时性要求不高,偶尔丢一次数据也能接受

我在项目中遇到过这么个事:两个任务共享一个 uint8_t 的传感器状态,一个任务每 10ms 更新,另一个任务每 100ms 读取显示。跑得好好的,突然有一天显示乱跳。查了半天,发现是读任务在读取过程中,写任务刚好更新了数据——读到了半个旧值半个新值。这就是经典的“读-改-写”冲突。

避坑指南:

我曾经在一个电机控制项目里用全局变量传转速值,结果电机偶尔抽风。后来发现是 16 位的转速变量在 8 位单片机上需要两次读取,中间被中断打断就坏了事。解决办法?要么关中断读,要么用 volatile 加原子操作。

// 不安全的全局变量访问
uint16_t g_motor_speed;

// Task A - 写入
void task_writer(void *arg) {
    g_motor_speed = get_sensor_value();  // 可能被中断打断
}

// Task B - 读取
void task_reader(void *arg) {
    uint16_t speed = g_motor_speed;  // 可能读到错误值
    display_speed(speed);
}

// 安全的做法:关中断保护
void task_reader_safe(void *arg) {
    uint16_t speed;
    enter_critical();
    speed = g_motor_speed;
    exit_critical();
    display_speed(speed);
}

4.2 信号量:资源管理的看门人

信号量,你可以把它想象成一个停车场的空位计数器。任务想用资源,先拿一个信号量;用完了还回去。没信号量了?那就等着。

我习惯把信号量分成两种:

类型 初始值 典型用途
二值信号量 0 或 1 互斥访问、事件通知
计数信号量 N 管理多个同类资源

我的经验:信号量最适合做“资源有没有”的同步,不适合传数据。你要是想传一个具体的数值,别用信号量,用消息队列。

// 二值信号量示例:UART 发送完成通知
SemaphoreHandle_t xUartSemaphore;

void uart_tx_complete_isr(void) {
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    xSemaphoreGiveFromISR(xUartSemaphore, &xHigherPriorityTaskWoken);
    portYIELD_FROM_ISR(xHigherPriorityTaskWoken);
}

void task_send_data(void *arg) {
    while (1) {
        // 等待上一次发送完成
        if (xSemaphoreTake(xUartSemaphore, portMAX_DELAY) == pdTRUE) {
            uart_send(next_packet());
        }
    }
}

4.3 消息队列:任务间的快递员

消息队列,就是给任务之间递纸条。一个任务把数据塞进队列,另一个任务从队列里取。数据不会丢,顺序也不会乱。

我个人最喜欢用消息队列。为什么?因为它自带缓冲,生产者快的时候数据能暂存,消费者忙的时候不会丢消息。这在传感器采集、按键处理、通信协议栈里特别好用。

什么时候该用消息队列:

  • 数据有明确的“消息”边界,比如一个数据包、一条指令
  • 需要多个任务往同一个地方发数据
  • 生产者和消费者速率不匹配
// 消息队列:按键任务 -> 处理任务
typedef struct {
    uint8_t key_code;
    uint8_t press_duration;
} KeyEvent_t;

QueueHandle_t xKeyQueue;

void task_key_scan(void *arg) {
    KeyEvent_t event;
    while (1) {
        if (key_pressed()) {
            event.key_code = get_key();
            event.press_duration = measure_duration();
            // 发送到队列,超时 10ms
            xQueueSend(xKeyQueue, &event, pdMS_TO_TICKS(10));
        }
        vTaskDelay(pdMS_TO_TICKS(5));
    }
}

void task_key_handler(void *arg) {
    KeyEvent_t event;
    while (1) {
        if (xQueueReceive(xKeyQueue, &event, portMAX_DELAY) == pdTRUE) {
            process_key_event(&event);
        }
    }
}

注意:队列深度别设太小。我曾经在一个项目里把队列深度设成 2,结果按键按快了就丢消息。后来改成 8,问题解决。一般建议至少能缓冲 3-5 个消息,具体看你的数据峰值速率。

4.4 事件标志组:多条件触发的开关板

事件标志组,就是一块有多个 bit 的状态板。每个 bit 代表一个事件。任务可以等“任意一个事件”发生,也可以等“所有事件”都发生。

你想想看,有时候一个任务要等好几个条件都满足了才能干活。比如一个数据采集任务,要等“传感器就绪”、“缓冲区有空位”、“定时器到点”三个条件同时满足。用信号量你得等三个,用事件标志组一个函数搞定。

特性 事件标志组 信号量
可等待条件数 多个(按位组合) 单个
条件逻辑 与 / 或 / 任意 只有“有/无”
是否消耗事件 可配置(手动清除) 自动消耗
// 事件标志组:等待多个传感器就绪
#define EVENT_SENSOR1_READY  (1 << 0)
#define EVENT_SENSOR2_READY  (1 << 1)
#define EVENT_TIMER_READY    (1 << 2)

EventGroupHandle_t xEventGroup;

void task_sensor1(void *arg) {
    while (1) {
        sensor1_collect();
        xEventGroupSetBits(xEventGroup, EVENT_SENSOR1_READY);
        vTaskDelay(pdMS_TO_TICKS(100));
    }
}

void task_collector(void *arg) {
    EventBits_t bits;
    while (1) {
        // 等待三个事件都发生
        bits = xEventGroupWaitBits(
            xEventGroup,
            EVENT_SENSOR1_READY | EVENT_SENSOR2_READY | EVENT_TIMER_READY,
            pdTRUE,   // 退出时清除所有等待的位
            pdTRUE,   // 等待所有位都置位
            portMAX_DELAY
        );
        
        if ((bits & (EVENT_SENSOR1_READY | EVENT_SENSOR2_READY | EVENT_TIMER_READY))
            == (EVENT_SENSOR1_READY | EVENT_SENSOR2_READY | EVENT_TIMER_READY)) {
            do_data_fusion();
        }
    }
}

4.5 邮箱:带数据的信号量

邮箱,有些人也叫它“消息邮箱”。它跟消息队列有点像,但每次只能存一条消息。你可以把它理解成“带一个数据槽的信号量”。

我一般在两种情况下用邮箱:

  • 只需要传递最新值,旧值丢了无所谓(比如当前温度、当前电压)
  • 数据更新频率高,消费者来不及处理每条消息

我的习惯:如果数据是“状态”而不是“事件”,用邮箱。比如系统当前模式、当前报警码。如果是“事件序列”,比如按键按下、数据包到达,用消息队列。

// 邮箱示例:传递最新温度值
MailboxHandle_t xTempMailbox;

void task_temp_sensor(void *arg) {
    int16_t temp;
    while (1) {
        temp = read_temperature();
        // 覆盖写入,只保留最新值
        xMailboxOverwrite(xTempMailbox, &temp);
        vTaskDelay(pdMS_TO_TICKS(50));
    }
}

void task_display(void *arg) {
    int16_t temp;
    while (1) {
        // 读取最新值,如果没有新值则阻塞
        if (xMailboxFetch(xTempMailbox, &temp, portMAX_DELAY) == pdTRUE) {
            update_display(temp);
        }
    }
}

4.6 怎么选?一张图说清楚

说了这么多,到底怎么选?我画了张图,帮你快速决策。

任务间通信机制选择决策树 需要传数据吗? 不传数据(只同步) 单个资源 → 二值信号量 多个资源 → 计数信号量 传数据 只保留最新值 → 邮箱 需要缓冲 → 消息队列 需要等多个条件? 是 → 事件标志组 否 → 信号量/邮箱/队列 💡 选型口诀 传数据用队列/邮箱,不传数据用信号量;多条件等待用事件组,简单共享用全局变量但要加保护

4.7 总结:选型对照表

最后,我把五种机制放在一张表里,方便你对照选型。

通信机制 传数据 数据缓冲 多条件等待 典型开销 我最常用的场景
全局变量 ✓ ✗ ✗ 0(需加保护) 系统状态标志、调试计数
二值信号量 ✗ ✗ ✗ 低 中断通知任务、互斥访问
计数信号量 ✗ ✓(计数) ✗ 低 管理缓冲池、资源计数
消息队列 ✓ ✓(FIFO) ✗ 中 按键事件、通信数据包
事件标志组 ✗ ✗ ✓ 低 多传感器同步、多条件触发
邮箱 ✓ ✗(覆盖) ✗ 低 最新状态值、当前配置

最后说一句:别想着一个项目只用一种通信机制。我做过一个智能家居网关,同时用了消息队列(传传感器数据)、信号量(同步 WiFi 连接)、事件标志组(等待多个设备就绪)和邮箱(传递当前模式)。每种机制各司其职,系统才跑得稳。