4、任务间通信:队列、信号量、互斥量、事件组

好,咱们进入第四章。这一章可以说是RTOS开发的“灵魂”所在。

你想想看,一个系统里跑着好几个任务,它们之间总得说说话、传传数据吧?总不能各干各的,老死不相往来。任务间通信,就是解决这个问题的。

我个人习惯把RTOS提供的通信机制分成四类:队列、信号量、互斥量、事件组。每个都有它的脾气,用错了地方,系统性能会大打折扣。我踩过的坑,今天一并讲给你听。

4.1 队列(Queue)—— 任务间的“邮局”

队列,说白了就是一个先进先出的数据缓冲区。一个任务往里扔数据,另一个任务从里面取数据。就像邮局寄信,你只管投递,邮递员会按顺序送到。

核心要点:队列是“数据搬运工”,不是“信号灯”。它传递的是实实在在的数据块,而不是一个标志位。

我在项目中遇到过一个问题:两个任务需要交换传感器数据。一开始我用全局变量加标志位,结果数据老是被覆盖,调试得我头大。后来换成队列,世界清净了。

看个简单的例子,FreeRTOS下的队列操作:

// 创建一个能存放5个int型数据的队列
QueueHandle_t xQueue;
xQueue = xQueueCreate(5, sizeof(int));

// 发送任务
void vSenderTask(void *pvParameters) {
    int value = 100;
    while(1) {
        xQueueSend(xQueue, &value, portMAX_DELAY);
        value++;
        vTaskDelay(100);
    }
}

// 接收任务
void vReceiverTask(void *pvParameters) {
    int receivedValue;
    while(1) {
        if(xQueueReceive(xQueue, &receivedValue, portMAX_DELAY) == pdPASS) {
            // 处理接收到的数据
            printf("收到数据: %d\n", receivedValue);
        }
    }
}

这里要注意几个点:

  • 队列深度:创建时指定了5个元素,满了再发就会阻塞(除非你设置超时)。
  • 数据拷贝:队列传递的是数据的副本,不是指针。如果你传递的是大结构体,要考虑内存开销。
  • 多任务读写:队列天然支持多任务同时发送或接收,内部有锁保护。

我的小技巧:如果传递的数据比较大,比如一个1024字节的数组,建议在队列里只传递指针,数据放在共享内存区。但要注意内存管理,别出现野指针。

4.2 信号量(Semaphore)—— 资源的“计数器”

信号量,本质上是一个计数器。它不传递数据,只传递“资源可用”这个信息。

我刚开始学RTOS时,总觉得信号量和队列差不多。其实完全两码事。队列是传数据,信号量是传“信号”。

信号量分两种:

  • 二值信号量:只有0和1两种状态。常用于任务同步,比如“数据准备好了,快来取”。
  • 计数信号量:可以计数到N。用于管理多个同类资源,比如“还有3个缓冲区可用”。

举个例子,一个典型的生产者-消费者模型:

SemaphoreHandle_t xSemaphore;

void vProducerTask(void *pvParameters) {
    while(1) {
        // 生产数据...
        xSemaphoreGive(xSemaphore);  // 释放信号量,通知消费者
        vTaskDelay(100);
    }
}

void vConsumerTask(void *pvParameters) {
    while(1) {
        xSemaphoreTake(xSemaphore, portMAX_DELAY);  // 等待信号量
        // 消费数据...
    }
}

我曾经踩过的坑:在中断服务函数里调用xSemaphoreGiveFromISR(),但忘了检查返回值。结果信号量给多了,消费者任务一直以为有数据,实际上缓冲区是空的。嗯,从那以后我每次调用ISR版本的API都会检查返回值。

4.3 互斥量(Mutex)—— 资源的“门禁卡”

互斥量,说白了就是一把锁。谁拿到锁,谁就能访问共享资源。别人只能等着。

它和信号量最大的区别是什么?互斥量有“优先级继承”机制。这个机制能防止“优先级反转”问题。

什么是优先级反转?我简单说:

  • 任务A(高优先级)和任务C(低优先级)共享一个资源。
  • 任务C先拿到了锁,然后被任务B(中优先级)抢占了CPU。
  • 任务A想拿锁,但锁在C手里,C又被B抢占了,所以A只能干等。
  • 结果:高优先级的A被中优先级的B“反转”了。

互斥量的优先级继承机制,会临时把持有锁的任务C的优先级提升到A的级别,这样C就能尽快执行完释放锁,A就能拿到锁了。

记住:如果只是简单的任务同步,用二值信号量就够了。但如果涉及共享资源的互斥访问,一定要用互斥量!

使用互斥量的典型场景:

// 共享的LCD显示资源
MutexHandle_t xLCDMutex;

void vTask1(void *pvParameters) {
    while(1) {
        xMutexTake(xLCDMutex, portMAX_DELAY);
        // 安全地操作LCD
        LCD_Print("Task 1");
        xMutexGive(xLCDMutex);
        vTaskDelay(100);
    }
}

void vTask2(void *pvParameters) {
    while(1) {
        xMutexTake(xLCDMutex, portMAX_DELAY);
        LCD_Print("Task 2");
        xMutexGive(xLCDMutex);
        vTaskDelay(150);
    }
}

我的建议:互斥量持有时间要尽量短。别在持有锁的时候做延时、等待等操作。否则其他任务会被长时间阻塞,系统实时性就没了。

4.4 事件组(Event Group)—— 任务的“信号灯组”

事件组,你可以把它想象成一个多位宽的标志位集合。每个位代表一个事件,任务可以等待多个事件同时发生,或者任意一个发生。

我最早用事件组是在一个多传感器数据采集的项目里。需要等温度、湿度、气压三个传感器都采集完毕,才能做融合计算。用事件组,一行代码就搞定了。

事件组的核心操作:

  • 设置事件位:任务或ISR可以设置一个或多个位。
  • 等待事件位:任务可以等待某些位被设置,支持“与”模式和“或”模式。
  • 清除事件位:可以手动清除,也可以在等待成功后自动清除。

看个例子:

EventGroupHandle_t xEventGroup;
#define BIT_TEMP    (1 << 0)
#define BIT_HUMID   (1 << 1)
#define BIT_PRESS   (1 << 2)

// 温度采集任务
void vTempTask(void *pvParameters) {
    while(1) {
        // 采集温度...
        xEventGroupSetBits(xEventGroup, BIT_TEMP);
        vTaskDelay(100);
    }
}

// 融合计算任务,等待三个事件都发生
void vFusionTask(void *pvParameters) {
    EventBits_t uxBits;
    while(1) {
        uxBits = xEventGroupWaitBits(
            xEventGroup,
            BIT_TEMP | BIT_HUMID | BIT_PRESS,  // 等待的位
            pdTRUE,    // 等待成功后自动清除
            pdTRUE,    // 等待所有位(与模式)
            portMAX_DELAY
        );
        // 三个数据都齐了,开始融合计算
        printf("开始融合计算\n");
    }
}

事件组 vs 信号量:信号量只能表示“一个资源可用”,事件组可以表示“多个条件同时满足”。如果你的任务需要等待多个条件,用事件组比用多个信号量优雅得多。

4.5 如何选择?一张表说清楚

我经常被问到:“到底该用哪个?” 嗯,这里我整理了一张表,你照着选就行:

通信机制 传递内容 典型场景 注意事项
队列 数据块 任务间传递传感器数据、命令 注意数据大小,大对象传指针
二值信号量 信号(0/1) 任务同步、中断通知 不要用于共享资源保护
计数信号量 资源计数 管理多个缓冲区、连接池 注意计数上限
互斥量 保护共享资源(全局变量、外设) 持有时间要短,有优先级继承
事件组 多位事件标志 等待多个条件同时满足 事件位数量有限(通常24位)

最后提醒一句:别在中断里调用带阻塞的API!比如xQueueReceive带超时参数,在ISR里调用会出大问题。ISR里只能用FromISR后缀的版本,而且不能阻塞。

好了,这一章的内容就到这儿。任务间通信是RTOS的“血管”,用好了,系统流畅得像丝绸;用不好,各种死锁、优先级反转够你喝一壶的。下一章我们聊聊内存管理,那个坑更多,到时候我慢慢讲。