Chapter 04 · FreeRTOS 实时操作系统

队列 Queue

队列是 FreeRTOS 任务间传递数据的基石。理解它的拷贝语义、阻塞超时、队列集与邮箱模式,以及如何在中断里安全收发。

课程进度33%

关键概念

队列 (Queue)
FreeRTOS 中最基础的任务间通信对象,一个先进先出(FIFO)的定长环形缓冲区。创建时固定长度(能存几条)和每条元素大小(字节)。任务和中断都能往里发数据、从里取数据,是解耦生产者与消费者的标准手段。信号量和互斥量在内核里其实都是特殊的队列。
拷贝语义 (Copy by Value)
FreeRTOS 队列按值拷贝:发送时把数据完整复制进队列的存储区,接收时再复制出来。好处是发送方修改/释放原变量不影响已入队的数据,天然线程安全。代价是拷贝开销——若要传大结构体,通常入队指针(传引用)而非整个结构体,但要自己管理指向数据的生命周期。
阻塞超时 (Block Time)
收发 API 的最后一个参数 xTicksToWait。发送时队列满、接收时队列空,调用任务会阻塞至多这么多 tick。取 0 表示不等待立即返回,取 portMAX_DELAY 表示无限等待(需 INCLUDE_vTaskSuspend=1)。这是队列实现任务同步的关键。
xQueueSend / xQueueReceive
最常用的收发对。xQueueSend(等价 xQueueSendToBack)把数据加到队尾;xQueueSendToFront 加到队头(可做"插队"高优先级消息);xQueueReceive 取出并移除队头数据;xQueuePeek 只查看队头不移除。返回 pdTRUE 成功、errQUEUE_FULL/pdFALSE 超时。
FromISR 版本
中断里必须用带 FromISR 后缀的 API(xQueueSendFromISR 等)。它们不会阻塞(ISR 里不能阻塞),并带 pxHigherPriorityTaskWoken 出参,用于在中断退出时决定是否 portYIELD_FROM_ISR 触发切换。普通版和 FromISR 版绝不能混用。
队列集 (Queue Set)
当一个任务要同时等待多个队列/信号量时,用 QueueSetHandle_t 把它们组合起来,任务阻塞在 xQueueSelectFromSet 上,任一成员就绪即返回该成员句柄。需 configUSE_QUEUE_SETS=1。它避免了轮询多个队列的低效,但用法较繁琐,很多场景可用任务通知或事件组替代。
邮箱模式 (Mailbox)
长度为 1 的队列的特殊用法:用 xQueueOverwrite 无条件覆盖唯一的槽位,用 xQueuePeek 读取但不移除。这样队列永远保存"最新一份数据",多个读者都能读到最新值,非常适合广播传感器最新读数、系统状态等。
生产者-消费者模型
队列最典型的用途:一个或多个生产者任务/中断把数据入队,一个或多个消费者任务出队处理。队列充当缓冲区,吸收生产与消费速率的差异,解耦两端时序,是嵌入式数据流水线的骨架。

队列的内部结构与拷贝流程

队列的按值拷贝语义 ═══════════════════════════════════════════════════════════ 生产者任务 Queue (len=4, itemSize=sizeof(msg_t)) 消费者任务 ┌──────────┐ ┌────┬────┬────┬────┐ ┌──────────┐ │ msg_t m; │ send │ m0 │ m1 │ m2 │ │ recv │ msg_t r; │ │ m.id=7 │ ───拷贝─► │████│████│████│░░░░│ ───拷贝─► │ r = m0 │ └──────────┘ └────┴────┴────┴────┘ └──────────┘ ↑ 数据复制入队 ↑ 队头复制出队(FIFO) 发送后 m 可修改/销毁,不影响队列内副本 队满时 send 阻塞: 队空时 recv 阻塞: ┌────┬────┬────┬────┐ ┌────┬────┬────┬────┐ │████│████│████│████│ │░░░░│░░░░│░░░░│░░░░│ └────┴────┴────┴────┘ └────┴────┴────┴────┘ 等到有位置或超时 等到有数据或超时

基础:创建、发送、接收

#include "FreeRTOS.h"
#include "task.h"
#include "queue.h"

/* 定义要传输的消息类型 */
typedef struct {
  uint8_t  sensor_id;
  float    value;
  uint32_t timestamp;
} SensorMsg_t;

QueueHandle_t g_sensorQueue = NULL;

/* 生产者:每 200ms 采集并入队 */
void ProducerTask(void *arg) {
  SensorMsg_t msg;
  for (;;) {
    msg.sensor_id = 1;
    msg.value     = read_adc_temp();
    msg.timestamp = xTaskGetTickCount();
    /* 队满则等待最多 10ms;成功返回 pdTRUE */
    if (xQueueSend(g_sensorQueue, &msg,
                   pdMS_TO_TICKS(10)) != pdPASS) {
      printf("queue full, sample dropped\r\n");
    }
    vTaskDelay(pdMS_TO_TICKS(200));
  }
}

/* 消费者:阻塞等待数据,来一条处理一条 */
void ConsumerTask(void *arg) {
  SensorMsg_t rx;
  for (;;) {
    /* 队空则永久阻塞;有数据即取出 */
    if (xQueueReceive(g_sensorQueue, &rx,
                      portMAX_DELAY) == pdPASS) {
      printf("id=%u v=%.2f t=%lu\r\n",
             rx.sensor_id, rx.value, rx.timestamp);
    }
  }
}

void setup_queue_demo(void) {
  /* 长度 5,每条 sizeof(SensorMsg_t) 字节 */
  g_sensorQueue = xQueueCreate(5, sizeof(SensorMsg_t));
  configASSERT(g_sensorQueue != NULL);   /* 堆不足则创建失败 */

  xTaskCreate(ProducerTask, "PROD", 256, NULL, 2, NULL);
  xTaskCreate(ConsumerTask, "CONS", 256, NULL, 2, NULL);
}
传大数据?入队指针,别拷贝整块

若消息很大(比如一整帧 512 字节的传感器数据),按值拷贝两次(入队+出队)会很浪费。此时创建 xQueueCreate(len, sizeof(void*)),发送方把指向数据的指针入队,接收方取出指针后处理。但要小心生命周期:数据必须在被消费前一直有效,通常来自内存池或 pvPortMalloc,消费完再释放。

在中断里发送:xQueueSendFromISR

/* 定时器中断:把采样值送入队列 */
void TIM2_IRQHandler(void) {
  BaseType_t woken = pdFALSE;
  if (__HAL_TIM_GET_FLAG(&htim2, TIM_FLAG_UPDATE)) {
    __HAL_TIM_CLEAR_FLAG(&htim2, TIM_FLAG_UPDATE);

    SensorMsg_t m = { .sensor_id = 2,
                       .value = read_adc_fast(),
                       .timestamp = xTaskGetTickCountFromISR() };
    /* ISR 版本:非阻塞,woken 表示是否唤醒了更高优任务 */
    xQueueSendFromISR(g_sensorQueue, &m, &woken);
  }
  portYIELD_FROM_ISR(woken);   /* 必要时立即切换 */
}
ISR 里绝不能用会阻塞的普通版

中断上下文不能阻塞。在 ISR 里调用普通 xQueueSend(它可能阻塞)会破坏内核状态、直接崩溃或触发断言。ISR 中只能*FromISR 系列 API,且它们没有超时参数(因为不阻塞)。同理,取时间戳也要用 xTaskGetTickCountFromISR()

邮箱模式:永远保存最新值

/* 长度为 1 的队列当邮箱 */
QueueHandle_t g_statusMailbox;

void setup_mailbox(void) {
  g_statusMailbox = xQueueCreate(1, sizeof(uint32_t));
}

/* 写:无条件覆盖,永远存最新状态 */
void update_status(uint32_t s) {
  xQueueOverwrite(g_statusMailbox, &s);
}

/* 读:peek 不移除,多个读者都能读到 */
uint32_t read_status(void) {
  uint32_t s = 0;
  xQueuePeek(g_statusMailbox, &s, 0);  /* 不阻塞 */
  return s;
}

队列集:同时等待多个来源

/* 需 configUSE_QUEUE_SETS = 1 */
QueueSetHandle_t set;
QueueHandle_t    qCmd;      /* 命令队列 */
SemaphoreHandle_t semTick;  /* 定时信号量 */

void setup_set(void) {
  qCmd    = xQueueCreate(4, sizeof(uint8_t));
  semTick = xSemaphoreCreateBinary();
  /* 集容量 = 所有成员长度之和 */
  set = xQueueCreateSet(4 + 1);
  xQueueAddToSet(qCmd, set);
  xQueueAddToSet(semTick, set);
}

void DispatchTask(void *arg) {
  for (;;) {
    /* 阻塞直到任一成员就绪,返回就绪的那个句柄 */
    QueueSetMemberHandle_t who =
        xQueueSelectFromSet(set, portMAX_DELAY);
    if (who == qCmd) {
      uint8_t cmd; xQueueReceive(qCmd, &cmd, 0);
      handle_cmd(cmd);
    } else if (who == semTick) {
      xSemaphoreTake(semTick, 0);
      handle_tick();
    }
  }
}

选型:队列 vs 任务通知 vs 邮箱

需求推荐机制理由
缓冲多条消息、多生产者/消费者队列唯一支持深度缓冲和多端
ISR → 单任务的简单唤醒任务通知更快、更省 RAM
广播"最新值"给多个读者邮箱(Overwrite+Peek)不消耗、永远最新
等待多个队列/信号量之一队列集 / 事件组避免轮询
传大结构体队列存指针避免双重拷贝
本章小结

队列是 FreeRTOS 传递数据的基石,一个定长 FIFO 环形缓冲区,创建时定长度与元素大小。它按值拷贝:发送和接收各拷贝一次,天然线程安全,传大数据时改为入队指针以省开销。xQueueCreate 创建,xQueueSend/xQueueSendToFront 发送、xQueueReceive 取出、xQueuePeek 只看不取;超时参数 0=不等、portMAX_DELAY=永久等。中断里只能*FromISR 版本并配 portYIELD_FROM_ISR(woken),绝不能用会阻塞的普通版。长度 1 的队列配 xQueueOverwrite+xQueuePeek 构成邮箱,永远保存最新值供多读者读取。队列集(configUSE_QUEUE_SETS)让任务用 xQueueSelectFromSet 同时等待多个队列/信号量。选型上:多消息缓冲用队列、简单唤醒用任务通知、广播最新值用邮箱、多源等待用队列集或事件组。