ARTICLE · 1010717
FreeRTOS 队列集源码剖析:一个任务同时等 8 条队列,它是怎么做到的?
一个任务要同时监听串口队列、按键队列、ADC 信号量三个事件源,怎么办?开三个任务各自阻塞?CPU 和栈都耗不起。轮询三条队列?要么忙等烧 CPU,要么响应延迟跟着轮询周期走。把三个源合并成一条大队列?各源的容量隔离、信号量语义全丢了。三种解法都有硬伤。
FreeRTOS 给的答案是 xQueueSelectFromSet——单任务、单阻塞点,任何一个成员有事件就醒来,还能拿到"是谁"。但这个答案背后藏着一个更漂亮的秘密:队列集没有新内核对象,它本身就是一条普通队列,装的还是指针。
这篇文章把 FreeRTOS V11.1.0 的 queue.c 队列集部分拆开,看成员怎么挂入、事件怎么上报、Select 怎么复用普通队列的阻塞与锁计数,最后用一段可跑的实验验证"单点阻塞、多源唤醒、两段式取数"。读完你会发现:队列集不是新机制,是队列机制的三次复用。
内核版本:FreeRTOS V11.1.0(Cortex-M3) configUSE_QUEUE_SETS = 0(FreeRTOS/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 家族
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) | xQueueReceiveFromISR((QueueHandle_t)set, &handle, NULL) |
1.3 没有队列集时的三种解法(都有硬伤)
xQueueReceive(q, &x, 0) | vTaskDelay → 响应延迟 = 轮询周期 |
2. 机制核心:全部复用普通队列
2.1 结论先行
队列集自身就是一条队列,消息队列那套三层保护(临界区、队列锁、锁计数补处理)对队列集原样生效。 本文不再重复推导,只列队列集特有的部分。普通队列的快/慢路径、锁计数怎么防止丢唤醒,见《FreeRTOS 消息队列源码刨析:为什么用了 xQueueSend 还是丢数据?》第 2、4 节(简单说就是:关中断只保护数据拷贝,任务挂链表期间中断来了只记数不碰链表,解锁时统一补唤醒)。
2.2 事件上报的四个调用点
成员队列每次 Send/Give 成功后,检查自己 pxQueueSetContainer != NULL 就调用 prvNotifyQueueSetContainer 上报。全 queue.c 共四处调用点:
xQueueGenericSend | ||
xQueueGenericSendFromISR | cTxLock == queueUNLOCKED | |
xQueueGiveFromISR | ||
prvUnlockQueue |
注意第四行的连带关系:ISR 写成员时若成员自己锁着(另有任务正阻塞在该成员上),连上报都不做——只递增成员的 cTxLock,等成员解锁补处理时再补上报。这和普通队列"锁定期间 ISR 只记数不动链表"是同一套规矩,只是又叠了一层。
2.3 上报时集自身被锁定怎么办
成员没锁、但队列集锁着(分发任务正走在 Select 的慢路径上、挂链表的缝隙中)——上报照常执行:往集里写指针是数据操作,锁定不禁;但唤醒链表不许碰,只递增集的cTxLock,由集的 prvUnlockQueue 稍后补唤醒。锁计数机制在两层各自独立生效,互不干扰。
3. 应用场景
| 不该用队列集 |
4. 源码剖析
4.1 创建:xQueueCreateSet
/* queue.c xQueueCreateSet */QueueSetHandle_t xQueueCreateSet(const UBaseType_t uxEventQueueLength ){QueueSetHandle_t pxQueue;pxQueue = xQueueGenericCreate( uxEventQueueLength,sizeof( Queue_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 ){if( listLIST_IS_EMPTY( &( pxQueueSetContainer->xTasksWaitingToReceive ) ) == pdFALSE ){if( xTaskRemoveFromEventList( &( pxQueueSetContainer->xTasksWaitingToReceive ) ) != pdFALSE )xReturn = pdTRUE; /* 唤醒了更高优先级任务, 要求调用处 yield */}/* else: 等待链表为空 → 没人可唤醒, 什么都不做 */}else{prvIncrementQueueTxLock( pxQueueSetContainer, cTxLock ); /* 集被锁定: 只记数不碰链表 */}}return xReturn;}
逐行拆解:
prvCopyDataToQueue(集, &pxQueue, ...)——把成员指针当数据 memcpy 进集的环形缓冲区。注意 &pxQueue:拷的是指针变量本身的 4 字节,不是队列内容;- 返回值语义
: pdTRUE表示"唤醒了更高优先级的等待者,需要切换",由四个调用点各自决定怎么响应(任务路径临界区内queueYIELD_IF_USING_PREEMPTION()、ISR 路径置pxHigherPriorityTaskWoken、解锁路径vTaskMissedYield); cTxLock判断:集被锁定(分发任务在 Select 慢路径的挂链表缝隙中)→ 只递增集的锁计数,唤醒推迟到集的 prvUnlockQueue补做——锁计数机制对集原样生效;- 满集防护的两种表现
: uxMessagesWaiting < uxLength不成立时,行为取决于断言开关——开发阶段(configASSERT开启)直接触发断言死机,第一时间暴露集长度不够;release 配置(断言关闭)才走到静默丢弃上报。这是个埋雷点:本地调试时集短了会死机,好查;上线后断言关了变成静默丢通知,更难查。所以"集长度必须 ≥ 成员容量之和"要在设计时算好,别指望运行时兜底。
4.4 调用点一:任务侧写入(xQueueGenericSend 快路径)
/* queue.c xQueueGenericSend 快路径中的队列集分支 */if( pxQueue->pxQueueSetContainer != NULL ){if( ( xCopyPosition == queueOVERWRITE ) && ( uxPreviousMessagesWaiting != 0 ) )/* overwrite 覆盖了已有条目, 条目数没变, 不上报 */...else if( prvNotifyQueueSetContainer( 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 if( prvNotifyQueueSetContainer( pxQueue ) != pdFALSE ){if( pxHigherPriorityTaskWoken != NULL )*pxHigherPriorityTaskWoken = pdTRUE; /* 通知 ISR 退出时切上下文 */}}else... /* 普通队列: 唤醒 xTasksWaitingToReceive */}elseprvIncrementQueueTxLock( 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 * 2, NULL,tskIDLE_PRIORITY + 2, NULL);xTaskCreate(qs_cmd_producer_task, "qs_cmd", configMINIMAL_STACK_SIZE, NULL,tskIDLE_PRIORITY + 1, NULL);xTaskCreate(qs_data_producer_task, "qs_data", configMINIMAL_STACK_SIZE, NULL,tskIDLE_PRIORITY + 1, NULL);}
核心片段 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);elseprintf("[队列集] 命令队列事件被其它消费者取走(正常竞争)\r\n");}else if (xActivatedMember == (QueueSetMemberHandle_t)xDataQueue){if (xQueueReceive(xDataQueue, &iVal, 0) == pdPASS)printf("[队列集] 来自数据队列: data=%d\r\n", iVal);elseprintf("[队列集] 数据队列事件被其它消费者取走(正常竞争)\r\n");}}}
预期串口输出(节选):
[队列集] xQueueCreateSet+命令/数据两成员队列, 消费者+2 生产者+1[队列集] 来自命令队列: cmd=1[队列集] 来自数据队列: data=10[队列集] 来自命令队列: cmd=2[队列集] 来自数据队列: data=20......(每500ms一条, 命令/数据交替, cmd递增1, data递增10)......
演示时序图

6. 注意事项(坑列表)
- Select 后必须做第二段 Receive/Take
——事件(指针)已被消费,数据还在成员里。漏做 = 该条数据永久滞留,且下次该成员来事件时仍能 Select 到它,错误被掩盖; - 集长度 ≥ 成员容量之和
:短了会发生"事件装不下"——上报函数里 uxMessagesWaiting < uxLength防护不成立时,开发阶段(断言开)直接触发 configASSERT 死机,第一时间暴露;release 配置(断言关)才静默丢弃上报。丢的是通知不是数据,但该数据要等下次任何成员来事件才被"捎带"发现,属于埋雷型错误; - 挂入/摘除必须在空队列时
:非空挂入 pdFAIL(已有数据从未上报,Select 轮不到);非空摘除 pdFAIL(集里留着悬空指针); - 一个成员只属于一个队列集
: pxQueueSetContainer是单值字段,第二次 Add 直接 pdFAIL; - 互斥量不能当成员
:互斥量语义要求"谁拿谁还",但 Select 任何任务都能取走事件,所有权链断了; - 任务侧 Select 醒来后成员可能又空了
:更高优先级任务可能插队把数据先收走。第二段用 0 超时、判返回值,失败就跳过(见 4.9 时序图); - 等成员的任务和队列集不要混用
:成员挂入集后,直接 Send 该成员走的是上报路径、唤醒等在集上的任务;等在该成员自身链表上的任务不会被这个 Send 唤醒; - 内存开销
:队列集 = 又一条队列(本工程 heap_4 共 4096B,队列对象约 76 + 4×长度 字节),规划堆余量; configUSE_QUEUE_SETS = 1才有这些 API:宏为 0 时 queue.c 里整段实现被裁掉,链接报"未定义符号"是第一症状。
总结
队列集最漂亮的地方在于它没有发明任何新机制——它就是一条"装队列指针"的普通队列,把消息队列那套东西原样复用了三次:
- 创建复用
: xQueueCreateSet就是xQueueGenericCreate,条目大小固定 4 字节(指针); - 等待复用
: xQueueSelectFromSet就是把集强转成普通队列做xQueueReceive,阻塞、超时、快慢路径、锁计数全部免费继承; - 上报复用
:成员有事件时往集里写自己的指针,走的还是 prvCopyDataToQueue+ 事件链表唤醒,集被锁时同样只递增锁计数、解锁时补唤醒。
唯一新增的东西是 QueueDefinition 里那个 pxQueueSetContainer 反向指针——成员靠它知道"我属于哪个集",集自己甚至不知道有哪些成员。
理解了"三次复用"这个核心,队列集的所有坑(两段式取数、集长度要算够、空队列才能挂入、互斥量不能当成员)都能从第一性原理推出来,不用死记。
— 写在最后 —
如果这篇文章对你有帮助,欢迎点赞、在看、转发三连支持,这是我持续更新的最大动力。
关注我,更多硬核技术剖析持续更新中,下一期再见。