资讯动态

Linux内核链表list_head深度解析:从原理到工程实践

发布时间:2026/10/3 4:16:11 来源:尧图企业网站定制
如果你翻过Linux内核源码第一次看到include/linux/list.h里面的struct list_head大概率会愣一下链表节点就这么点东西两个指针连数据域都没有那存什么我当时第一次看也是一脸懵——教材里的链表不都是data加next加prev吗怎么到了内核里数据全不见了。这个问题的答案就是Linux链表设计的精髓所在。它不把链表当成数据的一部分而是把链表当成数据结构的附件——你需要被链起来就把list_head嵌进你的结构体里。这套设计从2.x内核一直用到今天驱动、设备模型、文件系统、进程管理里面到处都是它的身影。搞懂它不只是为了面试题更是为了以后看内核代码、写驱动模块、做嵌入式Linux开发的时候不至于被list_for_each_entry和container_of劝退。这篇文章我打算从设计动机讲起拆到宏定义的源码层再带你把整套东西搬到一个普通C工程里跑起来。内容偏底但尽量好懂适合想深入看内核源码的Linux开发者、嵌入式方向的学生以及那些想摆脱只会调API瓶颈的C语言玩家。1. 反转直觉内核链表不存数据只存指针关系1.1 教科书写法 vs 内核写法教科书教的双向链表结构体基本长这样struct list_node { int data; struct list_node *prev; struct list_node *next; };这种设计有一个问题每定义一种新数据就得重新写一套链表操作。你有一个student链表就得写student_insert、student_delete、student_traverse再来一个device结构体又得从头写一遍。C语言没有泛型C的template在纯C工程里也用不上于是内核的做法是——把链表操作本身单独提炼成一个和具体数据无关的模块。内核里的链表节点长这样struct list_head { struct list_head *next, *prev; };没有数据域。真的没有。那数据存哪存外面。你自定义的结构体里想被链入链表就在里面放一个struct list_head成员struct task_node { int pid; char name[32]; struct list_head list; // 链表节点 };这就是所谓的结构体嵌套链表节点模式。教科书是节点包含数据内核是数据结构嵌入链表节点方向完全反过来了。1.2 一个节点两个指针怎么装下整个结构体关键问题来了遍历的时候拿到的只是一个struct list_head *指针怎么恢复成外层那个struct task_node *答案在container_of宏。它利用C语言结构体内存的确定性——只要知道结构体里某个成员的地址减去该成员在结构体中的偏移量就能得到结构体的起始地址。#define container_of(ptr, type, member) ({ \ const typeof(((type *)0)-member) *__mptr (ptr); \ (type *)((char *)__mptr - offsetof(type, member)); })拿到list成员的地址减去list在struct task_node里的偏移按pid、name排列后list一般在偏移40字节附近就是整个结构体的头。这个从头到尾再反推回去的手法是理解内核链表的第一道坎也是第一层惊艳。1.3 双向循环带来的三个直接收益list_head不只是双向的它还是循环的。链表头本身也是一个list_head节点它的next指向第一个元素prev指向最后一个元素空链表时它俩都指向自己。这三个特点带来三个实打实的收益插入和删除都是O(1)。不管在链表的哪个位置只需要改写邻居节点的指针不需要遍历定位前提是你拿得到那个位置的指针。没有头指针和尾指针的特殊情况。因为头节点和普通节点长得一样list_add往头插、list_add_tail往尾插用同一套代码逻辑。循环结构让第一个节点和最后一个节点的判断无比简洁。遍历接口统一。不管遍历哪个结构体的链表都走list_for_each_entry这几个宏代码风格高度一致。这三条合起来本质上是把链表这个基础设施变成了像标准库一样的东西。正是因为有list_head这套抽象内核里几百种结构体才能统一地、高效地被链来链去而不用每写一个结构体就重造一遍轮子。2. container_of与双向循环从成员反向找回整个结构体这一节单独拎出来讲是因为没吃透container_of后面的API全部看不懂只能死记硬背换个场景就会掉坑里。2.1 链表头也是普通节点先看这个宏#define LIST_HEAD_INIT(name) { (name), (name) } #define LIST_HEAD(name) \ struct list_head name LIST_HEAD_INIT(name)如果写下LIST_HEAD(my_list)相当于定义了一个叫my_list的struct list_head并且让它的next和prev都指向它自己。这就是空链表的表示一个孤零零的、自己环住自己的节点。初始化单个节点用INIT_LIST_HEADstatic inline void INIT_LIST_HEAD(struct list_head *list) { list-next list; list-prev list; }凡是准备嵌入结构体当成链表节点的成员插入链表之前必须先用INIT_LIST_HEAD初始化否则指针是乱值一加进去就会让整个链表崩掉。2.2 offsetof假设地址0上放着一个结构体container_of里头有一个关键的offsetof宏标准C里就有Linux内核给自己也实现了一份#define offsetof(TYPE, MEMBER) ((size_t) ((TYPE *)0)-MEMBER)这个宏的核心思路是把地址0强行转换成一个指向TYPE的指针然后取MEMBER成员的地址。因为起始地址是0所以取出来的地址值就是该成员在结构体中的字节偏移量。offsetof在C标准库stddef.h里就有平时写应用代码也常见到但在内核链表这个语境下它和container_of配合才是完整形态。2.3 container_of从内层指针减去偏移container_of做的事情一句话概括已知结构体成员指针ptr、结构体类型type、成员名member求结构体首地址。公式是结构体地址 成员地址 - 偏移量。类型上要做两步转换先把ptr强制转成char *因为按字节加减才能保证偏移量正确的字节数减完后再转成(type *)。代码里那一行const typeof(((type *)0)-member) *__mptr (ptr);的作用是让编译器检查指针类型匹配——防止你传错参数把一个完全不相干的指针塞进来。这是Linux风格的编译期检查不匹配直接编译报错不会留到运行时爆炸。我用一个生活化类比解释你把一个盒子放在一条刻度尺的40厘米处现在只看到40厘米这个刻度想知道盒子起点在哪。只要知道盒子起点到40厘米的偏移是5厘米就能反推出起点在35厘米处。container_of就是干这件事的只不过刻度是内存地址偏移是编译期就能确定的成员偏移。2.4 类型无关的宏设计list.h里面大量宏使用了typeof、offsetof这种编译期机制使得同一套代码可以通用于任何结构体。这就是C语言实现伪泛型的关键路线宏加结构体嵌套。没有运行时成本所有偏移在编译期就算死了也没有运行时类型信息全靠编译器静态检查。这也是为什么内核链表的头文件全是static inline函数和宏而不是放在.c文件里——它本来就是为了让每个编译单元都能按需实例化。3. list.h的日常操作插、删、改、查的源码级拆解3.1 四种插入list_add和list_add_tail到底往哪边装list.h最常用的插入接口有两个static inline void list_add(struct list_head *new, struct list_head *head) { __list_add(new, head, head-next); } static inline void list_add_tail(struct list_head *new, struct list_head *head) { __list_add(new, head-prev, head); }list_add是头插新节点插在head之后所以新节点成为链表第一个元素list_add_tail是尾插新节点插在head之前成为链表最后一个元素。因为是循环链表头插和尾插本质上是同一个操作的两面插在head-next还是head-prev。核心的__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-next再改new-prev最后改prev-next。这四步顺序不能乱否则在并发场景下会出现中间状态指针指向未初始化节点。单线程下顺序无所谓但内核从第一天起就要考虑并发所以这个顺序是刻在骨子里的规范。3.2 删除节点list_del的两个关键细节static inline void list_del(struct list_head *entry) { __list_del(entry-prev, entry-next); entry-next LIST_POISON1; entry-prev LIST_POISON2; }做的事情就两步把entry的前后节点互相连接绕开自己然后把entry的指针改成毒化值LIST_POISON1/2让任何误用已删除节点的情况快速触发页错误暴露bug。这个毒化指针的设计细节非常值得学习——主动制造崩溃比静默的内存破坏要好排查得多。还有一个常用变体list_del_init删完之后把entry重新初始化成自环这样该节点之后可以重新入链。内核里很多临时节点比如工作队列里的work都用它。3.3 判断空链表别看next看指向static inline int list_empty(const struct list_head *head) { return READ_ONCE(head-next) head; }空链表的判据是head-next head。注意它要求链表确实用INIT_LIST_HEAD或LIST_HEAD初始化过否则这个判断毫无意义。还有一个list_empty_careful考虑到了删除节点但还没重链的中间状态用于并发保护比较多。3.4 替换与搬移list_replace和list_movelist_replace(old, new)用new替换链表里的old适合在遍历中做节点升级时用list_move(new, head)把节点从当前位置摘除再插到head后面list_move_tail则是插到末尾。这些函数内部都是先list_del再list_add两件套只不过把中间状态处理好了。3.5 遍历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是外层结构体指针你来定义head是链表头member是list_head在外层结构体里的成员名。拿上面的struct task_node举例遍历就是struct task_node *pos; list_for_each_entry(pos, my_list, list) { // 此时pos已经是完整的task_node指针直接用pos-pid、pos-name }第一次看到这个宏的人往往会问pos的初始值从哪来答案是list_first_entry(head, typeof(*pos), member)它就是container_of(head-next, typeof(*pos), member)。所以这个宏的本质就是不断用container_of从链表节点的指针反推外层结构体指针直到又绕回链表头。还有一个高频变体list_for_each_entry_safe多了个n参数遍历同时允许删除当前节点#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))原理很简单在进入循环体之前先把下一个节点的地址用n存好。就算你在循环体里把当前节点pos删了、释放了下一次循环用的n已经保存了不至于踩到被释放的内存。凡是循环体里可能删除当前节点的一律用safe版本——这条应该刻在肌肉记忆里。4. 把内核链表搬进普通C工程一个完整可编译的实操示例纸上谈兵没意思。我在实际工作中发现一个很好的应用场景写Linux用户态网络代理或嵌入式管理程序时经常要维护一堆动态注册的服务节点频繁增删查。这套内核链表完全可以在用户态直接用只要把list.h里的依赖剥出来就行。4.1 场景设定一个动态设备节点管理器假设我们在写一个用户态守护进程需要动态维护注册上来的设备节点设备有id、name、state三个字段要求支持注册新设备尾插按id查找设备按id删除设备遍历打印当前所有设备4.2 完整代码实现为了不依赖内核头文件我把最核心的宏和函数手动实现了一遍真正项目里直接#include linux/list.h在用户态不一定可用因为内核头文件依赖很多内核专属定义我这里模拟的是抽取后自包含的版本#include stdio.h #include stdlib.h #include string.h /* ---------- 内核链表核心定义 ---------- */ #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)); }) struct list_head { struct list_head *next, *prev; }; #define LIST_HEAD_INIT(name) { (name), (name) } #define LIST_HEAD(name) struct list_head name LIST_HEAD_INIT(name) static inline void INIT_LIST_HEAD(struct list_head *list) { list-next list; list-prev list; } static inline int list_empty(const struct list_head *head) { return head-next head; } 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; } static inline void list_add_tail(struct list_head *new, struct list_head *head) { __list_add(new, head-prev, head); } 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 NULL; entry-prev NULL; } #define list_first_entry(ptr, type, member) \ container_of((ptr)-next, type, member) #define list_next_entry(pos, member) \ container_of((pos)-member.next, typeof(*(pos)), member) #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)) #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)) /* ---------- 业务数据结构 ---------- */ #define NAME_LEN 32 struct device_info { int id; char name[NAME_LEN]; int state; /* 0: offline, 1: online */ struct list_head list; }; /* ---------- 业务操作 ---------- */ static struct device_info *find_device(struct list_head *head, int id) { struct device_info *pos; list_for_each_entry(pos, head, list) { if (pos-id id) return pos; } return NULL; } static void add_device(struct list_head *head, int id, const char *name) { struct device_info *dev malloc(sizeof(*dev)); if (!dev) { perror(malloc); exit(EXIT_FAILURE); } dev-id id; snprintf(dev-name, NAME_LEN, %s, name); dev-state 1; INIT_LIST_HEAD(dev-list); list_add_tail(dev-list, head); } static void remove_device(struct list_head *head, int id) { struct device_info *pos, *n; list_for_each_entry_safe(pos, n, head, list) { if (pos-id id) { list_del(pos-list); free(pos); printf([removed] id%d\n, id); return; } } printf([warn] device id%d not found\n, id); } static void dump_all(struct list_head *head) { struct device_info *pos; int cnt 0; printf(---- device list ----\n); list_for_each_entry(pos, head, list) { printf( [%d] name%-10s state%s\n, pos-id, pos-name, pos-state ? online : offline); cnt; } printf(total: %d\n, cnt); printf(---------------------\n); } int main(void) { LIST_HEAD(device_list); add_device(device_list, 1, eth0); add_device(device_list, 2, eth1); add_device(device_list, 3, wlan0); dump_all(device_list); struct device_info *dev find_device(device_list, 2); if (dev) printf(found: id%d name%s\n, dev-id, dev-name); else printf(id2 not found\n); remove_device(device_list, 2); dump_all(device_list); return 0; }4.3 编译运行与结果验证用gcc直接编译$ gcc -Wall -o list_demo list_demo.c运行输出---- device list ---- [1] nameeth0 stateonline [2] nameeth1 stateonline [3] namewlan0 stateonline total: 3 --------------------- found: id2 nameeth1 [removed] id2 ---- device list ---- [1] nameeth0 stateonline [3] namewlan0 stateonline total: 2 ---------------------代码逻辑全部符合预期。这里的重点是find_device和dump_all完全不关心struct device_info除了list成员之外的字段变化你往结构体里加字段删字段遍历和查找逻辑一行都不用改。这就是链表逻辑和数据解耦最直观的体现。4.4 移植到嵌入式Linux的注意点如果你做嵌入式Linux、要写内核模块大部分list.h接口可以直接#include linux/list.h使用。和上面的自包含版本相比真正的内核版多了一些针对SMP的防护READ_ONCE、WRITE_ONCE、RCU相关变体list_add_rcu、list_for_each_entry_rcu以及hlist这种哈希链表变体。嵌入式环境里使用这套链表我建议注意三点内核模块里分配节点用kmalloc释放用kfree别混用用户态的malloc和free。中断上下文、软中断里操作链表要用spinlock保护进程上下文才适合用mutex。list.h本身不提供锁锁全靠调用者自己把握。遍历大链表时别在持锁状态下做耗时操作尽量先摘节点拷贝关键数据再释放锁处理。RCU变体就是为这种场景设计的但RCU语义复杂新手先拿自旋锁把正确性保证住。5. 实战踩坑空链表、遍历删除、类型强转和内存管理5.1 坑点一谁申请的内存谁负责释放list_del只是把节点从链表里摘出去它不会释放内存。新手最常见的错误删除之后忘记free导致内存泄漏或者free完了还继续用链表里的指针导致悬垂指针崩溃。内核对这套内存所有权划定得很清楚链表只负责组织关系不负责生命周期。所以我在工程里习惯做什么呢删除节点的函数里永远是先list_del再free并把pos指针置空list_del(pos-list); free(pos); pos NULL; /* 防止后面误用 */释放之后置空是防御性编程的习惯。内核里毒化指针用的也是同一思路——让问题在第一时间暴露而不是藏在角落里。5.2 坑点二遍历中删除节点必须用safe版本我见过很多次线上崩溃都栽在这一条上。逻辑很简单list_for_each_entry在每次迭代结束时需要靠pos-member.next来取下一个节点。如果循环体里把这个节点从链表中摘除甚至释放了下一次迭代访问pos-member.next时访问的就是无效内存。仔细看这两种遍历的差异普通版走进循环体时并没有保存下一个节点的地址safe版在进入前就用n保存了。所以在循环体里你敢随便删、随便改反正下一轮靠n恢复现场。注意safe版本只是让你安全删除当前节点并不是让你安全释放完还能继续读pos-name之类的字段。释放之后就别再用pos访问任何内容了。5.3 坑点三结构体第一个成员是list_head时有个经典强转有一种写法是把list_head放在结构体第一个成员struct list_node { struct list_head list; void *data; };这种情况下container_of计算出的偏移是0所以(struct list_node *)list_ptr是成立的。于是有些人会贪图方便顺手直接强转((struct list_node *)entry)。如果list_head恰好不是第一个成员这种强转就是灾难——拿到的地址不等于结构体首地址访问后面字段全是错位数据。我在一个同事的代码里见过类似的事他在结构体里先放了int type再把list_head放后面结果很多地方直接强转导致type字段读到的是链表指针的低32位。排查了很久最后用offsetof一算才发现偏移根本不是0。正确做法永远只有一个用container_of或list_entry宏让编译器帮你做偏移运算。5.4 坑点四误用list_empty判断只有一个节点list_empty只回答链表是不是空的。想判断链表是不是只有一个节点得看head-next-next head。很多人在做批量删除最后一个节点这类逻辑时容易把非空当成至少一个然后假设head-next必然存在。如果正好链表是空的head-next head你访问head-next-xxxx时实际上访问的是链表头自己逻辑上可能绕进死循环。5.5 面试里常见追问方向这个知识点在很多Linux面试题里高频出现我整理了几个典型的追问方向供参考list_head为何不带数据域答解耦链表操作与数据结构用宏实现类型无关复用同一套增删查改代码。container_of的原理是什么答成员指针减偏移量偏移量由offsetof在编译期算出。什么是双向循环链表的优势答头节点无特殊性、插入删除O(1)、首尾判断统一。遍历过程中删除节点为什么必须用safe版本答普通版本下一次迭代依赖当前节点的next成员删除后该指针失效。list_del之后为什么指针要置为毒化值答让误用快速崩溃避免静默内存破坏延长排查周期。如何实现遍历时按条件删除所有节点答用list_for_each_entry_safe删除当前节点后用n继续。这些高频问题的根源其实都指向同一个理解list_head不是链表里的节点而是嵌入数据里的链接装置。6. 这套设计的工程启示C语言的伪泛型范式最后聊点超出链表怎么用本身的东西。我在嵌入式Linux项目的代码评审里经常看到有人把增删查改写成一份1000行的通用链表库什么链表头带计数器、节点带数据指针、还可以按传入函数比较——搞出一套所谓更友好的封装。我不反对封装但如果你理解了内核这份设计会发现其实不需要为每一种结构体各写一套链表操作函数也不需要搞一个万能节点来容纳各种类型。内核链表的做法实际上是组合而不是继承把链表这个横切关注点作为结构体成员嵌入再用container_of还原整体。这种模式在用户态工程里完全可以借用。我在一个网络配置管理程序里维护了struct interface、struct route、struct vlan三类对象三套链表全部复用了同一套list_head操作查找函数甚至可以直接宏生成。代码量减少了差不多一半而且行为高度一致新同事上手非常快。这个模式的另一个好处是内存布局更紧凑。教科书里的链表节点每个节点单独存一份next/prev外加一个数据指针数据本身被指向。内核这种嵌入式的做法链表指针和数据在同一个结构体里连续存放对缓存的友好程度更好。性能敏感型路径上这个差异是能测出来的。当然它也不是没缺点。最大的缺点在于container_of这种从成员反推对象的魔法需要脑子的偏移计算读代码时心里不够亮堂的人容易绕晕。另外宏的写法对调试不友好断点打在宏展开的代码上GDB里看到的行号和源码对不上。这些我都踩过但不妨碍它成为C语言里最优雅的数据结构设计之一。如果还想继续深挖建议读一下list.h原版源码之外的hlist——哈希链表。它把head做成只有一个next指针的瘦结构用于哈希桶这种头节点数量巨大且通常为空的场景节省一半内存。这个变体展现了同样的设计哲学在不同约束下的取舍读懂了它你对Linux的数据结构设计能力会再上一个台阶。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑