C语言_链表使用(上)

Tutorial: 卓越工程师 Category: 嵌入式 Published: 2026-10-23 11:05:29 Views: 1 Likes: 0 Comments: 0

245.1.3C语言_链表使用(上)

#include <rtthread.h> /* 引入 RT-Thread 基本类型、内存管理和链表接口声明。 */

#define DBG_TAG "main" /* 设置本文件日志标签,串口输出会显示为 main。 */
#define DBG_LVL DBG_LOG /* 将本文件日志阈值设为最详细的 DBG_LOG。 */
#include <rtdbg.h> /* 必须放在 DBG_TAG、DBG_LVL 之后,使日志宏按上述配置展开。 */

struct info /* 定义课程中的人员业务对象。 */
{ /* 结构体定义开始。 */
    int age; /* 保存年龄。 */
    char name[20]; /* 在对象内部保存最多 19 个普通字符和结尾空字符。 */
    rt_list_t list; /* 嵌入 RT-Thread 双向链表结点,使对象可以加入侵入式链表。 */
}; /* 结构体定义后必须有分号。 */

int main(void) /* RT-Thread 自动创建的 main 线程从这里运行应用代码。 */
{ /* main 函数体开始。 */
    int count = 1; /* 课程用作常驻循环计数;循环体并不读取它的具体值。 */
    struct info *person1; /* 只定义指针,尚未创建 struct info 对象。 */
    struct info *person2; /* 定义第二个对象指针。 */
    struct info *person3; /* 定义第三个对象指针。 */
    struct info *person4; /* 定义第四个对象指针。 */

    person1=(struct info *)RT_KERNEL_MALLOC(sizeof(struct info)); /* 从 RT-Thread 堆申请一个完整对象的空间。 */
    if(person1==NULL) /* 检查分配是否失败,失败时不能解引用 person1。 */
    { /* person1 分配失败分支开始。 */
        LOG_E("person1 RT_KERNEL_MALLOC error!"); /* 用错误级别记录失败对象。 */
        return -1; /* 结束当前 main 入口;课程直接使用 -1 作为错误值。 */
    } /* person1 分配失败分支结束。 */
    person2=(struct info *)RT_KERNEL_MALLOC(sizeof(struct info)); /* 为 person2 申请对象空间。 */
    if(person2==NULL) /* 检查 person2 的申请结果。 */
    { /* person2 分配失败分支开始。 */
        LOG_E("person2 RT_KERNEL_MALLOC error!"); /* 输出 person2 分配失败日志。 */
        return -1; /* 课程在此直接返回,尚未回收已成功分配的 person1。 */
    } /* person2 分配失败分支结束。 */
    person3=(struct info *)RT_KERNEL_MALLOC(sizeof(struct info)); /* 为 person3 申请对象空间。 */
    if(person3==NULL) /* 检查 person3 的申请结果。 */
    { /* person3 分配失败分支开始。 */
        LOG_E("person3 RT_KERNEL_MALLOC error!"); /* 输出 person3 分配失败日志。 */
        return -1; /* 课程在此直接返回,前面成功分配的对象会失去释放机会。 */
    } /* person3 分配失败分支结束。 */
    person4=(struct info *)RT_KERNEL_MALLOC(sizeof(struct info)); /* 为 person4 申请对象空间。 */
    if(person4==NULL) /* 检查 person4 的申请结果。 */
    { /* person4 分配失败分支开始。 */
        LOG_E("person4 RT_KERNEL_MALLOC error!"); /* 输出 person4 分配失败日志。 */
        return -1; /* 课程在此直接返回,未清理 person1 至 person3。 */
    } /* person4 分配失败分支结束。 */

    person1->age=8; /* 指针用 -> 访问成员,把 person1 的年龄设为 8。 */
    strcpy(person1->name,"wfeng"); /* 把字符串及结尾空字符复制进 person1 的 name 数组。 */

    person2->age=15; /* 设置 person2 的年龄。 */
    strcpy(person2->name,"lihua"); /* 设置 person2 的姓名。 */

    person3->age=11; /* 设置 person3 的年龄。 */
    strcpy(person3->name,"xiaohong"); /* 设置 person3 的姓名。 */

    person4->age=4; /* 设置 person4 的年龄。 */
    strcpy(person4->name,"liuchang"); /* 设置 person4 的姓名。 */

    rt_list_init(&(person1->list)); /* 把 person1 内嵌的结点初始化为本例循环链表的起点。 */
    LOG_D("List len is %d",rt_list_len(&(person1->list))); /* 尚未插入其他结点,API 返回 0。 */
    rt_list_insert_after(&(person1->list),&(person2->list)); /* 在 person1 的结点后插入 person2。 */
    LOG_D("List len is %d",rt_list_len(&(person1->list))); /* 起点之外有 person2,返回 1。 */
    rt_list_insert_after(&(person2->list),&(person3->list)); /* 在 person2 后插入 person3。 */
    LOG_D("List len is %d",rt_list_len(&(person1->list))); /* 起点之外有两个结点,返回 2。 */
    rt_list_insert_after(&(person3->list),&(person4->list)); /* 在 person3 后插入 person4。 */
    LOG_D("List len is %d",rt_list_len(&(person1->list))); /* 起点之外有三个结点,返回 3。 */
    rt_list_remove(&(person3->list)); /* 从链表摘除 person3 的链接结点,但不释放 person3。 */
    LOG_D("List len is %d",rt_list_len(&(person1->list))); /* 剩 person2、person4 两个后继,返回 2。 */

    while (count++) /* 课程用循环让 main 线程一直存活。 */
    { /* 常驻循环开始。 */
        /* LOG_D("Hello RT-Thread!"); */ /* 原周期日志已注释,避免干扰链表长度输出。 */
        rt_thread_mdelay(1000); /* 当前线程延时 1000 ms,把 CPU 让给其他就绪线程。 */
    } /* 正常运行时不会退出该循环。 */

    return RT_EOK; /* 形式上的成功返回;按当前循环逻辑通常不可达。 */
} /* main 函数体结束。 */

以上代码还原视频结尾的实验状态:先把 person2、person3、person4 依次插到以 person1->list 为循环起点的链中,打印长度 0、1、2、3;随后摘除 person3->list,再打印 2。课程压缩包中的最终 02LIST 工程在下一课又加入了遍历函数,并把删除语句注释掉,因此不能直接把压缩包最终文件当成本视频每一时刻的代码。

一、链表的使用

本例把 rt_list_t 嵌入 struct info,展示 RT-Thread 的侵入式链表用法。业务对象负责保存 age、name,list 成员只负责链接。链表接口接收的是 rt_list_t *,因此调用时传 &(person->list),而不是 person 本身。

1.1 结构体的定义

struct info 的三个成员并不是物理上固定分成“上半块数据、下半块指针”;那只是视频画图帮助理解。C 编译器会根据成员顺序、类型对齐和填充规则安排实际布局。应使用 sizeof(struct info) 获取对象所需空间,不要手算成员字节数。

rt_list_t list 本身已经包含 next 和 prev,无需在 struct info 中再次声明两个指针。因为链接字段嵌在业务对象内,所以拿到 list 的地址后,可通过后续课程使用的 rt_list_entry() 找回外围的 struct info 地址。

char name[20] 是对象内部的字符数组。只要使用普通单字节字符串,它可保存最多 19 个非空字符,最后一个位置留给字符串结束符 \0。若保存 UTF-8 中文,每个汉字通常占多个字节,容量应按编码后的字节数判断,而不是按汉字个数判断。

1.2 结构体变量的定义

struct info *person1; 只创建一个指针对象,指针起初没有指向有效的 struct info。在给它赋予有效对象地址前,执行 person1->age 属于未定义行为。

可以让指针指向静态对象、自动存储期对象或动态分配对象;并非“结构体指针必须调用 malloc”。本课选择动态分配,是为了练习 RT-Thread 堆接口。四个指针分别声明,最终分别指向四块独立的对象空间。

1.3 内存的开辟

本工程的 rtdef.h 把 RT_KERNEL_MALLOC(sz) 定义为 rt_malloc(sz)。它从 RT-Thread 管理的堆中申请至少 sz 字节,成功时返回适当对齐的对象指针,失败时返回 RT_NULL/空指针;本例用标准宏 NULL 比较也能编译。

sizeof(struct info) 在编译期得到完整结构体所需字节数,包含可能存在的对齐填充。比写固定数字可靠,因为成员或目标架构变化时会自动调整。

课程保留了 (struct info *) 强制转换。C 语言允许 void * 隐式转换为对象指针,所以该转换并非必需;若错误地漏掉函数声明,强制转换还可能掩盖一部分有用诊断。这里能正确编译,是因为 <rtthread.h> 已声明相关接口。

动态分配成功只意味着得到一块可用空间,其中原有字节值不确定。代码随后必须先给需要读取的业务成员赋值,并在结点入链前初始化或由插入函数完整写好其链接字段。

1.4 问题的发现与解决

1.4.1 代码报错分析与修改

视频第一次编译时,工程自身还出现了 RT_WEAK 大小写不匹配的问题,这与上一课一致:课程所用 RT-Thread 5.0.2 为当前工具链定义的是小写 rt_weak。画面中的 reboot defined but not used 是另一个警告,含义是静态函数 reboot() 已定义却未被引用,不是“找不到 reboot”。

本课新写的结构体定义末尾也一度漏了分号。struct info { ... }; 中最后的分号是完整声明的一部分;漏掉后,编译器可能在下一行才报错。因此定位语法错误时,除了看被标红行,也要检查它前面的声明、括号和分号。

修改后应保存并重新构建,确认真正的首个错误已经消失。后续连锁报错往往由前面的语法错误触发,不宜逐条猜测。

1.4.2 警告处理与代码优化

视频画面中,直接写 malloc(...) 得到的核心诊断是 implicit declaration of function 'malloc',并提示包含 <stdlib.h> 或提供声明。编译器没有在这条警告里“建议改用 RT_KERNEL_MALLOC”;老师随后查看 RT-Thread 内核的分配方式,才主动换成了 RT-Thread 宏。

在 RT-Thread 应用里使用 RT_KERNEL_MALLOC/rt_malloc,可以与系统的堆配置、统计和对应释放接口保持一致。若坚持使用标准 malloc,至少要包含正确头文件,并确认 C 库堆与 RT-Thread 堆的配置及线程安全性。分配和释放接口必须配对,不能混用不同内存管理器。

“变量未使用”警告在敲代码的中间阶段可能暂时存在,但完成代码后应处理,而不是一概忽略。警告常常能暴露拼写错误、遗漏逻辑和无效代码。

1.4.3 例题1: 申请内存空间失败的处理

每次分配后立即检查空指针是必要的,否则下一次 -> 访问可能触发故障。LOG_E 用错误级别打印失败对象,便于从串口日志定位问题。

课程代码仍有一个资源清理缺口:若 person2、person3 或 person4 分配失败,之前已经成功申请的对象会因直接 return -1 而没有释放。教学程序通常常驻运行,正常路径也没有释放;在可重复调用或需要退出的真实模块中,应设计统一清理路径,并用与 RT_KERNEL_MALLOC 配套的释放接口回收已成功申请的对象。

1.5 打印级别的介绍

本工程 rtdbg.h 的级别值是:DBG_ERROR = 0、DBG_WARNING = 1、DBG_INFO = 2、DBG_LOG = 3。LOG_E、LOG_W、LOG_I、LOG_D 分别对应错误、警告、信息和调试日志。

编译条件采用“当前阈值大于等于该消息级别”判断:

  • DBG_LVL 为 DBG_LOG(3)时,四个级别都可编译输出。
  • DBG_LVL 为 DBG_INFO(2)时,调试级 LOG_D 被裁掉,信息、警告和错误仍保留。
  • DBG_LVL 为 DBG_ERROR(0)时,只保留错误日志。

这里的数值表示详细程度阈值,不是运行权限或任务优先级。DBG_TAG "main" 则给日志加上模块标签,所以视频串口显示 [D/main]。这些宏是否最终输出还受 DBG_ENABLE、ULog 配置和控制台设备等条件影响。

1.6 return的介绍

return -1; 会立刻结束当前函数,因而避免在分配失败后继续使用空指针。main() 的声明返回 int,返回值应表达成功或错误状态;本课为了演示直接写 -1,没有进一步使用 RT-Thread 的错误码体系。

原笔记中“复函数(嵌套函数)”来自转录误差,视频表达的是把重复的内存申请和检查整理得更易读,并没有定义嵌套函数。标准 C 本身不支持在函数体内再定义一个函数,个别编译器的扩展不能当作可移植语法。

代码末尾的 return RT_EOK; 通常执行不到,因为 while (count++) 持续循环。count 的值对循环工作没有意义;更重要的是,有符号 int 最终递增溢出属于未定义行为。它在每秒一次的演示中要很久才出现,但阅读代码时仍应知道这一点。

1.7 结构体变量赋值

1.7.1 例题1: 结构体变量赋值操作

普通结构体对象用 . 访问成员,结构体指针用 ->。表达式 person1->age 等价于 (*person1).age;其中 person1 必须已经指向仍在生命周期内的有效对象。

IDE 把输入的点自动替换成箭头只是编辑辅助,语法规则不会因此改变。课程分别给四个对象设置年龄和姓名,链表的链接成员则交由 rt_list_init() 和 rt_list_insert_after() 管理。

1.7.2 例题2: 结构体中字符串的赋值

创建完成后,字符数组成员不能写成 person1->name = "wfeng";。原因不是“把字符串首地址赋给普通数据”,而是 C 的数组不是可修改左值,数组对象创建后不能整体用赋值运算符重新赋值。

本例的字符串字面量都能连同末尾 \0 放进 20 字节数组,所以 strcpy() 不会越界。若来源长度不受控制,strcpy() 不会替目标数组检查容量,必须先验证字节长度或使用能够明确限制并保证结尾的复制方案。

1.8 链表的初始化

person1 是 struct info *,而 person1->list 是一个 rt_list_t 对象。rt_list_init() 需要 rt_list_t *,所以必须写 &(person1->list) 取得这个成员的地址。外层括号只是增强可读性,关键是取地址符 &。

调用后,person1->list.next 和 person1->list.prev 都指向 person1->list 自身。从 RT-Thread API 的视角,它是一个空表头,长度为 0;从课程业务叙事看,它同时属于 person1。正是这两个角色重叠,造成了后面的计数理解歧义。

1.9 链表的插入

1.9.1 链表插入的代码实现

rt_list_insert_after(&(person1->list), &(person2->list)) 把第二个对象的链接成员插到第一个之后。随后以 person2->list、person3->list 为定位点继续后插,最终形成:

person1->list <-> person2->list <-> person3->list <-> person4->list <-> person1->list

这是双向循环关系。接口的第一个参数是已在链中的定位结点 l,第二个参数才是要加入的新结点 n。每个对象只能靠同一个 list 成员同时处于一条链中;重复插入已在链中的结点会破坏链接。

1.9.2 例题1: 链表插入后的⻓度验证

rt_list_len(l) 从 l->next 开始计数,重新遇到 l 时停止,所以永远不统计传入的起点 l。视频以 person1->list 作为起点,输出依次为:

时刻起点之外的结点返回值
刚初始化无0
插入 person2 后person21
再插入 person3 后person2、person32
再插入 person4 后person2、person3、person43

这不是函数“漏算了第一个数据结点”,而是 API 把传入地址解释为表头。若设计上把 person1 也当作业务数据,那么链中确实有四个 struct info 对象,但 rt_list_len(&(person1->list)) 的语义仍只会返回起点之外的三个链接结点。工程中通常使用独立的 rt_list_t 哨兵头,以免把业务对象数与 API 长度混在一起。

1.9.3 例题2: 链表节点的完整插入与验证

视频烧录后的串口实际显示四次长度日志:0、1、2、3。这些数字是每一步调用 rt_list_len() 的结果,并不是结点内容或“节点序列”。原笔记把它解释为打印出“零一二三、确认包含四个节点”,缺少这一关键区别。

链表结构是否正确,不能只凭最终长度判断。还要确认循环能回到同一个起点,并满足每个结点的 next->prev 和 prev->next 均回指自身。长度日志能证明当前遍历走了几步,却不能单独证明所有反向链接都正确。

1.9.4 链表⻓度计算函数及链表操作相关函数

正确接口名是 rt_list_len(),不是转录中的 “list line”。该函数遍历整个环,时间复杂度为 O(n),频繁调用会反复走链表。其他常用接口还有 rt_list_isempty()、前插、后插、删除以及遍历宏;选择接口时先明确传入的是哨兵头还是普通结点。

1.10 链表的删除

rt_list_remove(&(person3->list)) 只把 person3->list 从环中摘下,并把它的 next、prev 重新指向自身。剩余环为 person1 <-> person2 <-> person4 <-> person1,所以从 person1->list 出发调用 rt_list_len() 返回 2。

视频没有打印姓名或年龄,不能据此说删除后“内容从 0123 变为 012”;画面验证的是长度从 3 变成 2。person3 指向的对象仍然存在、字段仍可读取,也尚未释放。若以后不再使用它,应先确保已经摘链,再用匹配的内存释放接口回收;若还要重新插入,它当前自指的 list 成员可以再次加入有效链表。