三、FreeRTOS 内核数据结构:列表与列表项详解
在 FreeRTOS 内核中,任务调度、延时管理、事件等待、软件定时器等功能都离不开一个基础数据结构:链表。FreeRTOS 并不是简单地使用普通单向链表,而是实现了一套适合内核调度使用的双向循环链表。理解List_t、ListItem_t和MiniListItem_t,对于后续学习任务控制块、就绪列表、延时列表和事件列表非常重要。
本文主要从链表的基本思想、FreeRTOS 列表结构、列表项初始化、链表初始化、节点插入、节点删除和实际使用场景几个方面进行整理。
一、为什么 FreeRTOS 需要链表
在裸机程序中,通常是一个while(1)主循环顺序执行各个功能函数。而在 RTOS 中,系统中可能同时存在多个任务,每个任务又会处于不同状态,例如:
| 任务状态 | 说明 |
|---|---|
| 就绪态 | 任务已经具备运行条件,等待调度器分配 CPU |
| 运行态 | 当前正在占用 CPU 执行 |
| 阻塞态 | 正在等待延时结束、队列数据、信号量或事件 |
| 挂起态 | 被主动挂起,暂时不参与调度 |
内核需要把这些任务按照状态组织起来。比如,就绪任务放在就绪列表中,延时任务放在延时列表中,等待队列的任务放在事件等待列表中。
如果使用数组管理这些任务,删除和插入都不够灵活。任务状态经常变化,如果每次状态切换都移动数组元素,效率并不理想。链表更适合这种频繁插入、删除、迁移节点的场景。
例如,一个任务调用延时函数后,需要从就绪列表移除,再插入延时列表;延时时间到达后,又需要从延时列表移除,再插回就绪列表。这个过程本质上就是链表节点的移动。
二、普通链表与 FreeRTOS 链表的区别
普通单向链表一般只包含一个指向下一个节点的指针:
/* ============================================ * 一、普通单向链表节点(对比用) * ============================================ */ typedef struct Node { int data; /* 节点存储的数据 */ struct Node *next; /* 指向下一个节点的指针(单向) */ } Node_t;这种结构适合简单数据管理,但删除某个节点时,如果不知道前一个节点,就需要从头遍历查找。
FreeRTOS 使用的是双向循环链表。每个节点不仅知道后一个节点,也知道前一个节点:
/* ============================================ * 二、双向链表节点(对比用) * ============================================ */ typedef struct Node { int data; /* 节点存储的数据 */ struct Node *next; /* 指向后一个节点的指针 */ struct Node *prev; /* 指向前一个节点的指针(双向核心) */ } Node_t;这样删除节点时,不需要从头查找前驱节点,直接通过prev和next就可以把当前节点摘下来。
FreeRTOS 的链表还有一个特点:它带有一个固定存在的结束标记节点,也就是常说的“哨兵节点”。这个节点不是任务,也不是普通数据,只是用来标记链表边界,简化插入和删除逻辑。
三、FreeRTOS 中的三个核心结构
FreeRTOS 链表主要由三个结构组成:
| 结构 | 含义 | 作用 |
|---|---|---|
List_t | 链表结构 | 管理整个链表 |
ListItem_t | 列表项 | 作为普通节点插入链表 |
MiniListItem_t | 迷你列表项 | 作为链表结束标记节点 |
在较新的 FreeRTOS 版本中,结构体名称通常通过typedef变成List_t、ListItem_t、MiniListItem_t。在一些旧资料中,也会看到xLIST、xLIST_ITEM、xMINI_LIST_ITEM这样的写法。本质上是同一类结构,只是命名和配置细节有所变化。
四、ListItem_t:真正挂入链表的节点
ListItem_t是链表中的普通节点。简化后的结构如下:
/* ============================================ * 三、ListItem_t:真正挂入链表的节点 * ============================================ */ typedef struct ListItem { uint32_t itemValue; /* 排序辅助值,用于延时列表等有序链表 */ struct ListItem *next; /* 指向链表中后一个节点 */ struct ListItem *prev; /* 指向链表中前一个节点(双向,便于删除) */ void *owner; /* 指向拥有该节点的对象,通常指向任务控制块 TCB */ void *container; /* 指向该节点当前所在的链表,为 NULL 表示未插入任何链表 */ } ListItem_t;每个成员都有明确作用。
itemValue用于排序。比如在延时列表中,它可以表示任务应该在什么 tick 时刻被唤醒。数值越小,说明越早被处理。
next和prev用于连接前后节点,这是双向链表的基础。
owner表示这个列表项属于哪个对象。在任务调度中,它通常指向任务控制块 TCB。
container表示这个列表项当前位于哪个链表中。如果一个节点已经插入某个链表,container就指向该链表;如果没有插入任何链表,container通常为NULL。
这个设计非常关键。FreeRTOS 并不是把任务控制块本身直接放进链表,而是把任务控制块内部的列表项挂到链表上。
可以用一个简化的任务控制块表示:
/* ============================================ * 四、任务控制块 TCB 简化示例 * 说明:FreeRTOS 将列表项内嵌在 TCB 中, * 通过列表项挂入不同链表,而非直接移动 TCB * ============================================ */ typedef struct TaskControlBlock { char taskName[16]; /* 任务名称 */ uint32_t priority; /* 任务优先级 */ ListItem_t stateListItem; /* 用于挂入就绪列表、延时列表等状态链表 */ ListItem_t eventListItem; /* 用于挂入队列、信号量等事件等待链表 */ } TCB_t;这样做的好处是,同一个任务可以根据不同用途挂入不同链表。例如stateListItem用于表示任务状态,eventListItem用于表示任务等待某个事件。
五、List_t:管理整个链表
List_t是链表的管理结构。简化后的定义如下:
/* ============================================ * 五、List_t:链表管理结构(链表头) * ============================================ */ typedef struct List { uint32_t numberOfItems; /* 链表中有效节点数量(不包含哨兵节点) */ ListItem_t *index; /* 遍历游标,调度器用它指向当前任务,实现轮转调度 */ ListItem_t end; /* 哨兵节点(结束标记),始终存在,不属于任何任务 */ } List_t;numberOfItems记录链表中真实节点的数量,不包含哨兵节点。
index是遍历链表时使用的指针。FreeRTOS 调度器在同优先级任务之间轮转时,会用类似游标的方式找到下一个任务。
end是链表结束标记节点。它始终存在,不代表任何任务。很多资料中把它理解成“根节点”,从学习角度看可以这么记,但更准确的叫法是“哨兵节点”或“结束标记节点”。
六、MiniListItem_t:为什么需要迷你节点
MiniListItem_t是精简版列表项。它一般只保留排序值和前后指针:
/* ============================================ * 六、MiniListItem_t:精简版列表项(哨兵专用) * 说明:哨兵节点不需要 owner 和 container, * 因此可用更小的结构节省内存 * ============================================ */ typedef struct MiniListItem { uint32_t itemValue; /* 排序值,哨兵节点设为最大值,确保位于链表末尾 */ struct ListItem *next; /* 指向后一个节点 */ struct ListItem *prev; /* 指向前一个节点 */ } MiniListItem_t;它没有owner和container,因为哨兵节点不属于任何任务,也不需要反向找到任务控制块。它只是链表的边界标记。
在一些新版本配置中,MiniListItem_t也可能直接等同于ListItem_t。这是 FreeRTOS 为了兼容性和配置灵活性做的处理。学习时只需要记住:xListEnd是链表内部自带的结束标记,不是普通任务节点。
七、链表初始化
链表在使用前必须初始化。初始化后的链表是一个空的双向循环链表。
简化代码如下:
/* ============================================ * 七、链表初始化函数 * 作用:将链表初始化为空的双向循环链表 * ============================================ */ #define MAX_DELAY 0xffffffffUL /* 32 位无符号最大值,作为哨兵节点的 itemValue */ void List_Init(List_t *list) { /* 初始化时链表中没有任何有效节点 */ list->numberOfItems = 0; /* 遍历游标初始指向哨兵节点,表示从头开始 */ list->index = &list->end; /* 哨兵节点排序值设为最大,确保在有序链表中永远位于最后 */ list->end.itemValue = MAX_DELAY; /* 哨兵节点的前后指针都指向自己,形成闭环 */ /* 这是空双向循环链表的标志 */ list->end.next = &list->end; list->end.prev = &list->end; /* 哨兵节点不属于任何任务,也不属于任何外部链表 */ list->end.owner = NULL; list->end.container = NULL; }初始化后,链表中没有普通节点,但哨兵节点已经存在:
end.next = &end; end.prev = &end; numberOfItems = 0;这表示链表为空。由于end的前后指针都指向自己,所以整个链表形成了一个闭环。
这种设计可以减少很多边界判断。普通链表在插入第一个节点、删除最后一个节点时经常需要单独处理,而带哨兵节点的循环链表可以把空链表、单节点链表、多节点链表统一处理。
八、为什么 end 的 itemValue 是最大值
FreeRTOS 的很多链表需要按照itemValue升序排列。比如延时列表中,itemValue可能表示任务解除阻塞的 tick 值。
end.itemValue被设置为最大值,是为了让哨兵节点永远位于排序链表的末尾。普通节点的值一般不会超过它,所以插入时总能在到达哨兵节点之前找到合适位置。
例如链表中有三个节点:
10 -> 20 -> 30 -> endend的值是最大值,因此它自然位于最后。
九、列表项初始化
列表项初始化主要是把container设置为空,表示当前节点没有插入任何链表。
/* ============================================ * 八、列表项初始化 * ============================================ */ void ListItem_Init(ListItem_t *item) { /* 将 container 置空,表示当前节点未插入任何链表 */ /* 这是判断节点是否在链表中的重要标志 */ item->container = NULL; }实际使用时,还需要设置itemValue和owner
/* 设置列表项的排序值 */ void ListItem_SetValue(ListItem_t *item, uint32_t value) { item->itemValue = value; } /* 设置列表项的拥有者(通常传入 TCB 指针) */ void ListItem_SetOwner(ListItem_t *item, void *owner) { item->owner = owner; }组合起来可以这样使用:
TCB_t task; ListItem_Init(&task.stateListItem); ListItem_SetValue(&task.stateListItem, task.priority); ListItem_SetOwner(&task.stateListItem, &task);这样,链表节点和任务控制块之间就建立了关系:
ListItem_t.owner -> TCB_t TCB_t.stateListItem -> ListItem_t通过列表项可以找到任务,通过任务也可以找到自己的列表项。
十、插入到链表尾部
尾插操作常用于就绪列表。比如相同优先级下有多个任务时,可以把新任务插到链表尾部,后续调度时实现时间片轮转。
简化代码如下:
/* ============================================ * 九、插入到链表尾部(常用于就绪列表) * 说明:在 index 指向的位置之前插入新节点 * 若 index 指向哨兵 end,则插入到链表尾部 * ============================================ */ void List_InsertEnd(List_t *list, ListItem_t *newItem) { /* 获取当前遍历游标位置,作为插入的参考点 */ ListItem_t *index = list->index; /* 新节点的 next 指向 index(即插入到 index 前面) */ newItem->next = index; /* 新节点的 prev 指向 index 原来的前一个节点 */ newItem->prev = index->prev; /* 将原前一个节点的 next 指向新节点,完成前向链接 */ index->prev->next = newItem; /* 将 index 的 prev 指向新节点,完成后向链接 */ index->prev = newItem; /* 记录新节点所属的链表,便于后续删除时直接定位 */ newItem->container = list; /* 有效节点数加一 */ list->numberOfItems++; }这段代码可以分成四步理解:
newItem->next = index;新节点的下一个节点指向index。
newItem->prev = index->prev;新节点的前一个节点指向index原来的前一个节点。
index->prev->next = newItem;原来的尾节点的下一个节点改成新节点。
index->prev = newItem;index的前一个节点改成新节点。
如果index指向哨兵节点end,那么这次插入就相当于把新节点插到了end前面,也就是链表尾部。
十一、按照 itemValue 升序插入
FreeRTOS 中还有一种常见插入方式:按照itemValue从小到大插入。延时列表就需要这种方式。
简化代码如下:
/* ============================================ * 十、按 itemValue 升序插入(常用于延时列表) * 说明:从哨兵开始向后遍历,找到第一个大于 * newItem->itemValue 的节点,插入其前面 * ============================================ */ void List_InsertSorted(List_t *list, ListItem_t *newItem) { /* 从哨兵节点开始遍历,哨兵在有序链表中位于末尾 */ ListItem_t *iterator = &list->end; /* 向后遍历,直到找到 itemValue 大于新节点的位置 */ /* 循环结束时,iterator 指向新节点应该插入位置的前一个节点 */ while (iterator->next->itemValue <= newItem->itemValue) { iterator = iterator->next; } /* 新节点的 next 指向 iterator 原来的下一个节点 */ newItem->next = iterator->next; /* 新节点的 prev 指向 iterator */ newItem->prev = iterator; /* 将原下一个节点的 prev 指向新节点 */ iterator->next->prev = newItem; /* 将 iterator 的 next 指向新节点,完成插入 */ iterator->next = newItem; /* 标记新节点已插入该链表 */ newItem->container = list; /* 有效节点数加一 */ list->numberOfItems++; }假设已有链表如下:
10 -> 30 -> end现在插入一个itemValue = 20的节点。遍历时会发现 20 应该插入 10 和 30 之间,插入后变为:
10 -> 20 -> 30 -> end如果用于延时列表,数值越小的任务越早被唤醒,调度器检查链表头部即可快速判断是否有任务到期。
十二、从链表中删除节点
删除节点时,只需要传入列表项本身。因为container已经记录了它属于哪个链表。
/* ============================================ * 十一、从链表中删除节点 * 说明:通过 container 字段直接定位所属链表, * 无需从头遍历查找 * ============================================ */ uint32_t List_Remove(ListItem_t *item) { /* 通过 container 反向找到该节点所属的链表头 */ List_t *list = (List_t *)item->container; /* 核心删除操作:让后一个节点的 prev 跳过当前节点 */ item->next->prev = item->prev; /* 核心删除操作:让前一个节点的 next 跳过当前节点 */ /* 这两步执行后,当前节点从链表中脱离 */ item->prev->next = item->next; /* 如果删除的节点正好是游标指向的节点,将游标回退到前一个节点 */ /* 防止游标悬空,保证后续遍历正常 */ if (list->index == item) { list->index = item->prev; } /* 清除 container 标记,表示该节点已不在任何链表中 */ item->container = NULL; /* 有效节点数减一 */ list->numberOfItems--; /* 返回删除后链表的剩余节点数 */ return list->numberOfItems; }删除过程的核心是两行:
item->next->prev = item->prev; item->prev->next = item->next;第一行让后一个节点的prev跳过当前节点,指向当前节点的前一个节点。
第二行让前一个节点的next跳过当前节点,指向当前节点的后一个节点。
这样当前节点就从链表中脱离出来了。
删除后需要把container设置为NULL,表示该节点已经不属于任何链表。
十三、遍历链表
FreeRTOS 中遍历链表时,经常通过index游标获取下一个节点对应的 owner。简化示例如下:
/* ============================================ * 十二、遍历链表:获取下一个节点的 owner * 说明:常用于任务调度,返回下一个要运行的任务 TCB * ============================================ */ void *List_GetNextOwner(List_t *list) { /* 游标向后移动一个节点 */ list->index = list->index->next; /* 如果移动后指向哨兵节点,说明已经到达链表末尾 */ /* 跳过哨兵,回到链表头部(循环链表特性) */ if (list->index == &list->end) { list->index = list->index->next; } /* 返回当前游标指向节点的拥有者,即任务控制块 */ return list->index->owner; }这段逻辑表示:先移动到下一个节点,如果遇到哨兵节点,就跳过哨兵节点,继续移动到真正的第一个节点。
在任务调度中,owner通常是任务控制块,因此这个函数可以返回下一个要调度的任务。
十四、一个完整的小例子
下面用一个简化例子模拟任务挂入链表的过程。
/* ============================================ * 十三、完整示例:模拟任务挂入就绪链表 * ============================================ */ #include <stdio.h> #include <stdint.h> #include <string.h> #define MAX_DELAY 0xffffffffUL /* 列表项结构 */ typedef struct ListItem { uint32_t itemValue; struct ListItem *next; struct ListItem *prev; void *owner; void *container; } ListItem_t; /* 链表管理结构 */ typedef struct List { uint32_t numberOfItems; ListItem_t *index; ListItem_t end; } List_t; /* 简化任务控制块 */ typedef struct TaskControlBlock { char name[16]; uint32_t priority; ListItem_t stateItem; /* 内嵌列表项,用于挂入就绪链表 */ } TCB_t; /* 链表初始化:构造空双向循环链表 */ void List_Init(List_t *list) { list->numberOfItems = 0; list->index = &list->end; /* 游标初始指向哨兵 */ list->end.itemValue = MAX_DELAY; /* 哨兵值最大,确保位于有序链表末尾 */ list->end.next = &list->end; /* 空表:哨兵自己指向自己 */ list->end.prev = &list->end; list->end.owner = NULL; list->end.container = NULL; } /* 列表项初始化:标记为未插入任何链表 */ void ListItem_Init(ListItem_t *item) { item->container = NULL; } /* 尾插法:在 index 之前插入新节点 */ void List_InsertEnd(List_t *list, ListItem_t *item) { ListItem_t *index = list->index; /* 新节点插入到 index 和 index->prev 之间 */ item->next = index; item->prev = index->prev; index->prev->next = item; /* 原尾节点的 next 指向新节点 */ index->prev = item; /* index 的 prev 指向新节点 */ item->container = list; /* 标记所属链表 */ list->numberOfItems++; /* 节点计数加一 */ } /* 任务初始化:设置任务名、优先级,并初始化其列表项 */ void Task_Init(TCB_t *task, const char *name, uint32_t priority) { /* 拷贝任务名,并确保字符串以 '\0' 结尾,防止越界 */ strncpy(task->name, name, sizeof(task->name) - 1); task->name[sizeof(task->name) - 1] = '\0'; task->priority = priority; /* 初始化列表项,此时未插入任何链表 */ ListItem_Init(&task->stateItem); /* 在就绪链表中,通常用优先级作为排序值 */ task->stateItem.itemValue = priority; /* 列表项的 owner 指向任务本身,便于从链表节点找回 TCB */ task->stateItem.owner = task; } /* 打印链表:遍历除哨兵外的所有节点,输出任务信息 */ void Print_List(List_t *list) { /* 从哨兵的下一个节点开始遍历(即链表第一个有效节点) */ ListItem_t *item = list->end.next; /* 当遍历回到哨兵时,说明链表已遍历完毕 */ while (item != &list->end) { /* 通过 owner 指针获取对应的任务控制块 */ TCB_t *task = (TCB_t *)item->owner; printf("task name: %s, priority: %lu\r\n", task->name, (unsigned long)task->priority); /* 移动到下一个节点 */ item = item->next; } } /* 主函数:演示链表初始化、任务创建和插入过程 */ int main(void) { List_t readyList; /* 定义就绪链表 */ TCB_t task1; /* 定义三个任务控制块 */ TCB_t task2; TCB_t task3; /* 步骤 1:初始化链表,构造空表 */ List_Init(&readyList); /* 步骤 2:初始化三个任务,分别设置名称和优先级 */ Task_Init(&task1, "LED", 1); /* 任务 LED,优先级 1 */ Task_Init(&task2, "UART", 2); /* 任务 UART,优先级 2 */ Task_Init(&task3, "KEY", 1); /* 任务 KEY,优先级 1 */ /* 步骤 3:将三个任务的 stateItem 依次挂入就绪链表尾部 */ /* 插入顺序:LED -> UART -> KEY */ List_InsertEnd(&readyList, &task1.stateItem); List_InsertEnd(&readyList, &task2.stateItem); List_InsertEnd(&readyList, &task3.stateItem); /* 步骤 4:遍历并打印链表内容,验证插入结果 */ Print_List(&readyList); return 0; }这个例子体现了 FreeRTOS 链表设计的核心思想:链表中挂入的是ListItem_t,但通过owner可以找到真正的任务控制块。
十五、FreeRTOS 链表与任务调度的关系
FreeRTOS 中常见的任务列表包括:
| 列表 | 作用 |
|---|---|
| 就绪列表 | 保存已经准备好运行的任务 |
| 延时列表 | 保存正在延时或等待超时的任务 |
| 挂起列表 | 保存被挂起的任务 |
| 事件等待列表 | 保存等待队列、信号量、事件组的任务 |
例如,一个任务创建成功后,会被插入就绪列表。任务调用vTaskDelay()后,会从就绪列表移除,再插入延时列表。系统 tick 中断到来时,内核会检查延时列表中是否有任务到期。如果到期,就把任务从延时列表移除,再放回就绪列表。
这个过程可以概括为:
任务运行 -> 调用延时函数 -> 从就绪列表删除 -> 插入延时列表 -> tick 到期 -> 从延时列表删除 -> 插入就绪列表 -> 等待调度器再次运行所以,任务状态切换的底层,本质上就是列表项在不同链表之间移动。
十六、学习这部分时容易混淆的点
List_t不是普通节点,而是链表管理结构。
ListItem_t才是真正挂入链表的普通节点。
xListEnd不是任务节点,而是哨兵节点。
pvOwner指向拥有该列表项的对象,通常是 TCB。
pxContainer指向该列表项当前所在的链表。
vListInitialise()初始化链表。
vListInitialiseItem()初始化列表项。
vListInsertEnd()通常用于尾插。
vListInsert()通常用于按xItemValue升序插入。
uxListRemove()用于把节点从所在链表中移除。