夜雨聆风学习资料网

ARTICLE · 1010717

FreeRTOS 队列集源码剖析:一个任务同时等 8 条队列,它是怎么做到的?

FreeRTOS 队列集源码剖析:一个任务同时等 8 条队列,它是怎么做到的?

一个任务要同时监听串口队列、按键队列、ADC 信号量三个事件源,怎么办?开三个任务各自阻塞?CPU 和栈都耗不起。轮询三条队列?要么忙等烧 CPU,要么响应延迟跟着轮询周期走。把三个源合并成一条大队列?各源的容量隔离、信号量语义全丢了。三种解法都有硬伤。

FreeRTOS 给的答案是 xQueueSelectFromSet——单任务、单阻塞点,任何一个成员有事件就醒来,还能拿到"是谁"。但这个答案背后藏着一个更漂亮的秘密:队列集没有新内核对象,它本身就是一条普通队列,装的还是指针

这篇文章把 FreeRTOS V11.1.0 的 queue.c 队列集部分拆开,看成员怎么挂入、事件怎么上报、Select 怎么复用普通队列的阻塞与锁计数,最后用一段可跑的实验验证"单点阻塞、多源唤醒、两段式取数"。读完你会发现:队列集不是新机制,是队列机制的三次复用。

内核版本:FreeRTOS V11.1.0(Cortex-M3)
configUSE_QUEUE_SETS = 0FreeRTOS/include/FreeRTOSConfig.h),想实验需先改成 1 并重新编译

1. 概述

1.1 队列集是什么

FreeRTOS 队列集是一条"装队列指针"的普通队列。内核没有新增任何对象类型,QueueDefinition 里只多了一个反向指针字段:

/* queue.c QueueDefinition 中的队列集字段 */typedef struct QueueDefinition{    ...    #if ( configUSE_QUEUE_SETS == 1 )        Queue_t * pxQueueSetContainer;      /* 指向所属队列集, NULL = 不属于任何集 */    #endif} xQUEUE;

设计哲学和信号量"复用队列"一脉相承——跟信号量一个套路:信号量是把数据区掏空的队列,队列集是把数据换成指针的队列。三次复用同一套 queue.c 机制——阻塞、超时、锁计数、补处理,队列集全部免费继承。

核心特性:上报指针,不上报数据。 成员队列有事件时,往队列集里写的是自己的指针;数据本体仍留在成员队列里。所以 xQueueSelectFromSet 返回成员句柄后,必须再对该成员做一次 xQueueReceive/xSemaphoreTake 才真正拿到数据——官方规定的两段式。

1.2 API 家族

API
作用
本质
xQueueCreateSet(uxEventQueueLength)
创建队列集
xQueueGenericCreate(len, sizeof(Queue_t*), queueQUEUE_TYPE_SET)
xQueueAddToSet(member, set)
成员挂入
写成员的反向指针 pxQueueSetContainer
xQueueRemoveFromSet(member, set)
成员摘除
清反向指针
xQueueSelectFromSet(set, ticks)
等待任一成员事件
xQueueReceive((QueueHandle_t)set, &handle, ticks)
xQueueSelectFromSetFromISR(set)
ISR 版 Select
xQueueReceiveFromISR((QueueHandle_t)set, &handle, NULL)

1.3 没有队列集时的三种解法(都有硬伤)

方案
硬伤
每条队列一个等待任务
N 个源 = N 个任务、N 份栈(本工程 1KB/任务)+ N 次切换;处理还要汇聚时得再转发一次
轮询所有队列 xQueueReceive(q, &x, 0)
零超时不阻塞 → 忙等烧 CPU;加 vTaskDelay → 响应延迟 = 轮询周期
合并成一条大队列 + 消息类型标签
失去各源独立容量:一路狂发灌满共享队列,把其他类型消息堵死;信号量语义(计数/二值)也混不进来

2. 机制核心:全部复用普通队列

2.1 结论先行

队列集自身就是一条队列,消息队列那套三层保护(临界区、队列锁、锁计数补处理)对队列集原样生效。 本文不再重复推导,只列队列集特有的部分。普通队列的快/慢路径、锁计数怎么防止丢唤醒,见《FreeRTOS 消息队列源码刨析:为什么用了 xQueueSend 还是丢数据?》第 2、4 节(简单说就是:关中断只保护数据拷贝,任务挂链表期间中断来了只记数不碰链表,解锁时统一补唤醒)。

2.2 事件上报的四个调用点

成员队列每次 Send/Give 成功后,检查自己 pxQueueSetContainer != NULL 就调用 prvNotifyQueueSetContainer 上报。全 queue.c 共四处调用点:

调用点
位置
场景
xQueueGenericSend
 快路径
任务侧写入成员
快/慢路径共用——慢路径被唤醒后重走同一循环,最终仍从快路径出去
xQueueGenericSendFromISR
ISR 写入成员
cTxLock == queueUNLOCKED
 时才尝试上报
xQueueGiveFromISR
ISR Give 信号量成员
同上
prvUnlockQueue
解锁补处理
成员自身被锁定时,ISR 只递增成员的锁计数,上报也被推迟到这里补做

注意第四行的连带关系:ISR 写成员时若成员自己锁着(另有任务正阻塞在该成员上),连上报都不做——只递增成员的 cTxLock,等成员解锁补处理时再补上报。这和普通队列"锁定期间 ISR 只记数不动链表"是同一套规矩,只是又叠了一层。

2.3 上报时集自身被锁定怎么办

成员没锁、但队列集锁着(分发任务正走在 Select 的慢路径上、挂链表的缝隙中)——上报照常执行:往集里写指针是数据操作,锁定不禁;但唤醒链表不许碰,只递增集的cTxLock,由集的 prvUnlockQueue 稍后补唤醒。锁计数机制在两层各自独立生效,互不干扰。

3. 应用场景

场景
说明
通信网关
网口帧 + 串口帧 + CAN 帧 → 一个协议分发任务,各链路独立缓冲
UI 状态机
按键队列 + 传感器队列 + 定时器信号量多种输入源,一个事件循环
日志系统
多模块日志队列 → 单落盘任务,削峰
任务通知 vs 队列集
一对一通知用任务通知更快更省;多源单消费者才是队列集的主场
不该用队列集
单事件源(直接阻塞那条队列即可);互斥量不能当成员(所有权语义破坏,见第 6 节坑 5)

4. 源码剖析

4.1 创建:xQueueCreateSet

/* queue.c  xQueueCreateSet */QueueSetHandle_t xQueueCreateSet(const UBaseType_t uxEventQueueLength ){    QueueSetHandle_t pxQueue;    pxQueue = xQueueGenericCreate( uxEventQueueLength,                                   sizeofQueue_t * ),   /* 条目 = 队列指针! */                                   queueQUEUE_TYPE_SET );    return pxQueue;}

就这么多。队列集 = 长度 uxEventQueueLength、每条 sizeof(Queue_t*)(4 字节)的普通队列。

uxEventQueueLength 必须 ≥ 所有成员容量之和:每个成员最多贡献"自己容量"个事件通知(成员满之前每次写入都上报一次)。queueQUEUE_TYPE_SET 只是类型标记,创建逻辑与普通队列完全相同。

4.2 挂入/摘除:xQueueAddToSet / xQueueRemoveFromSet

/* queue.c xQueueAddToSet (精简) */BaseType_t xQueueAddToSet( QueueSetMemberHandle_t xQueueOrSemaphore,                           QueueSetHandle_t xQueueSet ){    BaseType_t xReturn;    taskENTER_CRITICAL();    {        if( ( ( Queue_t * ) xQueueOrSemaphore )->pxQueueSetContainer != NULL )            xReturn = pdFAIL;      /* 一个成员只能属于一个队列集 */        else if( ( ( Queue_t * ) xQueueOrSemaphore )->uxMessagesWaiting != 0 )            xReturn = pdFAIL;      /* 必须空队列才能挂入 */        else        {            /* 挂入动作 = 写反向指针, 仅此而已 */            ( ( Queue_t * ) xQueueOrSemaphore )->pxQueueSetContainer = xQueueSet;            xReturn = pdPASS;        }    }    taskEXIT_CRITICAL();    return xReturn;}

关键设计:队列集不在成员上登记任何东西,成员的单向指针指回队列集才是唯一的联络通道。 队列集自己甚至不知道自己有哪些成员——它只负责收指针,成员靠反向指针自己找上门。

为什么必须空队列挂入?设想非空挂入:成员里已有 2 条数据但从未上报过,集里没有对应事件——这 2 条数据成了"集看不见的数据",Select 永远轮不到它们,数据滞留。

xQueueRemoveFromSet完全对称:校验"确实属于这个集 + 队列为空"后把反向指针置 NULL。非空摘除同样 pdFAIL——强摘会产生悬空事件:集里还留着它的指针,之后 Select 返回一个已摘除的句柄,二次 Receive 操作一个已脱离集的队列,行为不可控。

4.3 上报核心:prvNotifyQueueSetContainer

整个队列集机制的心脏。必须在临界区内调用(四个调用点都在临界区里)。

/* queue.c  prvNotifyQueueSetContainer (精简) */static BaseType_t prvNotifyQueueSetContainer(const Queue_t * const pxQueue ){    Queue_t * pxQueueSetContainer = pxQueue->pxQueueSetContainer;    BaseType_t xReturn = pdFALSE;    configASSERT( pxQueueSetContainer );    configASSERT( pxQueueSetContainer->uxMessagesWaiting < pxQueueSetContainer->uxLength );    if( pxQueueSetContainer->uxMessagesWaiting < pxQueueSetContainer->uxLength )    {        const int8_t cTxLock = pxQueueSetContainer->cTxLock;        /* 上报的数据 = 自己的指针 */        xReturn = prvCopyDataToQueue( pxQueueSetContainer, &pxQueue, queueSEND_TO_BACK );        if( cTxLock == queueUNLOCKED )        {            iflistLIST_IS_EMPTY( &( pxQueueSetContainer->xTasksWaitingToReceive ) ) == pdFALSE )            {                ifxTaskRemoveFromEventList( &( pxQueueSetContainer->xTasksWaitingToReceive ) ) != pdFALSE )                    xReturn = pdTRUE;   /* 唤醒了更高优先级任务, 要求调用处 yield */            }            /* else: 等待链表为空 → 没人可唤醒, 什么都不做 */        }        else        {            prvIncrementQueueTxLock( pxQueueSetContainer, cTxLock );  /* 集被锁定: 只记数不碰链表 */        }    }    return xReturn;}

逐行拆解:

  1. prvCopyDataToQueue(集, &pxQueue, ...)
    ——把成员指针当数据 memcpy 进集的环形缓冲区。注意 &pxQueue:拷的是指针变量本身的 4 字节,不是队列内容;
  2. 返回值语义
    pdTRUE 表示"唤醒了更高优先级的等待者,需要切换",由四个调用点各自决定怎么响应(任务路径临界区内 queueYIELD_IF_USING_PREEMPTION()、ISR 路径置 pxHigherPriorityTaskWoken、解锁路径 vTaskMissedYield);
  3. cTxLock 判断
    :集被锁定(分发任务在 Select 慢路径的挂链表缝隙中)→ 只递增集的锁计数,唤醒推迟到集的 prvUnlockQueue 补做——锁计数机制对集原样生效;
  4. 满集防护的两种表现
    uxMessagesWaiting < uxLength 不成立时,行为取决于断言开关——开发阶段configASSERT 开启)直接触发断言死机,第一时间暴露集长度不够;release 配置(断言关闭)才走到静默丢弃上报。这是个埋雷点:本地调试时集短了会死机,好查;上线后断言关了变成静默丢通知,更难查。所以"集长度必须 ≥ 成员容量之和"要在设计时算好,别指望运行时兜底。

4.4 调用点一:任务侧写入(xQueueGenericSend 快路径)

/* queue.c  xQueueGenericSend 快路径中的队列集分支 */if( pxQueue->pxQueueSetContainer != NULL ){    if( ( xCopyPosition == queueOVERWRITE ) && ( uxPreviousMessagesWaiting != 0 ) )        /* overwrite 覆盖了已有条目, 条目数没变, 不上报 */        ...    else ifprvNotifyQueueSetContainer( pxQueue ) != pdFALSE )        queueYIELD_IF_USING_PREEMPTION();   /* 唤醒了更高优先级的等待者 */}else    ...  /* 普通队列: 唤醒 xTasksWaitingToReceive 的任务 */

overwrite 特判的必要性:成员是"长度 1"的队列且用 overwrite 写、原本就有数据时不上报——条目数没变,上报会造成集里同一成员出现两条事件;第二条 Select 到时成员已空(数据只够喂一次),第二段 Receive 失败,凭空多一次空转。

注意这个分支的位置:它在判满之后、拷贝数据之后,且只走 pxQueueSetContainer != NULL 的路——成员挂入队列集后,任务直接 Send 该成员,不再走普通路径唤醒等在这个成员上的任务,而是上报集、唤醒等在集上的任务。所以"等某条队列"和"把队列挂进集"不要混用。

4.5 调用点二:ISR 写入(xQueueGenericSendFromISR)

/* queue.c xQueueGenericSendFromISR 中的队列集分支 */if( cTxLock == queueUNLOCKED ){    if( pxQueue->pxQueueSetContainer != NULL )    {        if( ( xCopyPosition == queueOVERWRITE ) && ( uxPreviousMessagesWaiting != 0 ) )            ...  /* 同 4.4: 覆盖不增条数, 不上报 */        else ifprvNotifyQueueSetContainer( pxQueue ) != pdFALSE )        {            if( pxHigherPriorityTaskWoken != NULL )                *pxHigherPriorityTaskWoken = pdTRUE;   /* 通知 ISR 退出时切上下文 */        }    }    else        ...  /* 普通队列: 唤醒 xTasksWaitingToReceive */}else    prvIncrementQueueTxLock( pxQueue, cTxLock );   /* 成员自身被锁定: 只记数, 上报推迟 */

ISR 版多了一层:成员自己锁着时,连上报都不做——只递增成员的 cTxLock,等成员的 prvUnlockQueue 补处理时再补上报(2.2 节表格的第四个调用点)。

4.6 等待与取出:xQueueSelectFromSet

/* queue.c xQueueSelectFromSet */QueueSetMemberHandle_t xQueueSelectFromSet( QueueSetHandle_t xQueueSet,                                            TickType_t const xTicksToWait ){    QueueSetMemberHandle_t xReturn = NULL;    ( void ) xQueueReceive( ( QueueHandle_t ) xQueueSet, &xReturn, xTicksToWait );    return xReturn;}

一行核心:把集强转回普通队列句柄,做普通 xQueueReceive。阻塞、超时、唤醒竞争、快/慢路径、锁计数——xQueueReceive 内部那一整套(判空 → 拷出指针 → 唤醒等空位的任务 → 慢路径挂 xTasksWaitingToReceive)全部免费继承。取出的 4 字节就是"有事件的成员"的句柄。

ISR 版 xQueueSelectFromSetFromISR同理复用 xQueueReceiveFromISR

为什么返回的是句柄不是数据:集的条目本来就只有 4 字节指针;数据在成员里,各成员条目大小不一(SensorMsg_t 12 字节、信号量 0 字节),集没法统一拷贝。两段式不是设计妥协,是唯一可行的方案。

4.7 主路径时序图:事件上报与两段式取数

4.8 精髓路径时序图:Select 慢路径进行中,ISR 上报

上一张图"唤醒等待者"的前提是集没被锁。若分发任务此刻正走在 Select 的慢路径上(挂链表的缝隙),集是锁定的——ISR 的上报不许动事件链表,只递增锁计数,由解锁时的补处理再唤醒:

要点:写指针发生在锁检查之前——锁定只禁事件链表、不禁数据写入,与普通队列"锁计数"同一套规矩。

4.9 竞争路径时序图:醒来后成员已被取空

队列集不独占成员——别的任务照样能直接 xQueueReceive(成员)。若高优先级任务抢在分发任务之前把数据收走,第二段就会失败:

要点:第二段 Receive 必须 0 超时 + 判返回值,失败就跳过本轮循环——这不是错误,是正常竞争(和普通队列"醒来回环重查"同一哲学)。若在此处重试或断言,反而把合法的并发行为变成了死机。

5. 场景示例:单消费者监听两条队列

5.1 实验设计

两生产者 + 一消费者:命令队列(长度 2)+ 数据队列(长度 3)挂入同一个队列集,消费者单点阻塞,验证两个核心结论——单点阻塞多源唤醒、两段式取数;消费者代码同时预置了竞争容忍分支(第二段失败即跳过,正常单消费者场景下不会触发)。

前置条件:FreeRTOSConfig.h 中 configUSE_QUEUE_SETS 改为 1 并重新编译;否则 xQueueCreateSet 等符号不存在(queue.c 里整段实现被 #if ( configUSE_QUEUE_SETS == 1 ) 裁掉)。

核心片段 1:创建队列集,挂入两个成员

#define QS_CMD_QUEUE_LEN    2#define QS_DATA_QUEUE_LEN   3/* 集长度必须 >= 所有成员容量之和: 每个成员最多上报自己容量个事件 */#define QS_SET_LEN          ( QS_CMD_QUEUE_LEN + QS_DATA_QUEUE_LEN )static QueueSetHandle_t xQueueSet;static QueueHandle_t    xCmdQueue, xDataQueue;voidqueue_set_demo(void){    /* 集本质是"装成员指针"的队列; 三个对象都从 4096B 堆里分配 */    xQueueSet  = xQueueCreateSet(QS_SET_LEN);    xCmdQueue  = xQueueCreate(QS_CMD_QUEUE_LEN, sizeof(int));    xDataQueue = xQueueCreate(QS_DATA_QUEUE_LEN, sizeof(int));    if (xQueueSet == NULL || xCmdQueue == NULL || xDataQueue == NULL)        return;   /* 堆不足 */    /* 挂入: 只能挂空队列, 且一个成员只能属于一个集 */    if (xQueueAddToSet(xCmdQueue, xQueueSet) != pdPASS ||        xQueueAddToSet(xDataQueue, xQueueSet) != pdPASS)        return;    /* 消费者优先级(+2)高于生产者(+1): 成员一入队即触发上报并唤醒消费者 */    printf("[队列集] xQueueCreateSet+命令/数据两成员队列, 消费者+2 生产者+1\r\n");    xTaskCreate(qs_consumer_task, "qs_rx", configMINIMAL_STACK_SIZE * 2NULL,                tskIDLE_PRIORITY + 2NULL);    xTaskCreate(qs_cmd_producer_task, "qs_cmd", configMINIMAL_STACK_SIZE, NULL,                tskIDLE_PRIORITY + 1NULL);    xTaskCreate(qs_data_producer_task, "qs_data", configMINIMAL_STACK_SIZE, NULL,                tskIDLE_PRIORITY + 1NULL);}

核心片段 2:两个生产者

/* 命令生产者: 每秒发一条递增命令号 */static void qs_cmd_producer_task(void *pvParameters){    int iCmd = 0;    for (;;)    {        vTaskDelay(pdMS_TO_TICKS(1000));        iCmd++;        /* 消费者优先级更高且即时取走, 队列不会满, 0 超时即可 */        xQueueSendToBack(xCmdQueue, &iCmd, 0);    }}/* 数据生产者: 错开 500ms 启动, 每秒发 10 的倍数(便于区分来源) */static void qs_data_producer_task(void *pvParameters){    int iData = 0;    vTaskDelay(pdMS_TO_TICKS(500));   /* 与命令源错开, 输出交替 */    for (;;)    {        vTaskDelay(pdMS_TO_TICKS(1000));        iData += 10;        xQueueSendToBack(xDataQueue, &iData, 0);    }}

核心片段 3:消费者——单点阻塞 + 两段式取数

staticvoidqs_consumer_task(void *pvParameters){    QueueSetMemberHandle_t xActivatedMember;    int iVal;    for (;;)    {        /* 单点阻塞: 集中任何成员来事件就醒, 返回"是哪个成员"的句柄 */        xActivatedMember = xQueueSelectFromSet(xQueueSet, portMAX_DELAY);        if (xActivatedMember == (QueueSetMemberHandle_t)xCmdQueue)        {            /* 第二段(必须做): Select 只消费了"事件指针", 真数据还在成员里;*/            /* 且可能已被更高优先级消费者抢先取走——失败要容忍, 不是错误 */            if (xQueueReceive(xCmdQueue, &iVal, 0) == pdPASS)                printf("[队列集] 来自命令队列: cmd=%d\r\n", iVal);            else                printf("[队列集] 命令队列事件被其它消费者取走(正常竞争)\r\n");        }        else if (xActivatedMember == (QueueSetMemberHandle_t)xDataQueue)        {            if (xQueueReceive(xDataQueue, &iVal, 0) == pdPASS)                printf("[队列集] 来自数据队列: data=%d\r\n", iVal);            else                printf("[队列集] 数据队列事件被其它消费者取走(正常竞争)\r\n");        }    }}

预期串口输出(节选):

[队列集] xQueueCreateSet+命令/数据两成员队列, 消费者+2 生产者+1[队列集] 来自命令队列: cmd=1[队列集] 来自数据队列: data=10[队列集] 来自命令队列: cmd=2[队列集] 来自数据队列: data=20  ......(每500ms一条, 命令/数据交替, cmd递增1data递增10)......

演示时序图

6. 注意事项(坑列表)

  1. Select 后必须做第二段 Receive/Take
    ——事件(指针)已被消费,数据还在成员里。漏做 = 该条数据永久滞留,且下次该成员来事件时仍能 Select 到它,错误被掩盖;
  2. 集长度 ≥ 成员容量之和
    :短了会发生"事件装不下"——上报函数里 uxMessagesWaiting < uxLength 防护不成立时,开发阶段(断言开)直接触发 configASSERT 死机,第一时间暴露;release 配置(断言关)才静默丢弃上报。丢的是通知不是数据,但该数据要等下次任何成员来事件才被"捎带"发现,属于埋雷型错误;
  3. 挂入/摘除必须在空队列时
    :非空挂入 pdFAIL(已有数据从未上报,Select 轮不到);非空摘除 pdFAIL(集里留着悬空指针);
  4. 一个成员只属于一个队列集
    pxQueueSetContainer 是单值字段,第二次 Add 直接 pdFAIL;
  5. 互斥量不能当成员
    :互斥量语义要求"谁拿谁还",但 Select 任何任务都能取走事件,所有权链断了;
  6. 任务侧 Select 醒来后成员可能又空了
    :更高优先级任务可能插队把数据先收走。第二段用 0 超时、判返回值,失败就跳过(见 4.9 时序图);
  7. 等成员的任务和队列集不要混用
    :成员挂入集后,直接 Send 该成员走的是上报路径、唤醒等在集上的任务;等在该成员自身链表上的任务不会被这个 Send 唤醒;
  8. 内存开销
    :队列集 = 又一条队列(本工程 heap_4 共 4096B,队列对象约 76 + 4×长度 字节),规划堆余量;
  9. configUSE_QUEUE_SETS = 1 才有这些 API
    :宏为 0 时 queue.c 里整段实现被裁掉,链接报"未定义符号"是第一症状。

总结

队列集最漂亮的地方在于它没有发明任何新机制——它就是一条"装队列指针"的普通队列,把消息队列那套东西原样复用了三次:

  1. 创建复用
    xQueueCreateSet 就是 xQueueGenericCreate,条目大小固定 4 字节(指针);
  2. 等待复用
    xQueueSelectFromSet 就是把集强转成普通队列做 xQueueReceive,阻塞、超时、快慢路径、锁计数全部免费继承;
  3. 上报复用
    :成员有事件时往集里写自己的指针,走的还是 prvCopyDataToQueue + 事件链表唤醒,集被锁时同样只递增锁计数、解锁时补唤醒。

唯一新增的东西是 QueueDefinition 里那个 pxQueueSetContainer 反向指针——成员靠它知道"我属于哪个集",集自己甚至不知道有哪些成员。

理解了"三次复用"这个核心,队列集的所有坑(两段式取数、集长度要算够、空队列才能挂入、互斥量不能当成员)都能从第一性原理推出来,不用死记。

— 写在最后 —

如果这篇文章对你有帮助,欢迎点赞、在看、转发三连支持,这是我持续更新的最大动力。

关注我,更多硬核技术剖析持续更新中,下一期再见。

相关学习资料

返回首页浏览学习资料