
说起内核里的链表我最早是吃过亏的。刚接手一个内核模块的维护工作要给一批模拟设备做动态管理心想链表这东西太基础了就自己按教科书写了一个带哨兵节点的双向链表。结果一上线就被打脸内存泄漏、空指针、死循环轮着来折腾了整整两天才定位到问题是删除逻辑里指针没有回填。后来老老实实换用内核自带的 klist 链表体系也就是include/linux/list.h里那套list_head宏和辅助函数才真正体会到什么叫“站在内核社区肩膀上干活”。这篇文章就把这套 klist 链表体系掰开揉碎讲清楚它为什么这么设计、核心 API 怎么用、实战中要注意哪些细节、以及我这些年踩过的坑和排查思路。不管是刚开始写内核模块的初学者还是被链表崩溃折磨过的老手这篇文章都值得你在动手前花十分钟过一遍。1. 先搞清楚klist 链表到底是什么为什么内核非用它不可先说一个容易混淆的点。内核语境里“klist”这个词绝大多数情况下指的是list.h里那套通用双向循环链表也就是 kernel list 的简称核心数据结构是struct list_head。另外include/linux/klist.h里确实还有个叫klist的专用结构带引用计数主要用于设备驱动模型里的设备-驱动配对管理。本文说的 klist 链表以前者为主——它是内核里出镜率最高的链表实现几乎每个子系统都在用。1.1 我为什么弃用手写链表手写链表看起来很简单一个节点结构体里塞next和prev指针再保存用户数据。但当你的数据种类不止一种或者同一份数据要挂到多张链表上时问题就来了。比如你要维护一个设备对象它既要在“所有设备”链表里又要在“按类型分组”的链表里手写方案就得在结构体里定义两套不同的节点每个节点的插入、删除、遍历都要重新写一遍逻辑代码量直接翻倍还特别容易在复制粘贴时改错指针。内核的 klist 链表把思路彻底倒过来不是把数据放进链表节点而是把链表节点嵌进数据结构里。你需要管多少种关系就在结构体里放多少个struct list_head操作哪张表就传对应的成员指针一套 API 通吃所有场景。这个设计让链表逻辑和数据逻辑彻底解耦内核里几万个链表使用者都在复用同一套宏。1.2 list_head 的本质不在节点里放数据而是在数据里放节点先看最核心的结构定义struct list_head { struct list_head *next, *prev; };就两个指针别的什么都没有。就这么朴素。使用时你把它作为一个成员塞进自己的结构体struct my_device { int id; char name[32]; struct list_head node; // 链表节点嵌进来 };然后通过node这个成员把my_device串成链表。链表上的每个list_head地址其实是某个my_device结构体内部的偏移地址。要拿到完整的my_device就得靠container_of宏逆向往外推算。这里我用生活类比解释一下传统链表像是火车车厢节点和货物数据焊死在一起你造几列车就得准备几种车厢内核链表像是货架上的档案盒盒子list_head只是贴在档案袋上的一个标签档案袋里装什么你随便而且你可以贴多张标签让同一个档案出现在多个不同分类的抽屉里。双向循环也是个重要设计。头节点不存数据只作为遍历的锚点head-next是第一个元素head-prev是最后一个元素。循环意味着从任何节点出发都能走回起点判断链表是否为空只需要看head-next head。1.3 container_of 这个魔术宏是怎么实现的klist 链表能“从成员指针拿到宿主结构体地址”靠的是container_of宏。这个宏值得单独拉出来讲它是整个内核链表技术的基石。#define offsetof(TYPE, MEMBER) ((size_t) ((TYPE *)0)-MEMBER) #define container_of(ptr, type, member) ({ \ const typeof(((type *)0)-member) *__mptr (ptr); \ (type *)((char *)__mptr - offsetof(type, member)); \ })核心思路就一句话指针减去成员在结构体里的偏移量。offsetof的巧妙之处在于它用地址 0 强转成结构体指针再取成员地址这样拿到的就是成员相对于结构体首地址的字节偏移。container_of先通过__mptr保留你传入的成员指针再减去这个偏移就得到了结构体的首地址。理解了这个宏你才能理解为什么list_entry、list_for_each_entry这些宏能直接从链表节点指针跳到完整数据结构。这也是为什么使用list_for_each_entry时member参数必须传对传错一个字段算出来的结构体首地址就是错的解引用必崩。2. 核心 API 逐个拆解增删查改背后的指针编排这套链表的 API 数量不多但每个宏和函数背后都是非常讲究的指针操作。我按使用频率从高到低拆一遍每一步都说明“为什么这么写”。2.1 初始化LIST_HEAD 与 INIT_LIST_HEAD创建链表头有两种方式静态和动态// 静态定义并初始化常用于全局链表 LIST_HEAD(dev_list); // 等价展开 struct list_head dev_list { dev_list, dev_list };如果链表头嵌在另一个结构体里或者你想在运行时重新初始化就用INIT_LIST_HEADstruct list_head my_head; INIT_LIST_HEAD(my_head);初始化就是让next和prev都指向自己。这个细节很重要空链表不是 NULL而是指向自身。很多初学者检查链表是否为空时习惯判断head NULL这是个典型误区。内核里判断空链表只能用list_empty(dev_list); // 空返回 1非空返回 02.2 插入与删除list_add、list_add_tail、list_del插入操作的核心是__list_add这个内联函数所有插入变体最终都调它static inline void __list_add(struct list_head *new, struct list_head *prev, struct list_head *next) { next-prev new; new-next next; new-prev prev; prev-next new; }四个指针赋值顺序有讲究。先把next-prev改掉再设置new自己的两个指针最后改prev-next这样在任何并发读的瞬间链表都不会出现断裂后无法恢复的状态。对外主要有两个插入接口list_add(new, head)插到 head 后面等价于__list_add(new, head, head-next)实现的是栈一样的前插后加的节点遍历时最先看到。list_add_tail(new, head)插到 head 前面等价于__list_add(new, head-prev, head)实现的是队列一样的尾插符合“先进先出”的直觉。选哪个取决于你的语义。需要最新数据优先访问的用list_add需要按创建顺序处理的用list_add_tail。我做设备管理时为了让调试输出顺序稳定一律用list_add_tail。删除接口static inline void __list_del(struct list_head *prev, struct list_head *next) { next-prev prev; prev-next next; } static inline void list_del(struct list_head *entry) { __list_del(entry-prev, entry-next); entry-next LIST_POISON1; entry-prev LIST_POISON2; }list_del的巧妙之处是它只需要一个要被删除的节点指针不用管它在哪。因为双向链表可以通过entry-prev和entry-next拿到前后节点把它们互相勾上即可。删完后它把被删节点的两个指针置成LIST_POISON1/2内核里专门定义的毒化地址目的就是让任何对已删除节点的误用迅速触发异常方便调试。list_del_init则是删除后再用INIT_LIST_HEAD重置节点这样节点可以重新挂到其他链表上不会残留毒化指针。我建议在需要重复利用节点的场景里优先用list_del_init。2.3 遍历list_for_each 与 list_for_each_entry最底层的遍历宏是list_for_each它遍历的是链表节点本身#define list_for_each(pos, head) \ for (pos (head)-next; pos ! (head); pos pos-next)注意循环结束条件是pos ! head不是pos ! NULL。因为这是循环链表回到头节点意味着遍历完毕。这个宏里pos是struct list_head *类型。实际业务里我们更关心完整的数据结构所以更常用list_for_each_entry#define list_for_each_entry(pos, head, member) \ for (pos list_first_entry(head, typeof(*pos), member); \ pos-member ! (head); \ pos list_next_entry(pos, member))这个宏直接帮你完成了“从节点指针到宿主结构体指针”的转换。pos是你的自定义结构体指针member是链表节点在结构体里的字段名。宏内部一步步通过container_of从head-next算出pos的起始地址然后判断pos-member是否回到head。还有一个常用的变体list_for_each_entry_reverse反向遍历原理一样只是从head-prev开始。2.4 安全遍历 list_for_each_safe为什么必须用 safe 版本这个必须单独拎出来讲它是新手翻车最严重的地方。看名字里的safe安全版本专为“遍历过程中删除节点”设计#define list_for_each_safe(pos, n, head) \ for (pos (head)-next, n pos-next; pos ! (head); \ pos n, n pos-next)多了个n在每次循环开始前先把pos-next备份到n。这样即使你在循环体里把pos对应的节点删掉了pos-next已经指向毒化地址也没关系循环增量用的是提前备份的n不会沿着断裂的指针走下去。对应的条目级版本是list_for_each_entry_safe#define list_for_each_entry_safe(pos, n, head, member) \ for (pos list_first_entry(head, typeof(*pos), member), \ n list_next_entry(pos, member); \ pos-member ! (head); \ pos n, n list_next_entry(n, member))在驱动的设备移除逻辑、文件系统的清理逻辑里你几乎总会看到它。我个人的习惯是只要循环体里可能出现list_del或者kfree(pos)一律用 safe 版本哪怕当时觉得“这次应该不会删”因为代码将来会被改谁也不能保证后来者会不会在循环里突然加一个删除操作。3. 进阶操作与并发控制真正干活的时候怎么用链表 API 本身只是工具真正决定稳定性的是你对并发和内存的理解。内核里链表几乎不会在单线程环境里跑中断上下文、进程上下文、多核并发一个都跑不掉。3.1 利用 list_entry 拿宿主结构驱动申请的设备节点示例有时你手里只有一个struct list_head *指针比如从list_for_each里拿到的这时想拿宿主结构就要用list_entry宏#define list_entry(ptr, type, member) \ container_of(ptr, type, member)它其实就是container_of的语义化包装。比如我写一个内核模块给模拟设备分配对象并挂到全局链表struct my_device { int id; char name[32]; struct list_head node; }; static LIST_HEAD(dev_list); static struct my_device *create_device(int id, const char *name) { struct my_device *dev; dev kzalloc(sizeof(*dev), GFP_KERNEL); if (!dev) return NULL; dev-id id; snprintf(dev-name, sizeof(dev-name), %s, name); INIT_LIST_HEAD(dev-node); list_add_tail(dev-node, dev_list); return dev; }INIT_LIST_HEAD(dev-node)这一步不能省。kzalloc清零了内存但链表节点的next和prev都是 0也就是 NULL直接list_add_tail的话__list_add里访问head-prev会直接空指针崩溃。初始化链表节点是这个场景里最常见的低级错误之一。3.2 并发安全自旋锁组合使用链表本身不是线程安全的并发修改时必须加锁。内核里保护链表最常用的锁是自旋锁因为在中断上下文里不能睡眠而链表操作都是微秒级短临界区自旋等待完全可接受。典型的使用方式是链表和锁捆绑成一个管理对象struct device_list { struct list_head head; spinlock_t lock; }; static struct device_list g_list; static void dev_list_init(struct device_list *list) { INIT_LIST_HEAD(list-head); spin_lock_init(list-lock); } static void dev_list_add(struct device_list *list, struct my_device *dev) { spin_lock(list-lock); list_add_tail(dev-node, list-head); spin_unlock(list-lock); }所有对链表的读写都必须在同一个锁的保护下进行。遍历也一样不能以为只读操作就不用加锁——你在遍历的同时另一个 CPU 可能正在删除节点等你pos pos-next时pos-next已经被改成LIST_POISON继续访问就崩了。所以遍历时也要持锁static void dump_all_devices(struct device_list *list) { struct my_device *dev; unsigned long flags; spin_lock_irqsave(list-lock, flags); list_for_each_entry(dev, list-head, node) { pr_info(id%d name%s\n, dev-id, dev-name); } spin_unlock_irqrestore(list-lock, flags); }用spin_lock_irqsave而不是spin_lock是因为你无法确定调用这个函数的上下文里中断是不是已经开启irqsave/irqrestore会把当前中断状态保存并在解锁时恢复避免在保存旧状态之前就被中断打断造成死锁。这是内核并发编程里非常基础也极其重要的习惯。3.3 一个结构挂多条链表klist 链表最强大的能力之一就是同一个结构体挂多张表。比如一个设备既在全局设备链表里又在按状态分类的链表里struct my_device { int id; char name[32]; struct list_head global_node; // 全局链表 struct list_head state_node; // 状态链表 };两个链表各自独立管理用不同的member参数操作即可。实际项目中我在一个流量控制模块里就把网络会话对象同时挂进了“全量会话表”和“按协议分组的会话表”删除对象时两条链表的节点都要摘干净。如果只删了一条另一个链表就成了悬垂指针的温床后续遍历必崩。这种“多链表挂载”场景是手写链表几乎无法优雅处理的而 klist 体系天然支持。4. 实战案例写一个内核模块管理设备对象理论讲再多不如完整跑一个例子。下面我用一个简化的内核模块演示 klist 链表从定义到清理的完整流程。4.1 场景设计与数据结构假想场景模块管理一批模拟设备支持按 ID 添加、按名称前缀查找、按 ID 删除卸载时清空全部节点。这个场景覆盖了 klist 链表增删查改的全部核心操作。首先是数据结构和全局链表struct my_device { int id; char name[32]; struct list_head node; }; static LIST_HEAD(dev_list);链表头用LIST_HEAD静态定义模块加载时不需要额外初始化。4.2 添加设备与遍历输出添加操作注意两点分配结构体内存后先INIT_LIST_HEAD然后list_add_tail保证按创建顺序排列static int add_device(int id, const char *name) { struct my_device *dev; dev kzalloc(sizeof(*dev), GFP_KERNEL); if (!dev) return -ENOMEM; dev-id id; snprintf(dev-name, sizeof(dev-name), %s, name); INIT_LIST_HEAD(dev-node); list_add_tail(dev-node, dev_list); return 0; }遍历接口用list_for_each_entry直接拿my_devicestatic void dump_devices(void) { struct my_device *dev; if (list_empty(dev_list)) { pr_info(device list is empty\n); return; } list_for_each_entry(dev, dev_list, node) { pr_info(device: id%d name%s\n, dev-id, dev-name); } }调试时我喜欢先把遍历输出固化下来每次增删前后都 dump 一次对比节点顺序和数量。printk虽然慢但在开发阶段是最直观的观测手段。4.3 按条件删除节点与安全释放删除操作必须用 safe 版本因为kfree会释放整个结构体内存节点的next/prev此时已经不可用static int remove_device_by_id(int id) { struct my_device *dev, *tmp; int found 0; list_for_each_entry_safe(dev, tmp, dev_list, node) { if (dev-id id) { list_del(dev-node); kfree(dev); found 1; break; } } return found ? 0 : -ENOENT; }这里有个细节list_del之后再kfree。因为我kzalloc分配的整个结构体都会被释放链表节点指针是否置毒化对后续kfree没有影响。但不要反过来先kfree再list_del那等于访问已释放内存属于 use-after-free。4.4 模块卸载时的完整清理模块卸载时必须把链表里所有节点清干净否则内核会一直持有已经消失的模块代码的引用后果是模块卸载后访问链表直接崩溃。清理逻辑static void cleanup_devices(void) { struct my_device *dev, *tmp; list_for_each_entry_safe(dev, tmp, dev_list, node) { list_del(dev-node); kfree(dev); } if (!list_empty(dev_list)) pr_warn(dev_list still not empty after cleanup!\n); }清完再调用list_empty做断言这是一个好习惯。如果还有残留说明有节点漏删这时不要直接返回要回头查代码逻辑。另外模块清理函数里应该先禁止任何新节点加入再执行清空否则一边加一边删永远清不完。生产级模块通常用状态标志位控制模块卸载中不再接受新请求。5. 常见 Bug 与调试经验这些坑我基本都踩过这一节就是大量实操换来的经验了。列几个我见过的、自己也踩过的高频问题以及能直接用的排查手段。5.1 遍历中删除节点导致死循环或崩溃这是 klist 链表第一坑。症状是内核 log 里出现大量重复打印的遍历记录或者直接 oops 在hlist_del/list_del附近。原因就是用了非 safe 版本遍历并在循环体内删除当前节点。删除操作把当前节点的next改成了毒化地址循环体结束后的pos pos-next拿到毒化地址继续判断pos ! head时已经是在读非法内存。排查和修复都简单先全项目搜索list_for_each_entry的使用点凡是循环体里有list_del或者kfree(pos)的全部替换成list_for_each_entry_safe。这个坑之所以高发是因为很多代码一开始遍历时不删除后来某次需求变更加了删除语句忘了同步改宏。5.2 节点没初始化就插入症状同样很直接第一次list_add_tail就崩溃。原因是我前面强调过的kzalloc不会帮你初始化链表指针。如果你用的是vmalloc或者静态数组里的节点更要小心。我的排查经验是遇到链表相关崩溃第一件事检查所有相关节点分配后有没有INIT_LIST_HEAD。另一个隐性问题是某些复用节点的场景节点从链表 A 摘下来后直接往链表 B 挂忘了中间做INIT_LIST_HEAD。list_del会把节点指针设置成毒化地址直接放进 B 链表后 B 就废了。正确姿势是list_del_init或者重新INIT_LIST_HEAD。5.3 list_for_each_entry 的 member 参数写错这个错误很隐蔽往往不是立刻崩溃而是遍历输出大量垃圾数据偶尔偶发崩溃。因为member写错时container_of计算的偏移量就不对拿到的所谓“结构体首地址”实际上偏到了结构体中间。解引用某些字段可能还没越界但读出来的值完全不是你想的那样。判断方法把遍历打印里第一个字段地址和结构体成员地址做对比如果不满足“成员地址 结构体地址 成员偏移”的约束那基本就是 member 用错了。养成习惯凡是用list_for_each_entry的循环先核对宏最后一个参数是不是当前结构体里真正的链表节点字段名。5.4 并发修改没有加锁症状很随机系统运行一段时间后偶发崩溃崩溃位置不稳定有时在链表遍历有时在设备释放。这种随机性最强的 bug 基本都是并发问题。内核里要用工具来查打开CONFIG_LOCKDEP、CONFIG_DEBUG_LIST、CONFIG_DEBUG_ATOMIC_SLEEP这几个调试选项能帮你抓出非法的链表并发操作。CONFIG_DEBUG_LIST特别有用它会给链表操作加上完整性校验一旦检测到链表结构被破坏内核会立刻给出明确报错而不是等到崩溃现场才察觉到问题。建议开发阶段的内核都打开这个配置虽然稍有性能损耗但能救命的排查效率远大于那点开销。再补一个自查清单我每次写完链表操作都会过一遍节点分配后是否INIT_LIST_HEAD遍历循环里有没有kfree(pos)有则用 safe 版本。删除后用list_del还是list_del_init节点还要不要复用所有链表访问路径是否持锁中断安全irqsave还是普通 spin_lock多链表挂载时删除对象是否摘了所有链表上的节点5.5 崩溃后的内核 log 解读就算崩了也别慌oops 信息里有大量线索。重点看两处RIP:或者PC is at后面的函数名能告诉你崩溃发生在哪个函数然后是Call Trace能告诉你调用路径。如果RIP指向的是__list_add或__list_del这类内联函数那基本就是链表被破坏或者节点非法。这时候我会加一个调试手段在可疑位置前后用WARN_ON 打印节点地址、head地址手动检查链表完整性。比如打印pos-node.next和head是否符合预期。有些老手会临时把CONFIG_DEBUG_LIST打开重编内核让内核在每次链表操作时自动校验完整性这招在定位“不知道哪里改了链表”的场景里极其高效。最后说两句跟我最初手写链表踩坑的经历相比klist 链表体系最大的价值在于它把链表操作从“我要管理的具体事物”里完全抽象出来了。你只管数据结构和语义指针编排、循环变量、安全遍历这些脏活累活内核社区早就替你打磨过无数遍了。我个人的实操体会是别觉得自己写个链表很厉害在内核里稳定、规范、可维护远比炫技重要。用 klist 链表时坚持几个原则——节点必先初始化、遍历删除必用 safe、并发修改必加锁、删除节点必从所有链表摘干净——基本就能避开九成以上的链表问题。这套经验不仅适用于内核模块你把它迁移到用户态 C 程序的嵌入式链表设计里同样能少掉很多头发。