FreeRTOS链表详解

一、先搞懂:链表到底是什么?

链表是一种动态存储数据的结构,核心是 “用指针把离散的节点串起来”,像一串糖葫芦:

  • 「节点」:每个糖葫芦(存储一个数据),包含两部分:
    1. 数据域:存储实际要保存的数据(比如数字、结构体、指针);
    2. 指针域:存储下一个(或上一个)节点的地址,相当于糖葫芦的竹签,把节点连起来。
  • 「链表」:所有节点通过指针域串联形成的整体,不需要连续的内存空间(和数组最大的区别)。

对比数组,你就能秒懂链表的核心优势:

特性数组(比如int arr[10]链表
内存分配连续内存块离散内存块(按需分配)
插入 / 删除需移动大量元素(效率低)只需改指针(效率高)
容量扩展固定大小(无法动态扩展)可动态添加节点(无上限)
访问方式下标直接访问(arr[3]必须从头遍历(顺序访问)

二、最基础的两种链表:单向链表 vs 双向链表

FreeRTOS 中用的是双向链表(方便正反遍历、插入删除),但先从单向链表入门,再过渡到双向链表更易理解。

1. 单向链表(入门必备)
  • 结构:每个节点只有一个指针,指向下一个节点,最后一个节点的指针为NULL(表示链表结束)。
  • 核心:只能从 “头节点” 往后遍历,不能反向查找。
(1)单向链表节点定义(C 语言代码)

c

运行

// 定义节点结构体:数据域 + 指针域 struct Node { int data; // 数据域:存储整数(可替换为任意类型,比如结构体) struct Node *next; // 指针域:指向同类型的下一个节点 }; // 为了书写方便,重定义类型名(类似FreeRTOS的typedef) typedef struct Node ListNode; 
(2)单向链表的核心操作(3 个最常用)
① 创建节点(申请内存 + 赋值)

c

运行

// 创建一个新节点,返回节点指针 ListNode* createNode(int data) { // 1. 动态分配内存(链表节点通常动态创建,按需分配) ListNode *newNode = (ListNode*)malloc(sizeof(ListNode)); // 2. 给节点赋值 newNode->data = data; // 数据域存值 newNode->next = NULL; // 新节点默认是最后一个,next指向NULL return newNode; }
② 插入节点(尾部插入,最简单)
  1. 找到链表的最后一个节点(最后一个节点的nextNULL);
  2. 把最后一个节点的next指针,指向新创建的节点;
  3. 新节点的next保持NULL(因为它现在是新的尾部)。
// 单链表节点定义 typedef struct Node { int data; // 数据域 struct Node *next; // 指针域:指向下一个节点 } ListNode; // 尾部插入节点:head是链表头指针的地址(二级指针) void insertNode(ListNode **head, int data) { // 步骤1:创建新节点 ListNode *newNode = createNode(data); // 步骤2:如果链表为空,新节点就是头节点 if (*head == NULL) { *head = newNode; return; } // 步骤3:遍历找到链表最后一个节点 ListNode *p = *head; while (p->next != NULL) { p = p->next; // 指针后移,直到找到最后一个节点 } // 步骤4:把新节点接在最后一个节点后面 p->next = newNode; } // 创建新节点(辅助函数) ListNode* createNode(int data) { ListNode *newNode = (ListNode*)malloc(sizeof(ListNode)); // 申请内存 newNode->data = data; // 给数据域赋值 newNode->next = NULL; // 新节点默认是尾部,next指向NULL return newNode; }
步骤 1:创建新节点(createNode函数)
  • 核心作用:申请一块内存,封装 “数据” 和 “指针”,形成一个独立的节点。
  • 关键代码解释:
    • (ListNode*)malloc(sizeof(ListNode)):向系统申请一块和ListNode结构体大小相同的内存,强制转换为ListNode*类型(因为malloc返回void*);
    • newNode->next = NULL:这是尾部插入的关键!新节点一开始是 “孤立” 的,它的next必须设为NULL,表示它暂时是 “最后一个节点”,后续接在链表尾部时才不会出错。

示意图(新节点创建后):plaintext

newNode: [data=传入的值] -> next = NULL 
步骤 2:判断链表是否为空(if (*head == NULL)
  • 核心逻辑:如果链表还没有任何节点(头指针*headNULL),新节点就直接成为 “头节点”。
  • 为什么用ListNode **head(二级指针)?
    • 因为要修改 “头指针本身” 的值(让它指向新节点)。如果用一级指针ListNode *head,函数内修改的只是head的副本,外部的头指针不会变,链表还是空的。
    • 简单说:要修改指针变量本身,就传它的地址(二级指针)。

示意图(链表为空时插入):plaintext

插入前:*head = NULL(链表空) 插入后:*head -> newNode: [data=值] -> next = NULL 
步骤 3:遍历找到链表尾部(while (p->next != NULL)
  • 核心目标:找到最后一个节点(nextNULL的节点)。
  • 逐行解释:
    • ListNode *p = *head:定义一个 “临时指针p”,从 “头节点” 开始遍历(p一开始指向头节点);
    • while (p->next != NULL):循环条件:只要pnext不是NULL,说明p不是最后一个节点,继续往后找;
    • p = p->nextp指针 “移动” 到下一个节点(相当于 “顺着糖葫芦的竹签往下走”)。
  • 示意图(遍历过程):假设链表已有节点:head -> 节点1 -> 节点2 -> NULL
    1. 初始:p = 节点1p->next = 节点2,不是NULL,进入循环);
    2. 第一次循环:p = p->next → p = 节点2p->next = NULL,循环结束);
    3. 最终:p指向最后一个节点(节点 2)。
步骤 4:连接新节点(p->next = newNode
  • 核心操作:把最后一个节点的next指针,指向新创建的节点,让新节点成为新的尾部。
  • 示意图(连接后):连接前:节点2 -> NULLnewNode -> NULL连接后:节点2 -> newNode -> NULL
  • 为什么新节点的next不用改?
    • 因为createNode中已经把newNode->next设为NULL,连接后它自然是最后一个节点,无需额外操作。
(3)单向链表使用示例
int main() { ListNode *head = NULL; // 链表头指针,初始为空 // 插入3个节点 insertNode(&head, 10); insertNode(&head, 20); insertNode(&head, 30); // 遍历链表,输出:10 20 30 traverseList(head); return 0; }
  1. 初始状态:ListNode *head = NULL(链表空);
  2. 插入第一个节点(data=10):
    • newNode[10] -> NULL
    • *head == NULL → *head = newNode
    • 链表:head -> [10] -> NULL
  3. 插入第二个节点(data=20):
    • newNode[20] -> NULL
    • *head != NULL → 定义p = headp指向 [10]);
    • p->next = NULL(循环不执行);
    • p->next = newNode → 链表:head -> [10] -> [20] -> NULL
  4. 插入第三个节点(data=30):
    • newNode[30] -> NULL
    • p = head(指向 [10]);
    • p->next = [20] != NULL → p = p->next(指向 [20]);
    • p->next = NULL(循环结束);
    • p->next = newNode → 链表:head -> [10] -> [20] -> [30] -> NULL

最容易踩的 2 个坑(重点提醒)

  1. 忘记给新节点的nextNULL:如果newNode->next是随机值(malloc 申请的内存未初始化),遍历的时候会陷入死循环;
  2. 用一级指针ListNode *head当参数:插入第一个节点时,head的副本被修改,外部head还是NULL,链表始终为空。

(2)双向链表的核心优势
  • 可以从表头往后遍历,也可以从表尾往前遍历;
  • 删除任意节点时,只需通过prevnext找到前后节点,直接修改指针即可(无需遍历找前驱)。

比如删除节点p

c

运行

// p是要删除的节点 p->prev->next = p->next; // 前节点的next指向后节点 p->next->prev = p->prev; // 后节点的prev指向前节点 free(p); // 释放节点内存
1. FreeRTOS 链表的特殊设计
  • 环形结构:没有 “NULL 结尾”,最后一个节点的next指向表头,表头的prev指向最后一个节点(方便循环遍历);
  • 根节点(xListEnd):不存储实际数据,仅作为链表的 “锚点”(方便插入删除操作,不用判断表头为空);
  • 节点带 “辅助值”(xItemValue):用于排序(比如延时列表按延时时间排序,就绪列表按优先级排序)。
2. 和通用双向链表的对应关系
通用双向链表FreeRTOS 链表(List)作用
节点数据域pvOwner指向节点的拥有者(比如 TCB)
节点前驱指针pxPrevious指向前一个列表项
节点后继指针pxNext指向后一个列表项
链表头xListEnd链表根节点(锚点)

比如 FreeRTOS 的就绪列表pxReadyTasksLists,就是一个数组,每个元素是一个双向环形链表,对应一个优先级的任务队列 —— 这就是多任务调度的基础!

四、链表的核心要点总结(必记)

  1. 节点是链表的基本单位:必须包含 “数据域” 和 “指针域”;
  2. 链表的核心是 “指针串联”:通过指针域把离散的内存块连起来,无需连续内存;
  3. 动态分配内存:链表节点通常用malloc创建(FreeRTOS 中也支持静态分配),按需扩展;
  4. 操作核心是 “指针移动”:遍历、插入、删除本质都是修改指针指向;
  5. FreeRTOS 的链表是 “增强版双向环形链表”:为任务管理、排序优化设计,本质还是链表的核心逻辑。

五、动手实践:写一个简单的双向链表(加深理解)

c

运行

#include <stdio.h> #include <stdlib.h> // 双向链表节点定义 typedef struct DoubleNode { int data; struct DoubleNode *prev; struct DoubleNode *next; } DoubleListNode; // 创建节点 DoubleListNode* createDoubleNode(int data) { DoubleListNode *newNode = (DoubleListNode*)malloc(sizeof(DoubleListNode)); newNode->data = data; newNode->prev = NULL; newNode->next = NULL; return newNode; } // 尾部插入节点 void insertDoubleNode(DoubleListNode **head, int data) { DoubleListNode *newNode = createDoubleNode(data); if (*head == NULL) { *head = newNode; return; } DoubleListNode *p = *head; while (p->next != NULL) { p = p->next; } p->next = newNode; newNode->prev = p; // 双向链表:新节点的prev指向最后一个节点 } // 遍历链表(正序) void traverseDoubleList(DoubleListNode *head) { DoubleListNode *p = head; while (p != NULL) { printf("%d ", p->data); p = p->next; } printf("\n"); } // 主函数测试 int main() { DoubleListNode *head = NULL; insertDoubleNode(&head, 100); insertDoubleNode(&head, 200); insertDoubleNode(&head, 300); traverseDoubleList(head); // 输出:100 200 300 return 0; }

Read more

用 Codex + GitHub Spec-Kit 做一次“规格驱动开发”实战

用 Codex + GitHub Spec-Kit 做一次“规格驱动开发”实战

* 用 Codex + GitHub Spec-Kit 做一次“规格驱动开发”实战 * 1) 初始化:把 spec-kit 工作区真正建起来(多种方式) * 方式 A:uvx 一次性运行(推荐) * 方式 B:uv tool install(全局安装 specify) * 方式 C:pipx 安装(Python 工具常用法) * 2) 初始化后,正确的目录结构长什么样( * 3) 在 Codex 里跑 speckit:统一输入规则(非常重要) * 4) 标准流水线:Constitution → Spec → Plan → Tasks → Implement * Step 1:

By Ne0inhk
VSCode Github Copilot使用OpenAI兼容的自定义模型方法

VSCode Github Copilot使用OpenAI兼容的自定义模型方法

背景 VSCode 1.105.0发布了,但是用户最期待的Copilot功能却没更新!!! (Github Copilot Chat 中使用OpenAI兼容的自定义模型。) 🔥官方也关闭了Issue,并且做了回复,并表示未来也不会更新这个功能: “实际上,这个功能在可预见的未来只面向内部人员开放,作为一种“高级”实验功能。是否实现特定模型提供者的功能,我们交由扩展作者自行决定。仅限内部人员使用可以让我们快速推进,并提供一种可能并非始终百分之百完善,但能够持续改进并快速修复 bug 的体验。如果这个功能对你很重要,我建议切换到内部版本 insider。” 🤗 官方解决方案:安装VSCode扩展支持 你们完全不用担心只需要在 VS Code 中安装扩展:OAI Compatible Provider for Copilot 通过任何兼容 OpenAI 的提供商驱动的 GitHub Copilot Chat,使用前沿开源大模型,如 Kimi K2、DeepSeek

By Ne0inhk
使用 VS Code 将项目代码上传到 Gitee 的完整指南

使用 VS Code 将项目代码上传到 Gitee 的完整指南

在现代软件开发流程中,版本控制是不可或缺的一环。 Gitee(码云)作为国内领先的代码托管平台,为开发者提供了稳定、快速的 Git 服务。 本文将详细介绍如何使用 Visual Studio Code(VS Code)将本地项目代码上传至 Gitee 仓库,涵盖从环境配置、初始化仓库到推送代码的完整流程。 一、准备工作 1. 安装必要工具 * Git:确保你的系统已安装 Git。 可通过终端运行 git --version  或 git -v 验证是否安装成功。 * VS Code:下载并安装 Visual Studio Code。 * Gitee 账号:前往 Gitee 官网 注册账号(如尚未注册)。 2. 安装 VS

By Ne0inhk
使用Git将代码从远程仓库拉取到本地(详细图解、简单易懂)

使用Git将代码从远程仓库拉取到本地(详细图解、简单易懂)

目录 一、前言 二、全流程 一、前言 本博客主要记录一下使用Git将代码从远程仓库拉取到本地的全流程,使用Git拉取代码在学校内多同学合作开发项目或者是实习拉取公司代码等场景都很常见,单纯记录希望对你有帮助 二、全流程 首先在你想要存放代码的位置新建一个文件夹并改名 进入刚刚创建的空文件中,右键然后点击显示更多选项 然后点击Git Bash Here 然后就会出现如图所示的命令行窗口 此时先不用管命令行窗口,找到你要远程仓库所在的平台(我这里以Gitee演示),如图点击克隆/下载按钮 HTTPS下方就是远程仓库的url地址,只要有远程仓库的url地址,只需要在刚刚的命令行窗口打上git clone在将url地址复制在后面再回车即可(Gitee下面的提示也给了,直接复制带git clone的命令就行,没有的话就自己敲git clone) 复制到命令行窗口之后,等待片刻即可 然后点开刚刚创建的文件夹就可以看到拉取下来的代码了,后续用IDEA打开该文件就可以在本地进行开发了

By Ne0inhk