资讯动态

FreeRTOS源码拆解:列表项与链表如何撑起任务调度

发布时间:2026/10/17 5:57:46 来源:尧图企业网站定制
说实话FreeRTOS 里最让我当初看懵的不是上下文切换那几行汇编而是列表List和列表项ListItem这两个基础结构。它们躺在 list.h 里满打满算不到一百行但真要把 xItemValue、pxIndex、vListInsertEnd 这些点串起来看你会发现整个调度器的筋骨其实都压在这套链表机制上。这篇文章我打算用源码阅读笔记的方式把列表项从结构定义到调度器中的真实用法一个一个拆开讲。适合刚接触 FreeRTOS 源码、或者已经把系统跑起来但想搞明白“为什么任务能轮着跑”的开发者。理解了列表项再回头去看 xTaskIncrementTick、vTaskSwitchContext、prvAddCurrentTaskToDelayedList 这些核心函数会顺畅非常多。1. 先把两个结构体刻进脑子里1.1 ListItem_t 和 List_t 的逐字段拆解从 list.h 里把关键定义拎出来看不同版本命名略有差异核心结构一致struct xLIST_ITEM { configLIST_VOLATILE TickType_t xItemValue; struct xLIST_ITEM * configLIST_VOLATILE pxNext; struct xLIST_ITEM * configLIST_VOLATILE pxPrevious; void * pvOwner; struct xLIST * configLIST_VOLATILE pxContainer; #if ( configUSE_LIST_ITEM_INTEGRITY_CHECK 1 ) uint32_t uxListIntegrityCheck; #endif }; typedef struct xLIST_ITEM ListItem_t; struct xMINI_LIST_ITEM { configLIST_VOLATILE TickType_t xItemValue; struct xLIST_ITEM * configLIST_VOLATILE pxNext; struct xLIST_ITEM * configLIST_VOLATILE pxPrevious; }; typedef struct xMINI_LIST_ITEM MiniListItem_t; typedef struct xLIST { configLIST_VOLATILE UBaseType_t uxNumberOfItems; ListItem_t * configLIST_VOLATILE pxIndex; MiniListItem_t xListEnd; } List_t;先说 ListItem_t 的每个字段字段作用理解要点xItemValue排序键值决定该项在链表中的位置它不是固定含义的“数值”在不同列表中承载不同语义下文重点讲pxNext / pxPrevious双向链表的前后指针循环链表没有 NULL 结尾pvOwner指向拥有这个列表项的对象就绪列表里通常指向任务 TCBpxContainer指向该项当前所在的 List_t用于判断该项是否已经在某个链表中以及移除时回写uxListIntegrityCheck完整性检查填充值可选功能用于抓内存踩踏List_t 里值得注意的地方有两个一是 uxNumberOfItems 记录当前链表元素个数二就是那个初看很别扭的 MiniListItem_t 型的 xListEnd 哨兵节点。MiniListItem_t 其实只保留了 ListItem_t 的前三个字段xItemValue、pxNext、pxPrevious。因为哨兵节点不需要 pvOwner也不需要 pxContainer它只是一个“锚点”用来标记链表的起点和终点。C 语言里这两个结构体的头部内存布局一致所以代码里可以把 MiniListItem_t 的地址强转成 ListItem_t* 来操作这是后面各类宏和函数能跑通的前提。1.2 为什么 FreeRTOS 要自己造一个链表很多初学者会问系统里现成的链表不香吗为什么非要自己维护一套答案是——体积和裁剪性。FreeRTOS 的目标是能在几 KB Flash 的单片机上跑一个通用链表库往往会引入动态分配、迭代器、泛型等重机制调度器根本用不上。内核只需要“按值有序插入”“在当前位置之前插入”“摘除指定节点”“取下一个节点”这四种能力自己写一个循环双向链表代码量极小还完全不依赖标准库。另一个原因是实时性。任务调度器可能随时被 tick 中断打断链表操作需要尽可能短小且行为可预期。自己维护一套没有任何外部依赖的链表配合 configLIST_VOLATILE 关键字能让临界区的保护逻辑变得非常简单——这在中断里改链表时尤其重要。这个设计思路其实也给所有嵌入式开发者提了个醒不要盲目照搬上位机的“优雅设计”先看看当前场景到底需要什么。FreeRTOS 的链表源码简陋但极其实用就是这种“克制”的产物。2. xItemValue一个字段三种身份2.1 它就是整个链表排序的“排队号”xItemValue 最容易让人误解的地方在于它不是一个“值”而是一个排序依据。vListInsert 函数里对这个字段做比较值越小越靠近链表头部值越大越靠近尾部。可以把它理解成银行排队的取号码号码小的先办业务号码大的往后排。FreeRTOS 源码里大量出现对 xItemValue 的赋值操作比如 listSET_LIST_ITEM_VALUE、listGET_LIST_ITEM_VALUE这些宏本质上就是直接读写这个字段。值具体是什么含义完全取决于这个列表项被放到哪条链表里。正因为如此读源码时一旦发现某个列表项的 xItemValue 被赋值第一反应不应该是“记录了一个数”而是“它在为某条链表排序”。2.2 在就绪列表里是优先级在延时列表里是唤醒时刻同一个 xStateListItem进入就绪列表时xItemValue 会被设置成任务优先级或其编码值进入延时列表时又会被设置成任务的唤醒时刻。这是它最常见的两种身份。先看就绪列表。任务创建时调用 prvAddTaskToReadyList往里插入的就是任务 TCB 里的 xStateListItem。此时 xItemValue 直接反映优先级比如listSET_LIST_ITEM_VALUE( ( pxTCB-xStateListItem ), ( TickType_t ) pxTCB-uxPriority );不同版本写法会有差异有些版本为了把“高优先级”映射成“更靠前”的数会写成configMAX_PRIORITIES - uxPriority本质都是用值表达优先级。就绪列表数组本身就是按下标索引优先级的同时列表内部的顺序则表达了同优先级任务的排队顺序。再看延时列表。当任务调用 vTaskDelay或等待队列、信号量时调度器会调用 prvAddCurrentTaskToDelayedList把当前任务挪到延时列表里。这时 xItemValue 被设置为任务的绝对唤醒时间/* 计算唤醒时刻可能是当前 tick 延时节拍要考虑溢出 */ xTimeToWake xConstTickCount xTicksToWait; listSET_LIST_ITEM_VALUE( ( pxCurrentTCB-xStateListItem ), xTimeToWake );唤醒时间越小说明越早该醒来所以排在延时列表头部。系统每次 tick 中断只需要看链表头一个任务是否到期如果没到期后面的任务更不可能到期如果到期了就把它摘下来重新放回就绪列表。这就是延时列表能高效工作的根本原因——有序链表把 O(n) 的“找最早到期任务”变成了 O(1) 的“看第一个”。2.3 portMAX_DELAY 和 TickType_t 溢出问题既然 xItemValue 是 TickType_t 类型就绕不开位数和溢出问题。FreeRTOS 允许把 TickType_t 配置成 16 位或 32 位。16 位时最大值是 0xFFFF。延时列表里哨兵节点 xListEnd 的 xItemValue 被初始化成 portMAX_DELAY也就是 0xFFFFFFFF 或 0xFFFF。它表示“无穷大”任何真实任务都不可能排在哨兵后面。这里隐藏着一个极容易困惑的细节如果新列表项的 xItemValue 正好等于 portMAX_DELAYvListInsert 里有一段特殊判断会把这个项直接插到哨兵之前。否则按正常升序插入逻辑走有可能因为哨兵本身的值也等于 portMAX_DELAY导致循环条件永远成立从而死循环。源码里之所以单独写if( xValue portMAX_DELAY ) { pxIterator ( ListItem_t * ) ( pxList-xListEnd ); } else { /* 正常升序查找 */ }就是专门为了堵住这个边界情况。还有一个更隐蔽的问题绝对唤醒时间 xTimeToWake 在计算时允许自然回绕。比如 TickType_t 是 16 位当前 tick 是 65530延时 20 个 tick数学上是 65550但对 16 位无符号数来说这等于 65550 mod 65536 14。这不表示任务立即到期而是表示“当 tick 数回绕并从 15、16... 走到 14 时任务醒来”。FreeRTOS 的延时列表设计本质上容忍了这种回绕只要比较时都使用无符号差值的方式就能正确处理。自己写类似逻辑时千万不要用相对时间排序否则一旦回绕就会出错。提示读这类源码时看到 xItemValue第一反应应该是“这又是给哪条链表排序用的”而不是“这个数本身是什么”。搞清了身份后面所有函数都顺了。3. pxIndex 和哨兵节点读懂这两个链表才算入门3.1 为什么需要哨兵节点 xListEnd先看 vListInitialise 的源码片段void vListInitialise( List_t * const pxList ) { pxList-pxIndex ( ListItem_t * ) ( pxList-xListEnd ); pxList-uxNumberOfItems ( UBaseType_t ) 0U; pxList-xListEnd.xItemValue portMAX_DELAY; pxList-xListEnd.pxNext ( ListItem_t * ) ( pxList-xListEnd ); pxList-xListEnd.pxPrevious ( ListItem_t * ) ( pxList-xListEnd ); }初始化后空链表里只有哨兵节点 xListEnd它的 pxNext 和 pxPrevious 都指向自己。这个自环结构非常关键无论链表是否为空插入和删除代码都不需要判断 NULL也不需要特殊处理头尾分支。想象一下用哨兵来写链表操作有多省事插入时只要找到合适的位置把新节点的前后指针接上即可删除时只需要修改前后两个节点的指针。空表和普通表走的是同一套代码没有任何边界分支。这比传统“头指针 NULL”的链表实现要干净得多。MiniListItem_t 之所以能放在 List_t 内部也是因为它是“哨兵中的哨兵”只负责标记链表的循环始终点不需要 owner也不需要 container。3.2 pxIndex 不是头指针是“当前读到哪了”的游标pxIndex 的命名很容易让人误以为是“指向头部节点”的索引但实际它是一个遍历游标。初始化时pxIndex 指向 xListEnd。它的作用主要有两个一个是配合 vListInsertEnd 做“当前位置之前插入”另一个是配合 listGET_OWNER_OF_NEXT_ENTRY 实现任务轮转调度。可以把整条链表想象成一个圆桌xListEnd 是桌面上一个固定刻痕pxIndex 是你手里的一支笔指到哪里下一次读取就从哪里开始。vListInsertEnd 不是“尾插”而是在这支笔当前位置的前面放一个新的座位。当笔停在 End 上时看起来就是尾插当笔已经指到中间某个节点时新座位会被放到当前座位和上一个座位之间。对照源码看void vListInsertEnd( List_t * const pxList, ListItem_t * const pxNewListItem ) { ListItem_t * const pxIndex pxList-pxIndex; pxNewListItem-pxNext pxIndex; pxNewListItem-pxPrevious pxIndex-pxPrevious; pxIndex-pxPrevious-pxNext pxNewListItem; pxIndex-pxPrevious pxNewListItem; pxNewListItem-pxContainer pxList; ( pxList-uxNumberOfItems ); }这里新节点的 next 指向 pxIndexprevious 指向 pxIndex 的前一个节点然后让原来前一个节点的 next 指向新节点pxIndex 的 previous 指向新节点。完成插入后pxIndex 本身并没有移动所以新节点是排在整个“当前轮次”之后的。配合 listGET_OWNER_OF_NEXT_ENTRY 宏就能实现时间片轮转的基本动作。每次选任务前先把 pxIndex 向后移一位如果移到 End就再向后移一位跳到第一个真实节点。这个宏长这样#define listGET_OWNER_OF_NEXT_ENTRY( pxTCB, pxList ) \ { \ List_t * const pxConstList ( pxList ); \ ( pxConstList )-pxIndex ( pxConstList )-pxIndex-pxNext; \ if( ( void * ) ( pxConstList )-pxIndex ( void * ) ( ( pxConstList )-xListEnd ) ) \ { \ ( pxConstList )-pxIndex ( pxConstList )-pxIndex-pxNext; \ } \ ( pxTCB ) ( pxConstList )-pxIndex-pvOwner; \ }看到 pxIndex 先移动、再判断是否撞到 End、再取 pvOwner轮转的含义就很清楚了。这就是就绪列表同优先级任务能轮流被调度的核心原理。提示pxIndex 不是“头指针”头指针如果用哨兵模型是 End 的 pxNext。pxIndex 是一个会移动的游标它让“下一次从哪开始”这件事有了状态。4. 三个关键操作vListInsert、vListInsertEnd、uxListRemove4.1 vListInsert按排队号升序插队vListInsert 的作用是把一个新列表项按 xItemValue 升序插入链表。如果链表已经按升序排好新插入后依然有序。看关键源码void vListInsert( List_t * const pxList, ListItem_t * const pxNewListItem ) { ListItem_t * pxIterator; const TickType_t xValue pxNewListItem-xItemValue; if( xValue portMAX_DELAY ) { pxIterator ( ListItem_t * ) ( pxList-xListEnd ); } else { for( pxIterator ( ListItem_t * ) ( pxList-xListEnd ); pxIterator-pxNext-xItemValue xValue; pxIterator pxIterator-pxNext ) { /* 什么都不做只移动指针 */ } } pxNewListItem-pxNext pxIterator-pxNext; pxNewListItem-pxNext-pxPrevious pxNewListItem; pxNewListItem-pxPrevious pxIterator; pxIterator-pxNext pxNewListItem; pxNewListItem-pxContainer pxList; ( pxList-uxNumberOfItems ); }这段代码里最反直觉的是循环条件pxIterator-pxNext-xItemValue xValue意思是“只要下一个节点的值还小于等于新节点的值就继续往后走”。当遇到第一个大于新值的节点时停止把新节点插到它前面。注意这里用的是不是。这个等号带来了一个非常重要的性质相等值的新节点会排到旧节点后面也就是保持了 FIFO先进先出。同优先级任务加入就绪列表时先进去的任务先从列表里被取出来排队顺序稳定。拿一个空列表举例。连续插入 xItemValue 分别为 10、20、10 的三个节点插入 10End 的 pxNext 是 End 自己End 的 xItemValue 是 portMAX_DELAY比 10 大循环不执行插在 End 后面。链表现在是 End - 10 - End。插入 20从 End 开始第一个节点 10 20继续走第二个节点是 EndEnd 的 value 是 portMAX_DELAY大于 20停止。插到 End 前面链表现为 End - 10 - 20 - End。再次插入 10从 End 开始第一个节点 10 10 成立继续走下一个节点 20 10停止。因此新 10 插在旧 10 和 20 之间链表现为 End - 10(旧) - 10(新) - 20 - End。正是因为等号的存在相同排序值的多个节点才能保持“先来后到”。4.2 vListInsertEnd不是尾插是“当前位置之前插入”vListInsertEnd 这个名字极具误导性。它不是传统意义上的尾部插入而是把新节点插入到 pxIndex 指向的节点之前。源码在前面已经贴过这里重点解释它和 vListInsert 的本质差异操作排序依据适用场景vListInsert按 xItemValue 升序相等时保持 FIFO延时列表、事件列表、按优先级入队vListInsertEnd插到 pxIndex 之前不比较 xItemValue时间片轮转把当前任务放到“本轮最后”理解这个差异的关键在于当 pxIndex 指向 End 时vListInsertEnd 的效果等同尾插但当 pxIndex 已经移动到某个真实节点时插入的位置就变成当前节点之前。再配合时间片轮转的调用场景xTaskIncrementTick 发现当前任务的同优先级队列里还有别的任务在等就会先把当前任务从就绪列表中移除再调用 vListInsertEnd 把它插回队列。此时 pxIndex 正指向下一个应该运行的任务插入后的效果是当前任务被排到了整个队列的“末尾”。从效果上看这就像动车上加座你把当前乘客挪到了队尾队头就自然轮到下一位乘客。这也是同优先级任务能轮流获得 CPU 时间片的底层逻辑。4.3 uxListRemove摘除节点别忘了返回值uxListRemove 负责把节点从链表中摘除。看源码UBaseType_t uxListRemove( ListItem_t * const pxItemToRemove ) { List_t * const pxList pxItemToRemove-pxContainer; pxItemToRemove-pxNext-pxPrevious pxItemToRemove-pxPrevious; pxItemToRemove-pxPrevious-pxNext pxItemToRemove-pxNext; pxItemToRemove-pxContainer NULL; ( pxList-uxNumberOfItems )--; return pxList-uxNumberOfItems; }这里有几个容易忽略的关键点移除前必须先确保 pxItemToRemove-pxContainer 不是 NULL否则 pxList 是野指针一访问就崩。pxContainer 在移除后被置为 NULL防止同一个节点被移除两次。返回值是移除后链表的剩余节点数。很多调用点拿这个返回值来判断“当前列表是否还有人”比如if( uxListRemove( ( pxCurrentTCB-xStateListItem ) ) 0 ) { /* 说明这个优先级的就绪列表已经空了 */ }在 16 位系统中uxNumberOfItems 的类型是 UBaseType_t这个返回值也是无符号类型注意不要依赖它做“移除前个数”的判断不同 FreeRTOS 版本的语义可能不同。提示uListRemove 的命名以 ux 开头因为它有返回值。看到函数名里带 ux 前缀的多半是返回 UBaseType_t 的内部函数。5. 列表项在任务调度里到底是怎么被调用的5.1 TCB 里的两个列表项xStateListItem 和 xEventListItem打开 tasks.c 里的 TCB 结构任务控制块里有几个和列表项相关的成员ListItem_t xStateListItem; ListItem_t xEventListItem;这两个列表项非常重要也容易搞混。xStateListItem 代表任务当前所处的状态。它会被放到就绪列表、延时列表、挂起列表等“状态相关链表”中。当任务状态发生变化时调度器就把它从旧链表摘下来插入新链表。比如任务被延时就是从就绪列表摘下插入延时列表。xEventListItem 则用于任务等待某个事件的情况比如等待队列消息、等待信号量、等待事件组。在事件未满足时任务会进入阻塞列表这时用的是 xEventListItem。事件列表也是按优先级排序的所以高优先级的等待者会排更前面事件触发时可以优先被唤醒。这里有一个很实用的区分技巧看列表项所在的链表用途。凡是表示“任务当前状态”的链表插的是 xStateListItem凡是表示“任务在等某个事件”的链表插的是 xEventListItem。两条链表可能在调度器里并行存在互不干扰。5.2 就绪列表优先级加同优先级排队FreeRTOS 维护了一组就绪列表数组数组下标就是优先级编号PRIVILEGED_DATA static List_t pxReadyTasksLists[ configMAX_PRIORITIES ];需要调度时vTaskSwitchContext 从最高优先级的就绪列表开始往下找找到第一个非空列表后调用 listGET_OWNER_OF_NEXT_ENTRY 取出一个任务。这一步就已经用到了前面讲的 pxIndex 游标。同优先级任务入队时走的是 vListInsert所以相同优先级的任务会按入队顺序排好。一旦某个任务被选中运行下一次 tick 时如果时间片用完调度器又会把它从队列里摘下来再用 vListInsertEnd 放回队尾。这样一来同优先级任务内部就是严格的循环轮转A、B、C、A、B、C 的顺序反复执行。这个过程有两个维度横向看优先级数组从上往下选保证高优先级优先纵向看同优先级内部轮转保证公平。列表项在这两个维度上都扮演了核心角色。5.3 延时队列按绝对唤醒时间排队的闹钟列表延时列表的处理是 xItemValue 应用得最漂亮的地方。前面说过任务延时后 xItemValue 被设置成绝对唤醒时刻然后通过 vListInsert 插到延时列表。每次 tick 中断xTaskIncrementTick 会检查延时列表的第一个任务if( xConstTickCount listGET_ITEM_VALUE_OF_HEAD_ENTRY( pxDelayedTaskList ) ) { /* 任务到期需要唤醒 */ }由于列表是有序的第一个任务就是所有延时任务里最早到期的那一个。如果第一个任务都没到期后面的更不可能到期直接跳过。这个“只看头部”的算法让延时判断的时间复杂度从 O(n) 降到 O(1)对中断处理来说是质变。列表项的排序价值在这个场景下体现得淋漓尽致。这里特别提醒一下延时列表里的 xItemValue 是绝对时间不是剩余时间。自己写类似闹钟列表时如果存的是“还要等多久”每次 tick 都要遍历整个列表去减剩余值效率很低如果存的是绝对唤醒时刻每次只需要比较一次。6. 我自己验证这些理解时踩过的坑6.1 用最小工程打印链表看书和读源码都容易产生“以为自己懂了”的错觉尤其是链表这种依赖指针关系的结构。我建议你上手跑一个最小工程直接把链表关系打印出来。做法不复杂创建一个 FreeRTOS 工程在 main 里定义几个 ListItem_t 变量手动调用 vListInitialise、vListInsert、vListInsertEnd然后用串口或者调试器看每个节点的 pxNext、pxPrevious 指向。我写过这样一个测试函数static void printList( List_t *pxList ) { ListItem_t *pxItem; UBaseType_t uxCount pxList-uxNumberOfItems; volatile ListItem_t *pxEnd ( pxList-xListEnd ); printf( number%lu\n, uxCount ); pxItem pxEnd-pxNext; for( UBaseType_t i 0; i uxCount; i ) { printf( item value%lu addr%p\n, pxItem-xItemValue, pxItem ); pxItem pxItem-pxNext; } }循环次数直接取 uxNumberOfItems而不是判断是否碰到 End。因为链表是环形的用 End 判断结束会非常痛苦碰到 End 时其实已经多走了一圈的一半。用这个函数分别测 vListInsert 插入 10、20、10和 vListInsertEnd 插入两个节点观察顺序差异。纸上得来终觉浅这种实验比看一百遍源码都有用。6.2 常见理解误区速查表这些年我在各种技术社区看 FreeRTOS 源码讨论发现大家最容易踩的坑基本集中在下面几个误区实际情况vListInsertEnd 是尾插它是插到 pxIndex 之前只有当 pxIndex 在 End 时才等价于尾插xItemValue 固定表示优先级它在就绪列表里是优先级在延时列表里是绝对唤醒时间在不同列表语义不同pxIndex 指向链表的第一个节点初始化时指向 End它是一个可移动的游标空链表时 End 的 pxNext 是 NULL不是 NULL是自环指向 End 自己列表项是头部指针加 NULL 的普通链表是带哨兵节点的循环双向链表uxListRemove 返回值是移除前的个数通常是移除后的剩余个数具体语义看版本延时剩余时间存在 xItemValue 里存的是绝对唤醒时刻到期判断基于当前 tick 与唤醒时刻的比较第 2 条和第 5 条是最多人问的也是理解 FreeRTOS 调度的关键我的建议是单独抄一遍源码注释掉所有分支手动走一遍插入、删除、遍历的三种场景推演。6.3 几个值得打开源码确认的细节除了上面讲的核心还有几个细节值得亲自去源码里看一眼它们能帮你把整个链路补完整。一个是 configUSE_LIST_ITEM_INTEGRITY_CHECK。打开这个宏后每个列表项会填充完整性字段链表操作前后会做校验一旦发现字段被改写会触发断言。这玩意在调试内存越界时极其好用。我遇到过一次诡异的调度错乱百思不得其解开了这个宏后立刻定位到是某个缓冲区越界把相邻 TCB 的列表项踩了。生产环境可以关掉省点时间和空间但调试阶段强烈建议打开。另一个是 configLIST_VOLATILE 这个看似莫名其妙的关键字。列表结构里的成员都被声明成 volatile是因为链表会被中断上下文和任务上下文同时访问。tick 中断可能正在修改就绪列表或延时列表而任务切换代码同时也在读如果没有 volatile编译器可能把某些指针缓存到寄存器里导致读到陈旧数据。这里不需要过度解读但要知道它存在的原因。最后就是我反复强调的阅读源码的方法永远不要只看一个函数要把调用它的地方和它所在的链表联系起来。列表项本身只是一堆指针真正赋予它含义的是那些把它塞进不同链表的调度逻辑。我个人在实际操作中的体会是FreeRTOS 的源码难啃难不在语法而在“每个基础结构都被复用在多个场景里”。列表项就是最典型的例子——同一个 xStateListItem换个列表含义就变。把 xItemValue、pxIndex 和哨兵节点这三个概念用自己的话讲清楚再回去看调度器你会有一种“原来如此”的通透感。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑