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 连接)、事件标志组(等待多个设备就绪)和邮箱(传递当前模式)。每种机制各司其职,系统才跑得稳。