FreeRTOS深入解析(一)_链表与并发保护——哨兵循环链表、排序键游标、临界区与关调度

文章摘要

  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. 第五步:嵌套计数)
  • 三、常见面试题
  • 四、总结

本栏系列文章

数据结构其实很简单(四): 队列------顺序队列、环形队列与链式队列

其他专栏精品文章

CSDN高赞项目:工程训练竞赛之垃圾分类垃圾桶

前言

  学 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 真正的主角------任务管理与调度。

相关推荐
-银雾鸢尾-1 小时前
两数之和-C++实现
c++·算法
j7~1 小时前
【动态规划算法专题】动态规划五部曲实战:泰波那契数、三步问题、最小花费爬楼梯
c++·算法·动态规划·数据结构与算法
倍利福猎头公司官方账号1 小时前
大企业HR如何向猎头公司清晰传达AI岗位的技术需求?
人工智能·算法·机器人·求职招聘·业界资讯
揽星河..2 小时前
【C++】类和对象
c++·算法
Zwawa2 小时前
串口命令如何从字符串长成协议:帧头、长度、校验和与状态机
c语言·单片机·嵌入式
小李不想当小白2 小时前
BKP备份寄存器与RTC实时时钟(STM32标准库学习笔记)
笔记·stm32·单片机·嵌入式硬件·学习·嵌入式
D e re k ZH2 小时前
C++ 并发、未定义行为与工程化难点(No 3)
数据结构·c++·算法·面试
释厄6233 小时前
同性线套娃律——圆面积必然=1+2+...+n 的求和·圆心不与 π 相关
java·人工智能·算法·1024程序员节
暴力求解3 小时前
C++ ---继承(二)
开发语言·c++·算法