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条 | 快 | 最新状态、控制参数 |
选型的时候,我一般按这个思路走:
- 要不要传数据? 不要 → 信号量
- 要传数据,数据量多大? 小且频繁 → 邮箱;大或不定量 → 消息队列或共享内存
- 对实时性要求多高? 极高且数据量大 → 共享内存 + 互斥量;一般 → 消息队列
- 调试方便重要吗? 重要 → 优先消息队列
4.6 知识结构图
下面这张图把四种机制的关系和适用边界画出来了,你可以存下来当参考。
这张图把四种机制放在一起对比,特性一目了然。你写代码前,先对着这张图想清楚:我到底需要什么?是同步,还是传数据?传的数据是流式的,还是只关心最新的?想清楚了,选型就不会错。
最后说一句:没有银弹。每种机制都有它的适用场景。我的建议是,先从消息队列开始用起,它最通用、最安全。等你对系统有了更深的理解,再根据性能需求去尝试共享内存或邮箱。别一上来就搞共享内存,调试起来真的会让人头秃。