4、任务间通信:队列(queue)原理与使用、队列集、队列的阻塞机制

好,咱们今天聊聊任务间通信。说白了,在FreeRTOS里,任务之间怎么传数据?最常用的就是队列(Queue)。

我刚开始用FreeRTOS那会儿,总觉得队列就是个“放数据、取数据”的容器。后来踩了坑才明白——队列的核心价值,其实是解耦。发送任务不用管谁在收,接收任务也不用管谁在发。两个任务各干各的,通过队列这个“信箱”来沟通。

4.1 队列的基本原理

队列本质上是一个环形缓冲区。每个队列都有固定的长度和固定的数据单元大小。你往队列里放数据,它会把数据拷贝进去;你从队列里取数据,它会把数据拷贝出来。

嗯,这里要注意:是拷贝,不是传指针。如果你传的是大结构体,每次拷贝的开销可不小。我个人习惯是,传指针而不是传整个数据块。比如传一个指向结构体的指针,队列里只存4字节的地址值,效率高很多。

核心数据结构:

  • 存储区:一块连续内存,用来存放消息
  • 读指针:指向下一个要取出的消息位置
  • 写指针:指向下一个要写入的消息位置
  • 消息计数:当前队列中有多少条消息
  • 等待列表:记录哪些任务在等队列可写/可读

4.2 队列的创建与使用

创建队列用 xQueueCreate()。两个参数:队列长度、每个数据单元的大小。

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

if (xQueue == NULL) {
    // 创建失败,通常是内存不够
    // 我建议你在这里打日志,方便调试
}

发送数据用 xQueueSend()xQueueSendFromISR()。接收数据用 xQueueReceive()xQueueReceiveFromISR()

// 任务A:发送数据
void TaskA(void *pvParameters) {
    int data = 100;
    while(1) {
        xQueueSend(xQueue, &data, portMAX_DELAY);
        data++;
        vTaskDelay(pdMS_TO_TICKS(1000));
    }
}

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

我在项目中遇到过一个问题:发送任务往队列里塞数据,接收任务却一直收不到。查了半天,发现是队列创建时数据单元大小设错了。我传的是结构体指针,但队列单元大小设成了结构体本身的大小。嗯,这种低级错误,犯过一次就记住了。

4.3 队列的阻塞机制

这是队列最强大的地方。你想想看,如果队列空了,接收任务调用 xQueueReceive() 会发生什么?

答案是:任务可以阻塞等待。第三个参数就是阻塞时间,单位是tick。

  • portMAX_DELAY:一直等,直到有数据
  • 0:不等待,立即返回
  • 其他值:最多等这么多tick

为什么会阻塞?因为FreeRTOS把任务放到了队列的“等待读取列表”里。当有数据写入时,系统会检查这个列表,如果有任务在等,就把数据直接拷贝给那个任务,然后唤醒它。

我的经验:阻塞时间别设太长。如果接收任务优先级高,设portMAX_DELAY没问题。但如果优先级低,长时间阻塞可能导致其他任务饿死。我一般设个100~500ms的超时,配合错误处理,系统更健壮。

4.4 队列集(Queue Set)

当你的任务需要从多个队列接收数据时,怎么办?一个个轮询?太浪费CPU了。

FreeRTOS提供了队列集。你可以把多个队列注册到一个队列集里。然后任务只需要等待这个队列集,哪个队列有数据,它就会告诉你。

// 创建队列集,最多支持3个队列
QueueSetHandle_t xQueueSet = xQueueCreateSet(3);

// 创建两个队列
QueueHandle_t xQueue1 = xQueueCreate(5, sizeof(int));
QueueHandle_t xQueue2 = xQueueCreate(5, sizeof(int));

// 注册到队列集
xQueueAddToSet(xQueue1, xQueueSet);
xQueueAddToSet(xQueue2, xQueueSet);

// 任务中等待队列集
void Task(void *pvParameters) {
    QueueSetMemberHandle_t xActivatedQueue;
    int data;

    while(1) {
        // 等待队列集中任意队列有数据
        xActivatedQueue = xQueueSelectFromSet(xQueueSet, portMAX_DELAY);

        // 判断是哪个队列被激活
        if (xActivatedQueue == xQueue1) {
            xQueueReceive(xQueue1, &data, 0);
            // 处理队列1的数据
        } else if (xActivatedQueue == xQueue2) {
            xQueueReceive(xQueue2, &data, 0);
            // 处理队列2的数据
        }
    }
}

注意:队列集有个限制——不能同时注册队列和信号量到同一个集合。另外,从队列集取出队列句柄后,要立即读取数据,否则可能被其他任务抢走。我曾经因为这个bug,调试了整整一个下午。

4.5 队列的避坑指南

我总结几个常见问题,都是血泪教训:

  1. 队列溢出:队列满了还往里写,数据会丢。可以用 xQueueSend() 的返回值判断是否成功。或者用 xQueueOverwrite() 覆盖旧数据(只适用于长度为1的队列)。
  2. ISR中使用队列:中断服务函数里只能用 FromISR 版本的API。而且中断里不要用portMAX_DELAY阻塞,中断必须快速返回。
  3. 队列大小与内存:每个队列会占用 队列长度 * 数据单元大小 + 管理结构 的内存。如果创建很多队列,堆可能不够用。我建议用 uxQueueSpacesAvailable() 监控队列使用情况。
  4. 优先级反转:高优先级任务等队列,低优先级任务占着队列不放,中间优先级任务抢了CPU。嗯,这个问题比较复杂,后面讲互斥量时会详细说。

4.6 实际项目中的队列设计

我参与过一个数据采集项目,需要从多个传感器读取数据,然后汇总处理。当时的设计是这样的:

模块 队列作用 队列长度 数据大小
传感器1任务 发送采集数据 10 32字节
传感器2任务 发送采集数据 10 32字节
汇总处理任务 从队列集接收 - -
日志输出任务 接收日志消息 20 64字节

每个传感器任务有自己的队列,汇总任务通过队列集统一接收。日志队列独立,避免影响数据通路。这样设计的好处是:传感器任务只管发,汇总任务只管收,互不干扰。而且队列集让汇总任务不用轮询,省CPU。

小技巧:队列长度不要设太大。够用就行,设大了浪费内存。我一般根据数据产生速率和消费速率来估算。比如传感器每秒产生10个数据,处理任务每秒能处理20个,那队列长度设5~10就够。如果设100,纯属浪费。

最后说一句:队列是FreeRTOS里最基础、最常用的通信机制。你把它用好了,大部分任务间通信问题都能解决。下一章咱们聊聊信号量和互斥量,那又是另一番天地了。