文章摘要
FreeRTOS 内核里所有"排队、等待、就绪"的语义,全都建立在一个带哨兵的双向循环链表 之上。本文从三个结构体(ListItem_t、MiniListItem_t、List_t)讲起,拆解哨兵、反向指针、排序键与游标这几个关键设计为什么这么写 ,再讲清楚调度器怎么靠"转游标"实现 O(1) 的公平轮转;后半篇对比并发保护的两把锁------临界区 与关调度,说明它们各自防住谁、代价为何一重一轻、嵌套计数又凭什么能保证安全。读懂这一篇,后面再啃任务、队列、信号量就顺了。
文章目录
- 文章摘要
- 本栏系列文章
- 其他专栏精品文章
- 前言
- 一、链表数据结构(List)
-
- [1. 三个结构体](#1. 三个结构体)
- [2. 链表长什么样](#2. 链表长什么样)
- [3. 三个关键设计(为什么这么设计)](#3. 三个关键设计(为什么这么设计))
- [4. 两个插入函数(分工不同,别再混)](#4. 两个插入函数(分工不同,别再混))
- [5. 排序键(xItemValue)与游标(pxIndex)](#5. 排序键(xItemValue)与游标(pxIndex))
- [6. 轮转调度的核心宏:listGET_OWNER_OF_NEXT_ENTRY](#6. 轮转调度的核心宏:listGET_OWNER_OF_NEXT_ENTRY)
- 二、并发保护
-
- [1. 两个工具:临界区与关调度](#1. 两个工具:临界区与关调度)
- [2. 第一步:谁可能插进来?](#2. 第一步:谁可能插进来?)
- [3. 第二步:两个工具分别堵住了谁](#3. 第二步:两个工具分别堵住了谁)
- [4. 第三步:为什么一个"重"一个"轻"](#4. 第三步:为什么一个"重"一个"轻")
- [5. 第四步:怎么选(一句话原则)](#5. 第四步:怎么选(一句话原则))
- [6. 第五步:嵌套计数](#6. 第五步:嵌套计数)
- 三、常见面试题
- 四、总结
本栏系列文章
数据结构其实很简单(四): 队列------顺序队列、环形队列与链式队列
其他专栏精品文章
前言
学 FreeRTOS 内核,很多人一上来就啃任务调度、啃信号量,结果一头扎进源码里出不来。其实内核的地基就是一张链表 和两把锁:任务挂在链表的哪条链上,就代表它处于什么状态,链表靠游标轮转实现公平调度;而多个任务、中断同时碰同一块数据时,靠临界区与关调度来保护。这一篇先把这两块基石讲透,后面再讲任务、队列、信号量,就都是在这上面盖楼了。
一、链表数据结构(List)
FreeRTOS 用一个「带哨兵的双向循环链表」记录任务状态------任务挂在哪条链上,就代表它处于什么状态。这张链表要支持极高频的 O(1) 增删,所以设计了哨兵和反向指针;多任务会同时碰它,所以又需要临界区/关调度来保护。先把链表本身吃透。
1. 三个结构体
FreeRTOS 所有"排队 / 等待 / 就绪"的语义,全部落在一个数据结构上:带哨兵的双向循环链表。它由三个结构体组成。
ListItem_t ------ 链表节点:
c
struct xLIST_ITEM {
TickType_t xItemValue; // 排序键:优先级 or 唤醒时间
struct xLIST_ITEM *pxNext; // 后继
struct xLIST_ITEM *pxPrevious; // 前驱
void *pvOwner; // 反向指针①:指向"拥有"这个节点的对象(通常是TCB)
struct xLIST *pxContainer; // 反向指针②:指向"当前所在"的链表
};
typedef struct xLIST_ITEM ListItem_t;
MiniListItem_t ------ 哨兵专用结构体,只有前三个字段的"瘦身版"节点:
c
struct xMINI_LIST_ITEM {
TickType_t xItemValue; // 只有这 3 个字段
struct xLIST_ITEM *pxNext;
struct xLIST_ITEM *pxPrevious;
};
List_t ------ 链表本身:
c
typedef struct xLIST {
UBaseType_t uxNumberOfItems; // 节点计数(不含哨兵)
ListItem_t *pxIndex; // 游标:轮转调度时"下一个该轮到谁"
MiniListItem_t xListEnd; // 哨兵节点,内嵌在 List_t 里
} List_t;
一句话记住分工:List_t 是装节点的"盒子",ListItem_t 是被装进去的"钩子"。 List_t 是一个可复用的有序链表容器,把一堆任务节点串成一条有序链,让调度器能 O(1) 地插入/删除,并从头部取出"最该被处理的节点"。
2. 链表长什么样
pxNext 方向 →
哨兵 ←→ 节点A ←→ 节点B ←→ 节点C ←→ (绕回)哨兵
(值=最大) ↑ │
└───────── pxPrevious 方向 ─────┘
- 哨兵 是内嵌在 List_t 里的假节点,值 = portMAX_DELAY(最大),所以永远排在末尾。
- 首尾相接成环,全程没有 NULL 。
- 空链表 = 哨兵自己指自己(哨兵.pxNext == 哨兵.pxPrevious == 哨兵)。
3. 三个关键设计(为什么这么设计)
- 哨兵 + 循环 → 没有 NULL → 插入 / 删除 / 遍历全无边界特判,永远不用判断"是不是头 / 尾 / 空"。
- pxContainer 反向指针 → 删除时直接从节点拿到"我在哪条链",O(1) 删除。
- pvOwner 反向指针 → 从节点直接拿到"属于哪个任务",O(1) 反查。
链表节点本来就有"向前 / 向后"两个指针(pxNext / pxPrevious),那是顺着链表走的。真正让它高效的是另外两条"反向链接":
任务(TCB) ←──── pvOwner ──── 节点(ListItem)
↑ │
│ 节点是 TCB 的成员(内嵌) │ pxContainer
└──────────────────────────── 链表(List_t)
- TCB → 节点 :天然就有(节点本来就是 TCB 的一个字段)。
- 节点 → TCB :靠 pvOwner 补上,于是双向都 O(1)。
- 节点 → 链表 :靠 pxContainer 补上,于是删除时不用满世界找链表。
pxContainer 让"删除"变成 O(1) 的典型证据,就是 uxListRemove 的签名------它只接收节点,不接收链表:要从节点里反查出"我在哪条链",靠的正是这个字段。
4. 两个插入函数(分工不同,别再混)
| 函数 | 行为 | 用在哪 |
|---|---|---|
vListInsertEnd |
插到游标前面(即队尾),不排序 | 就绪表:任务就绪时排队尾 |
vListInsert |
按 xItemValue 升序插到对应位置 |
延时表(按唤醒时间)、事件表(按优先级) |
vListInsert 里 <= 的意义就一句话:值相同时,新节点排到同值节点的后面(FIFO),而不是前面(LIFO)。 如果改成 < 就会排到前面,变成"后来者插队"。FIFO 是"先来先服务"的通用保证,这也是为什么用 <= 而不是 <。
5. 排序键(xItemValue)与游标(pxIndex)
排序键就是每个节点身上那个数字,链表按这个数字从小到大排队,谁数字小谁站前面。
那它为什么叫"键"?因为同一个节点放进不同链表时,这个数字的含义会变------它是一把"按什么排序"的钥匙:
| 链表 | xItemValue 装的是 | 排出来什么效果 |
|---|---|---|
| 就绪表 | 优先级 | 优先级高的靠前 |
| 延时表 | 唤醒时间(第几个 tick 醒) | 醒得早的靠前 |
| 事件等待表 | 最大优先级 − 优先级 | 优先级高的反而排前面 |
所以"排序键"= 决定节点在链表里站第几位的那个数字。
游标则是一个"手指",指向"下一个该轮到谁",它是 List_t 里的一个指针。链表排好队之后,调度器不用重新排队,只要把手指往前挪一格,指到谁就轮到谁;手指指到队尾(哨兵)就绕回队头。
用一个生活类比串起来:链表 = 一条排队的队伍;排序键(xItemValue)= 每个人胸口的号码牌 ,决定谁站前谁站后(静态顺序);游标(pxIndex)= 服务员的手指,指着"下一个服务谁"(动态轮转)。
对应到代码:
c
// 排序键:决定插入位置(排队)
listSET_LIST_ITEM_VALUE(item, xValue); // 给节点贴上号码
vListInsert(list, item); // 按号码站队
// 游标:决定轮到谁(点名)
listGET_OWNER_OF_NEXT_ENTRY(&tcb, list); // 手指前进一格,返回指到的人
排序键(xItemValue): 5 5 9 MAX
↓ ↓ ↓ ↓
[节点A] → [节点B] → [节点C] → [哨兵] → (绕回)
↑
pxIndex 游标(手指,指着"下一个该选谁")
就绪表的轮转,靠的就是"转游标"。节点 A、B、C 待在环上不动,动的是游标 pxIndex:
pxIndex(记住"上次选中谁")
↓
哨兵 → A → B → C → (绕回)哨兵
每次 listGET_OWNER_OF_NEXT_ENTRY 就做两件事:游标前进一格;撞到哨兵就绕回头部。 连续调用就是 A → B → C → A → B → C → ...,结果就是公平轮转。没有节点被搬动,公平来自游标在环上循环走;新就绪的任务用 vListInsertEnd 排到队尾,保证不插队。
6. 轮转调度的核心宏:listGET_OWNER_OF_NEXT_ENTRY
listGET_OWNER_OF_NEXT_ENTRY 是轮转调度的核心工具------一个宏,作用是:把游标往前推一格,然后返回"这一格节点属于哪个任务"。调度器就是靠它一次次取出"下一个该跑的任务"。
二、并发保护
1. 两个工具:临界区与关调度
链表会被多个任务、甚至中断同时访问,必须保护。FreeRTOS 给了两把锁:
临界区 taskENTER_CRITICAL |
关调度 vTaskSuspendAll |
|
|---|---|---|
| 机制 | 关中断(BASEPRI) | 只加计数器,中断照跑 |
| 保护谁 | 任务 + 中断共享的数据 | 仅 任务 vs 任务共享的数据 |
| 代价 | 重(延迟中断) | 轻(几乎零成本) |
选型原则一句话:数据会被 ISR 碰 → 用临界区;只有任务之间碰 → 用关调度。
2. 第一步:谁可能插进来?
一个任务(比如任务 A)正在改某块共享数据时,谁能"插进来"把它改乱?只有两个来源:
谁可能插进来?
├─ ① 别的任务(任务B)------ 靠"任务切换/抢占"插进来
└─ ② 中断 ISR ------ 靠"硬件中断"随时插进来
所以并发保护要防的,就是这两个东西。
3. 第二步:两个工具分别堵住了谁
关调度 vTaskSuspendAll ------ 只是把计数器 +1,中断照常触发,但调度器看到"挂起了"就不切任务。
结果:
- ① 别的任务插进来? ✗ 堵住了(不切任务,B 进不来)
- ② 中断插进来? ✓ 堵不住(中断照跑!)
临界区 taskENTER_CRITICAL ------ 直接把中断关掉(BASEPRI 抬高)。
结果:
- ② 中断插进来? ✗ 堵住了(中断被屏蔽,ISR 进不来)
- ① 别的任务插进来? ✗ 也堵住了!
为什么连"别的任务"也堵住了?因为 tick 定时器本身也是一个中断------中断被关了 → tick 不响 → 没有抢占 → 别的任务也切不进来。
关键点:临界区是"一石二鸟"。 关中断这个动作,顺带把任务切换也停了(因为切换靠 tick 触发)。所以它防住两个来源,而关调度只防住"任务"这一个来源。
4. 第三步:为什么一个"重"一个"轻"
关中断(临界区):中断被推迟了。实时系统里,ISR 负责紧急的事(读传感器、控制电机、处理通信),你让它晚几微秒响应,可能就出问题,所以代价重。
关调度:只是加个计数器,中断该怎么响还怎么响,几乎零成本,所以代价轻。
5. 第四步:怎么选(一句话原则)
数据会不会被中断碰?
- 会 → 必须用临界区(关调度挡不住中断)。
- 不会 → 用关调度(更便宜)。
6. 第五步:嵌套计数
两个工具都能嵌套调用,靠计数保证"最外层退出时才真正恢复":
c
taskENTER_CRITICAL(); // 计数 0→1,真正关中断
taskENTER_CRITICAL(); // 计数 1→2,不动
taskEXIT_CRITICAL(); // 计数 2→1,不开中断
taskEXIT_CRITICAL(); // 计数 1→0,才真正开中断
为什么要这样?因为函数会互相调用(A 进临界区后调 B,B 自己又进临界区)。如果 B 退出时就把中断开了,A 的保护就被提前破坏了。计数保证:谁开的谁关,最外层的那个 EXIT 才真正恢复。
uxSchedulerSuspended 同理:vTaskSuspendAll 两次 → 计数 2;xTaskResumeAll 两次才归 0 恢复调度。
三、常见面试题
1. ListItem_t 有哪些字段?哨兵值是多少、为什么是这个值?
2. vListInsert 为什么用 <=?改成 < 会怎样?
3. pxContainer 和 pvOwner 分别让什么操作变成 O(1)?
4. 临界区和关调度各保护什么?ISR 也会碰的数据该用哪个?
四、总结
本文把 FreeRTOS 内核最基础的两块砖------链表与并发保护------梳理了一遍:
1. 三个结构体 :ListItem_t 是节点(带排序键、前后指针、pvOwner/pxContainer 两条反向指针),MiniListItem_t 是哨兵的瘦身版,List_t 是链表容器(计数 + 游标 + 内嵌哨兵)。
2. 链表形态 :带哨兵的双向循环链表,哨兵值 = portMAX_DELAY 永远排末尾,全程无 NULL,空链表即哨兵自指。
3. 三个关键设计 :哨兵+循环消除边界特判;pxContainer 让删除 O(1);pvOwner 让反查 O(1)。
4. 排序键与游标 :xItemValue 决定"排队"(静态顺序),pxIndex 决定"点名"(动态轮转);公平轮转靠转游标,节点本身不动。
5. 并发保护 :临界区(关中断)防住"任务+中断"两个来源、代价重;关调度(加计数)只防住任务、代价轻。判断标准就看数据会不会被 ISR 碰,两个工具都靠嵌套计数保证最外层退出才恢复。
这一篇的关键就一句话:链表负责"任务在哪、谁该跑",并发保护负责"别被打断"。 地基打牢了,下一篇我们就进入 FreeRTOS 真正的主角------任务管理与调度。