4. 队列(Queue)通信:队列的创建、发送与接收

各位同学,今天我们来聊聊FreeRTOS里最常用的通信方式——队列。说实话,我做了这么多年嵌入式开发,队列这东西几乎每个项目都会用到。它就像任务之间的“快递通道”,一个任务往里扔数据,另一个任务取出来用。

我记得刚入行那会儿,有个同事用全局变量传数据,结果两个任务同时读写,程序跑着跑着就崩了。后来换成队列,问题立马解决。嗯,这就是队列的价值——安全、可靠地传递数据。

4.1 队列的核心概念

队列本质上是一个先进先出(FIFO)的数据缓冲区。你可以把它想象成一个管道,数据从一端进去,从另一端出来。每个队列都有固定的长度和每个数据项的大小。

我个人习惯把队列比作“邮箱”:

  • 创建队列:相当于在系统里挂一个邮箱
  • 发送数据:往邮箱里塞一封信
  • 接收数据:从邮箱里取出一封信
  • 阻塞等待:如果邮箱空了,你就等着有人投信

关键点:队列是“拷贝传递”的。发送方把数据拷贝进队列,接收方从队列拷贝出来。这意味着你传递的是数据的副本,而不是指针。这样做的好处是安全——发送方修改原数据不会影响接收方。

4.2 队列的创建

创建队列用 xQueueCreate() 函数。它的原型很简单:

QueueHandle_t xQueueCreate( UBaseType_t uxQueueLength, UBaseType_t uxItemSize );
  • uxQueueLength:队列能容纳的最大消息数量
  • uxItemSize:每个消息的大小(字节)
  • 返回值:队列句柄,创建失败返回 NULL

举个例子,创建一个能存5个整数的队列:

QueueHandle_t xQueue;
xQueue = xQueueCreate(5, sizeof(int32_t));
if (xQueue == NULL) {
    // 创建失败,处理错误
    printf("队列创建失败!\n");
}

我的经验:创建队列时,uxItemSize 不要设得太大。我在项目中见过有人把整个结构体(几百字节)直接塞进队列,结果内存消耗巨大。如果数据量大,建议传递指针,队列只存指针大小(4或8字节)。

4.3 队列的发送与接收

4.3.1 发送数据:xQueueSend()

发送数据用 xQueueSend()。它会把数据拷贝到队列尾部。函数原型:

BaseType_t xQueueSend( QueueHandle_t xQueue, 
                       const void *pvItemToQueue, 
                       TickType_t xTicksToWait );
  • xQueue:队列句柄
  • pvItemToQueue:指向要发送的数据的指针
  • xTicksToWait:队列满时的等待时间(单位:系统时钟节拍)
  • 返回值:成功返回 pdPASS,失败返回 errQUEUE_FULL

看个实际例子:

int32_t data = 100;
BaseType_t ret;

// 发送数据,如果队列满则等待100个节拍
ret = xQueueSend(xQueue, &data, pdMS_TO_TICKS(100));
if (ret == pdPASS) {
    printf("数据发送成功\n");
} else {
    printf("队列满了,发送超时\n");
}

4.3.2 接收数据:xQueueReceive()

接收数据用 xQueueReceive()。它会从队列头部取出数据并删除该数据项。函数原型:

BaseType_t xQueueReceive( QueueHandle_t xQueue, 
                          void *pvBuffer, 
                          TickType_t xTicksToWait );
  • xQueue:队列句柄
  • pvBuffer:接收数据的缓冲区指针
  • xTicksToWait:队列空时的等待时间
  • 返回值:成功返回 pdPASS,失败返回 errQUEUE_EMPTY

接收示例:

int32_t received_data;
BaseType_t ret;

// 接收数据,如果队列空则一直等待
ret = xQueueReceive(xQueue, &received_data, portMAX_DELAY);
if (ret == pdPASS) {
    printf("收到数据:%d\n", received_data);
}

注意:接收缓冲区的大小必须大于等于队列创建时指定的 uxItemSize。我曾经犯过这个错——缓冲区小了,结果数据被截断,排查了半天才发现是缓冲区大小不匹配。

4.4 队列阻塞时间设置

阻塞时间是队列通信的精髓。说白了,就是当队列满(发送)或空(接收)时,任务愿意等多久。

阻塞时间 含义 使用场景
0 不等待,立即返回 轮询检查,不想阻塞任务
pdMS_TO_TICKS(ms) 等待指定毫秒数 有超时要求的通信
portMAX_DELAY 无限等待,直到成功 必须等到数据的场景

我个人习惯这样用:

  • 发送任务:一般用 0 或短超时。如果队列满了,说明接收方处理不过来,这时候丢数据比阻塞任务更合理。
  • 接收任务:常用 portMAX_DELAY。因为接收任务通常就是等着数据来了再干活,没必要空转浪费CPU。

避坑指南:我曾经在一个项目里把接收任务的阻塞时间设成了 0,结果任务一直在空循环检查队列,CPU占用率飙到90%以上。改成 portMAX_DELAY 后,CPU占用率直接降到5%。你想想看,这差距多大!

4.5 队列集(Queue Set)简介

当你有多个队列,想同时等待它们中的任何一个有数据时,怎么办?一个一个轮询?太浪费CPU了。这时候就需要队列集

队列集就像一个“队列的集合”。你可以把多个队列注册到同一个队列集里。然后任务只需要等待这个队列集,一旦其中任何一个队列有数据,任务就会被唤醒。

创建队列集:

QueueSetHandle_t xQueueSet;
xQueueSet = xQueueCreateSet( QUEUE_SET_SIZE );

把队列加入队列集:

xQueueAddToSet( xQueue1, xQueueSet );
xQueueAddToSet( xQueue2, xQueueSet );

从队列集中获取有数据的队列:

QueueSetMemberHandle_t xActivatedQueue;
xActivatedQueue = xQueueSelectFromSet( xQueueSet, portMAX_DELAY );

// 然后从激活的队列中接收数据
if (xActivatedQueue == xQueue1) {
    xQueueReceive(xQueue1, &data, 0);
} else if (xActivatedQueue == xQueue2) {
    xQueueReceive(xQueue2, &data, 0);
}

我的建议:队列集适合管理多个数据源。比如一个任务要同时处理按键消息、传感器数据、网络包,用队列集就很优雅。但要注意,队列集有额外的内存开销,如果只有一两个队列,直接用单个队列就够了。

4.6 实战:按键消息通过队列驱动LED

好了,理论讲完了,咱们来做个实战。这个项目很简单:按一下按键,LED状态翻转。但关键是用队列来传递按键消息。

整体思路:

  1. 创建一个队列,用于传递按键事件
  2. 按键中断(或轮询)中发送消息到队列
  3. LED控制任务从队列接收消息,并翻转LED

先定义消息类型:

typedef enum {
    KEY_PRESS_SHORT,   // 短按
    KEY_PRESS_LONG     // 长按
} KeyEvent_t;

创建队列:

QueueHandle_t xKeyQueue;
xKeyQueue = xQueueCreate(10, sizeof(KeyEvent_t));
if (xKeyQueue == NULL) {
    // 处理错误
}

按键任务(模拟按键检测):

void vKeyTask(void *pvParameters) {
    KeyEvent_t keyEvent;
    
    for (;;) {
        // 检测按键(这里简化,实际用GPIO读取)
        if (按键按下) {
            keyEvent = KEY_PRESS_SHORT;
            xQueueSend(xKeyQueue, &keyEvent, 0);
        }
        vTaskDelay(pdMS_TO_TICKS(20)); // 20ms扫描一次
    }
}

LED控制任务:

void vLEDTask(void *pvParameters) {
    KeyEvent_t receivedEvent;
    BaseType_t ret;
    
    for (;;) {
        // 阻塞等待按键消息
        ret = xQueueReceive(xKeyQueue, &receivedEvent, portMAX_DELAY);
        
        if (ret == pdPASS) {
            switch (receivedEvent) {
                case KEY_PRESS_SHORT:
                    // 翻转LED
                    HAL_GPIO_TogglePin(LED_GPIO_Port, LED_Pin);
                    printf("短按:LED翻转\n");
                    break;
                    
                case KEY_PRESS_LONG:
                    // 长按可以执行其他操作
                    printf("长按:执行特殊功能\n");
                    break;
                    
                default:
                    break;
            }
        }
    }
}

创建任务:

xTaskCreate(vKeyTask, "KeyTask", 128, NULL, 2, NULL);
xTaskCreate(vLEDTask, "LEDTask", 128, NULL, 1, NULL);

我的经验:这个架构的好处是解耦。按键检测和LED控制完全独立。如果你想增加“双击”功能,只需要在按键任务里增加判断,发送新的消息类型,LED任务那边加个case就行。互不影响。

4.7 本章小结

队列是FreeRTOS任务通信的基石。说白了,它解决了两个核心问题:

  • 数据安全:队列内部有互斥保护,多任务读写不会冲突
  • 任务同步:通过阻塞机制,让任务在数据到达时自动唤醒

我个人觉得,掌握队列的使用是入门FreeRTOS的关键一步。你想想看,有了队列,任务之间就能有序地传递信息,整个系统就像有了“神经系统”一样协调工作。

下一章我们会讲信号量和互斥量,它们和队列有很深的渊源。到时候你就知道,其实信号量本质上就是一个特殊的队列。