☰
FreeRTOS源码解析:深入列表项、哨兵节点与任务调度机制
2026/10/17 1:33:51 网站建设 项目流程

学 FreeRTOS 源码,最容易被卡住的地方往往不是调度器本身,而是它底层的列表(List)和列表项(ListItem)。调度器、队列、事件组、软件定时器,几乎所有机制都在操作这几条链表。上一篇笔记把内核的基础数据结构框架过了一遍,这一篇我打算专门啃列表项:xItemValue 到底存什么,pxIndex 为什么像“书签”,vListInsertEnd 和 vListInsert 有什么本质区别,以及 xListEnd 这个哨兵节点为什么能撑起整个双向循环链表。适合那些已经把 FreeRTOS 跑通 demo、正准备往源码里钻的同学。看完你会发现,列表项其实就是一堆带标签的钩子,任务调度不过是这些钩子之间的跳来跳去。

1. 从结构体入手:列表项和列表到底长什么样

1.1 ListItem_t 的五个字段,每个都不是白给的

先看列表项的核心结构体。不同内核版本里字段名可能有微调,但本质一致:

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; }; typedef struct xLIST_ITEM ListItem_t;

第一个字段 xItemValue,类型是 TickType_t。名字很直白:这个列表项在列表里的“排序值”。后面我会专门展开讲,这里先记住一个结论:列表是有序的,排序依据就是它。

pxNext 和 pxPrevious 就是双向链表的两个指针,分别指向后继和前驱。用生活化的方式理解:列表项就像火车车厢,pxNext 是连接后面车厢的钩子,pxPrevious 是连接前面车厢的钩子。FreeRTOS 的这个链表是循环的,所以最后一节车厢后面的钩子会勾回第一节车厢。

pvOwner 是指向“谁拥有这个列表项”的指针。最常见的情况是,列表项属于某个任务控制块(TCB),pvOwner 就指向那个任务。为什么需要这个字段?因为调度器在遍历就绪列表时,只知道当前这个列表项,并不知道它属于哪个任务。拿着 pvOwner 往上一转,就能拿到完整的任务结构体,然后才能做上下文切换。这个“从列表项反查任务”的链路,是理解后面所有调度代码的关键。

最后一个字段 pxContainer 是指向“这个列表项当前挂在哪个列表”的指针。换句话说,每个列表项都记得自己属于哪条链表。这带来一个非常实用的效果:当你调用 uxListRemove 删除一个列表项时,只需要把列表项指针传进去,函数自己通过 pxContainer 就能找到它所在的列表,不需要额外再传一个“所在列表”参数。这个设计我在第一次读源码时根本没注意到,后来自己写链表才发现这个“反向索引”能省掉不少麻烦。

1.2 List_t 的组成:计数、游标和哨兵

列表本身的结构体长这样:

typedef struct xLIST { UBaseType_t uxNumberOfItems; ListItem_t * configLIST_VOLATILE pxIndex; MiniListItem_t xListEnd; } List_t;

三个字段,各有分工:

  • uxNumberOfItems:列表里当前有多少个列表项。在判断列表是否为空时非常常用。
  • pxIndex:列表的“游标”,也可以理解成书签。后面讲时间片轮转时会说明它的真正用途。
  • xListEnd:这是一个哨兵节点,类型是 MiniListItem_t,不是完整的 ListItem_t。

MiniListItem_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;

它比 ListItem_t 少了 pvOwner 和 pxContainer 两个字段。原因是哨兵节点本身不归属任何任务,也不需要挂在某个列表上,只要保存“列表终点”的信息就够了。这个裁剪不仅节省内存,还向读代码的人传达了一个信息:xListEnd 不是真实任务节点,是边界。

初始化列表的代码也很典型:

void vListInitialise( List_t * const pxList ) { pxList->uxNumberOfItems = ( UBaseType_t ) 0U; pxList->pxIndex = ( ListItem_t * ) &( pxList->xListEnd ); pxList->xListEnd.xItemValue = configLIST_END; pxList->xListEnd.pxNext = ( ListItem_t * ) &( pxList->xListEnd ); pxList->xListEnd.pxPrevious = ( ListItem_t * ) &( pxList->xListEnd ); }

初始化之后,空列表只有一个哨兵节点,哨兵的 pxNext 和 pxPrevious 都指向自己,pxIndex 也指向自己。这时候列表就处于“自循环”状态,像一条首尾相接但没有任何乘客车厢的轨道。

有一个让我当初很困惑的点:xListEnd 明明是 MiniListItem_t,为什么代码里到处都把它强转成 ListItem_t 来用?比如上面 vListInitialise 里pxList->pxIndex = ( ListItem_t * ) &( pxList->xListEnd );。原因在于 MiniListItem_t 和 ListItem_t 的前三个字段完全一样,在常见编译器下内存布局是兼容的。把 xListEnd 地址当作 ListItem_t 指针去访问 xItemValue、pxNext、pxPrevious 时,实际访问到的还是同样的成员。这是 FreeRTOS 有意依赖的一个技巧,理解之后,再看到哨兵被强转就不会慌了。

1.3 为什么需要 pxContainer/pvOwner 这种“反查指针”

写普通链表时,我们通常会直接操作 next、prev 指针,很少在节点里保存“我在哪个链表里”的信息。但 FreeRTOS 里的列表项会被频繁移动:任务被创建、阻塞、唤醒、删除,这些操作都涉及把列表项从 A 链表挪到 B 链表。如果没有 pxContainer,删除一个列表项时必须同时记住它属于哪个列表,调用方很容易出错。

有了 pxContainer,uxListRemove 就能这样工作:

UBaseType_t uxListRemove( ListItem_t * const pxItemToRemove ) { List_t * const pxList = pxItemToRemove->pxContainer; pxItemToRemove->pxNext->pxPrevious = pxItemToRemove->pxPrevious; pxItemToRemove->pxPrevious->pxNext = pxItemToRemove->pxNext; if( pxList->pxIndex == pxItemToRemove ) { pxList->pxIndex = pxItemToRemove->pxPrevious; } pxItemToRemove->pxContainer = NULL; ( pxList->uxNumberOfItems )--; return pxList->uxNumberOfItems; }

注意,函数第一步就是通过pxItemToRemove->pxContainer拿到列表地址,后面所有操作都在这个列表上完成。删除完成后,代码会把 pxContainer 置成 NULL,相当于给这个列表项打上“我已不在任何链表上”的标记。这也是后面断言的基础:往列表里插入一个列表项之前,FreeRTOS 会检查pxItemToRemove->pxContainer == NULL,如果发现它还在某个列表上,就直接触发断言。这个检查能防止同一个列表项被同时插入两条链表,避免链表结构被破坏。

pvOwner 的作用也很类似。它让调度器可以只凭一个列表项指针,就拿到拥有者任务 TCB。FreeRTOS 源码里经常能看到这类代码:先拿到列表项,然后( TCB_t * ) pxListItem->pvOwner。换句话说,列表是调度器的“台账”,台账上每一项后面都连着一个任务。

2. xItemValue:列表的排序依据,最容易想当然

2.1 xItemValue 在不同列表里有不同含义

第一次读源码时,我犯过一个大错:想当然地以为 xItemValue 就是任务优先级。实际上,这个字段的语义取决于这个列表项被挂到了哪条列表上。

在就绪列表里,xItemValue 确实通常存的是任务优先级。例如任务被创建时,会执行类似这样的初始化:

listSET_LIST_ITEM_VALUE( &( pxNewTCB->xStateListItem ), ( TickType_t ) uxPriority );

这样同优先级的任务会拥有相同的 xItemValue,而不同优先级的任务在列表中的相对顺序就能靠它确定。

但在延时列表里,xItemValue 存的是任务应该被唤醒的 tick 值,而不是优先级。任务调用 vTaskDelay 进入阻塞时,会把xWakeTime写入列表项的 xItemValue,然后用 vListInsert 把这个列表项按唤醒时间升序插入延时列表。这样一来,内核每次只需要看延时列表的第一个任务,就知道下一个该叫醒谁。同一个字段,在就绪列表里是优先级,在延时列表里是时间戳,这是理解 FreeRTOS 列表的一个大前提:别把字段名和业务语义绑定死。

2.2 哨兵 xListEnd 占了最大的那个值:portMAX_DELAY

前面 vListInitialise 里有一行:pxList->xListEnd.xItemValue = configLIST_END;。configLIST_END 在宏定义里被设置成端口相关的“最大可表示值”:

  • 16 位 TickType_t 时是 0xFFFF;
  • 32 位 TickType_t 时是 0xFFFFFFFF。

这个值其实就等于 portMAX_DELAY。为什么哨兵要占这么大的值?因为列表是按 xItemValue 升序排列的,哨兵放在末尾,就相当于一个“终点标志”。遍历列表时,只要看到 xListEnd,就说明已经走完一圈了。

这个设计在 vListInsert 里非常关键。vListInsert 的大致逻辑是从 xListEnd 开始往后遍历,找到第一个“下一个节点的 xItemValue 大于新节点 xItemValue”的位置,然后把新节点插到它前面。由于哨兵的 xItemValue 是最大的,当遍历到哨兵时,条件一定会失败,循环一定停下。如果没有这个哨兵,就得在循环里额外判断“是不是绕回来了”,代码会复杂得多。

不过,哨兵值太大也会带来一个边界问题:如果新列表项的 xItemValue 恰好也是 portMAX_DELAY,那常规循环会怎样?遍历到哨兵时,条件变成了“portMAX_DELAY <= portMAX_DELAY”,永远成立,循环就会一直绕圈。所以 vListInsert 里针对这个情况做了特判:直接找到哨兵的前一个节点,把新节点插到它后面,也就是插到整个链表的真正末尾。这个特判在源码里是一个独立的 if 分支,不看注释的话很多人第一次读到会愣住,我就是愣住之后才明白它是为了绕开哨兵自指造成的死循环。

2.3 vListInitialiseItem 只清 pxContainer,别指望它清零 xItemValue

还有一个非常容易踩的小坑:vListInitialiseItem 这个“初始化列表项”的函数,并不清 xItemValue。

void vListInitialiseItem( ListItem_t * const pxItem ) { pxItem->pxContainer = NULL; }

第一次看到这个函数时,我以为它会顺便把 xItemValue 设成 0。结果翻到定义,只有一行。这意味着列表项刚初始化完时,xItemValue 里存的是随机值。如果后续没有显式调用 listSET_LIST_ITEM_VALUE 给它赋值,就把这个列表项插进列表,排序就会错乱。

FreeRTOS 内部创建任务时,会紧接着给两个列表项分别设置值,所以这个问题在内核自身代码里不容易暴露。但如果你要在自己的驱动或者组件里复用这种列表结构,一定要记得:每个列表项在插入前都必须明确设置它的 xItemValue。这是那种“文档里不会写,踩一次就记住了”的细节。

3. pxIndex 与 vListInsertEnd:时间片轮转的幕后推手

3.1 vListInsertEnd 源码:插到 pxIndex 后面,而不是真正的链表末尾

很多人在初学时会以为 vListInsertEnd 就是“把节点追加到链表尾部”。从函数名看确实像,但严格来说它插到的是当前 pxIndex 指向节点的后面,并且插入完成后会把 pxIndex 更新为新节点。看源码:

void vListInsertEnd( List_t * const pxList, ListItem_t * const pxNewListItem ) { ListItem_t * const pxIndex = pxList->pxIndex; configASSERT( pxNewListItem->pxContainer == NULL ); pxNewListItem->pxNext = pxIndex->pxNext; pxNewListItem->pxNext->pxPrevious = pxNewListItem; pxNewListItem->pxPrevious = pxIndex; pxIndex->pxNext = pxNewListItem; pxList->pxIndex = pxNewListItem; pxNewListItem->pxContainer = pxList; ( pxList->uxNumberOfItems )++; }

只看这段代码,一个干净的列表如果连续插入 A、B、C 三个节点,每插一个 pxIndex 就指向最后一个,所以最终顺序就是 A -> B -> C,看起来确实像是在尾部追加。但如果列表里已经有过其他操作,pxIndex 不一定在尾部,这时 vListInsertEnd 的行为就变成“在书签后面插一个”,而不是“在整个队列最后插一个”。

这个细节非常有意思:FreeRTOS 根本没把 vListInsertEnd 当作“尾部插入”来用,而是把它当作“记录当前游标位置,并在游标之后插入”的机制。pxIndex 随着每次插入不断前进,正好形成了一个“轮转队列”的形态。等读到调度器选择任务那部分,你会发现这个设计是故意为之的。

3.2 pxIndex 如何让同优先级任务轮流执行

先说结论:pxIndex 是调度器用来实现时间片轮转(round-robin)的关键。

假设现在有三个同优先级任务 A、B、C,都在就绪列表里。如果调度器每次都从链表头挑第一个任务,那么 A 会一直被执行,B 和 C 永远轮不到,这显然不是我们想要的时间片轮转。有了 pxIndex 之后,每个任务被选中执行一次,pxIndex 就移动到它身上;下次再从 pxIndex 的下一个节点开始挑选,这样 A、B、C 就会依次被选中。

配合 vListInsertEnd 的“插到 pxIndex 后面再更新 pxIndex”行为,新加入就绪列表的任务会被放到轮转队列的末尾,不会立刻抢占当前同优先级任务的位置。你可以把 pxIndex 理解成餐厅里的“取号牌”:轮到谁,号牌就挪到谁那儿,下一位永远从号牌的下一号开始叫。

任务让出 CPU 时,调度器会通过 listGET_OWNER_OF_NEXT_ENTRY 这个宏来选下一个任务:

#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 往后移一步,如果移到哨兵节点,就再跳一步,绕回链表头;然后把 pxIndex 当前指向节点的 pvOwner 取出来,这就是下一个要运行的任务。正因为有这个宏,调度器才能不依赖“从链表头开始找”,而是每次从上一次停止的地方继续,形成真正的循环。

3.3 uxListRemove 对 pxIndex 的“抢救式回退”

pxIndex 是一个游标,那它指向的节点被删除时怎么办?如果直接删掉这个节点,pxIndex 就会指向一块已经被摘除的内存,再往后遍历就全乱了。uxListRemove 里专门有一段处理:

if( pxList->pxIndex == pxItemToRemove ) { pxList->pxIndex = pxItemToRemove->pxPrevious; }

如果游标恰好指向被删除的节点,就把游标回退到它的前一个节点。这样虽然游标位置变了一下,但链表依然是完整的,后续遍历不会走到野指针上。这个处理在任务被删除、或任务从某个列表挪到另一个列表时会用到。

我当年自己写链表时,完全没考虑过“迭代器指向的节点被删了怎么办”这个问题,结果就是程序偶尔跑飞,查了半天才发现是迭代器悬空。FreeRTOS 用一行代码就把这个坑填了,这种细节很值得抄进自己的代码里。

4. 任务里那两个列表项:xStateListItem 与 xEventListItem

4.1 为什么一个任务需要两个列表项

任务控制块 TCB 里通常有两个 ListItem_t 类型的成员:xStateListItem 和 xEventListItem。第一次看到时我有点懵:一个任务为什么要准备两个列表项?

原因在于一个 ListItem_t 同一时间只能挂在一个列表上,它的 pxContainer 只有一个。而一个任务在运行过程中经常需要同时出现在两条不同的链表里。举例来说,一个任务因为等待某个队列消息而阻塞,它一方面要被挂到“延时/阻塞列表”里等待超时处理,另一方面要被挂到“该队列的等待者列表”里,这样当其他任务往队列里发消息时,内核能立刻找到它并唤醒。一个列表项根本不够用,所以 TCB 里直接备了两个。

两个列表项的 pvOwner 都指向这个任务自己,所以无论通过哪个列表项找到任务,都能正确回到 TCB。

4.2 xStateListItem 是怎么被使用的

xStateListItem 是任务的状态列表项,负责把任务挂到各种“状态列表”里。任务在什么状态,对应哪个列表:

任务状态被挂载的列表使用哪个插入函数
就绪pxReadyTasksLists[uxPriority]vListInsertEnd
延时阻塞pxDelayedTaskList 或 pxOverflowDelayedTaskListvListInsert
挂起xSuspendedTaskListvListInsertEnd

就绪列表按优先级分组,同优先级任务用 vListInsertEnd 插入,形成轮转;延时列表用 vListInsert 按唤醒 tick 值升序插入,这样内核每次只需看列表头部就知道下一个超时任务是谁。同一个 xStateListItem,在不同列表里的 xItemValue 含义并不一样:在就绪列表里一般是优先级,在延时列表里则是唤醒时间。

任务状态一变,内核就先把 xStateListItem 从旧列表移除,再插到新列表。所以你可以把 xStateListItem 看成任务在调度器里的“状态标签”,它挂在哪个列表,就代表任务处于什么状态。

4.3 xEventListItem 反优先级设计:高优先级排在前面

xEventListItem 是事件列表项,主要服务队列、信号量、事件组这类同步机制。当任务因为等待某个事件而阻塞时,它会把自己挂到对应内核对象的等待者列表里。

有趣的是,这个列表项的 xItemValue 在创建任务时被设置成了反向优先级:

listSET_LIST_ITEM_VALUE( &( pxNewTCB->xEventListItem ), ( TickType_t ) configMAX_PRIORITIES - ( TickType_t ) uxPriority );

也就是说,优先级越高的任务,xEventListItem 的 xItemValue 反而越小。因为 vListInsert 是升序插入,值越小的节点越靠前,所以高优先级任务在等待者列表里总是排在前面。这样一来,当多个任务同时等待同一个队列消息、而消息一次只能发给一个任务时,内核沿着等待者列表从头开始找,最先被唤醒的就是优先级最高的那个任务。

这个“反优先级”设计不仔细看源码很容易忽略。我当时看到任务优先级明明是 3,为什么事件列表项里存的是 configMAX_PRIORITIES - 3,一度以为自己看错了。后来才意识到,这是为了复用同一个升序插入逻辑,不需要为事件等待列表单独写一套降序插入。

5. 把代码连起来:vListInsert 的升序插入、遍历与调试建议

5.1 vListInsert:升序插入的边界处理

vListInsert 负责按 xItemValue 升序插入。前面提到过,它从 xListEnd 开始借助 pxNext 方向遍历,直到找到合适的位置。源码逻辑可以概括成两段:

  • 如果新值等于 portMAX_DELAY,直接取哨兵的前驱节点,插到它后面;
  • 否则,从哨兵开始沿 pxNext 走,遇到“下一个节点的值大于新值”就停下,插到当前节点后面。

为什么从哨兵开始而不是从真正的头节点开始?因为哨兵是环的“基准点”,从它出发沿 pxNext 走,正好能走完整个有序队列;走到哨兵自身时,由于哨兵的值最大,循环自然终止。这比维护一个独立的头指针要简洁。

和 vListInsertEnd 相比,两者的定位完全不同:

插入函数排序策略典型使用场景
vListInsertEnd插到 pxIndex 之后,维持轮转顺序同优先级就绪任务、挂起列表
vListInsert按 xItemValue 升序插入延时列表、事件等待列表

判断一个场景该用哪个函数,关键看你要不要“顺序公平”。同优先级任务之间要公平轮转,所以用 vListInsertEnd。延时任务之间按时间先后唤醒,所以用 vListInsert 升序插入。看代码时如果发现某个列表的插入方式很奇怪,先想想这个列表的业务语义是什么,通常就能理解作者的选择。

5.2 遍历宏和 xListEnd 哨兵的配合

除了 listGET_OWNER_OF_NEXT_ENTRY,FreeRTOS 还提供了一些遍历列表的辅助宏。比如要拿列表第一个节点的 owner,可以直接看哨兵节点的下一个节点是谁。因为哨兵是环的基准点,xListEnd.pxNext 指向的就是整条链表的第一个数据节点。vListInitialise 之后,空列表的哨兵自环,这时 xListEnd.pxNext 就是 xListEnd 自身。判断列表是否为空,只需要看 uxNumberOfItems 是否为 0,或者看 xListEnd.pxNext 是否等于 &xListEnd。

遍历链表时也依赖哨兵做边界判断:从 xListEnd.pxNext 开始沿 pxNext 走,每次判断当前节点是不是 xListEnd,如果是就说明已经走完一圈。这种“哨兵既当终点又当起点”的设计,让循环遍历的代码不需要额外的 head 指针,也不会出现空链表解引用的问题。

5.3 调试建议:断点看 pxIndex,比死记 API 有用

源码读了几遍之后,如果还是觉得云里雾里,我建议你直接在工程里做一个小实验:创建两个同优先级任务,在任务切换点打断点,观察就绪列表里 pxIndex 的指向变化。

具体做法是,在 vListInsertEnd 和 uxListRemove 这两个函数里各加一个临时断点,运行几个 tick 之后,看:

  • 插入任务时,pxIndex 是不是被更新成了新插入的节点;
  • 删除任务时,pxIndex 是不是回退到了前驱节点;
  • 列表进入稳定轮转后,pxIndex 是否按照 A -> B -> C -> A 的顺序移动。

如果你用的是带调试器的 IDE,直接在 Watch 窗口里展开 List_t 结构体,就能看到 pxIndex、xListEnd、uxNumberOfItems 的变化。这个实验比单纯读代码直观太多。我第一次把 pxIndex 的移动过程完整看下来,才真正理解了“游标”两个字是什么意思。

还有一个容易踩的调试迷惑点:列表项里看到的值可能和你想的不一样。比如某个任务在延时列表里,它的 xStateListItem.xItemValue 就是唤醒 tick 值,这个值很大很正常。别一看到大数字就以为是优先级。先判断当前列表是哪种列表,再解释 xItemValue 的语义。

我在实际读源码时还有一个习惯:每次看完一个函数,就手动在纸上画一遍“插入/删除后链表长什么样”。FreeRTOS 的列表操作虽然细节多,但画过三轮之后,xItemValue、pxIndex、xListEnd 的配合关系基本就刻在脑子里了。如果你也在啃这段源码,建议别急着往下看调度器,先把列表项的这套“钩子”机制玩熟,后面读 task.c 的速度会快很多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询