数组和链表之争,几乎是每个写C语言的人早晚都要面对的一道坎。我见过不少刚入行的同学,能把“数组连续、链表离散”背得滚瓜烂熟,但真到写代码的时候,要么把链表用成了数组,要么被Linux内核里的list_head搞得一头雾水。这篇东西,我想把这些年折腾双向链表和内核链表的经验一次性讲透,不只是给结论,更要把背后的设计逻辑和踩过的坑都交代清楚。
文章适合三类人看:正在准备面试的开发者(Linux内核链表是高频考点)、做嵌入式或驱动开发的朋友(内核链表是基本功)、以及那些觉得“链表懂了但又没完全懂”的C语言爱好者。看完你至少能收获三样东西:解决“数组还是链表”的判断框架、能默写出来的双向链表操作、以及一套可以直接抄走用的内核风格通用链表实现。
1. 从本质说起:数组和双向链表的分岔路
1.1 数组的底层逻辑:连续内存带来的快与痛
数组最核心的特征就一句话:元素在内存里是紧挨着排的。这个特征决定了它的一切优点和缺点。
优点大家都很熟:随机访问是O(1),因为a[i]的地址就是a + i * sizeof(T),一条乘法指令直接算出来,不需要任何遍历。CPU缓存也友好得很,顺序遍历的时候,硬件预取器能把后面几十个元素一次性捞进缓存,实际跑起来比理论上的O(n)还要快上一大截。
缺点也很致命:插入和删除要搬动后续所有元素。往数组中间塞一个数,平均要移动n/2个元素;扩容的时候更麻烦,realloc一旦涉及到新地址,整个数组都要拷贝一遍。如果你在维护一个频繁增删的队列,用数组做就是灾难现场。
还有一个隐藏很深的坑:数组名会退化成指针。这个特性让无数C语言新手栽过跟头——以为sizeof(arr)能拿到数组大小,结果在函数参数里拿到的是8(一个指针的大小)。多维数组更是如此,int a[3][4]作为参数传递时,第二维必须显式给出,否则编译器根本没法计算行的偏移量。
1.2 双向链表的出现:牺牲随机访问换灵活性
如果数组的问题是“挪动成本太高”,那链表的思路就是“尽量不挪”。每个节点不要求内存连续,各自独立申请,节点之间用指针串起来。单向链表只有next指针,找前驱得从头遍历;双向链表加了一个prev指针,前后都能走,删除节点的时候不用遍历找前驱,这是它对单向链表最大的优势。
代价当然也有。每个节点多存一个指针,在64位系统上是8字节的额外开销;访问第n个节点必须从头一步步走,O(n)的随机访问和数组的O(1)没法比;节点分散在堆的不同地址,遍历时CPU缓存命中率低,Cache Miss一多,实际性能可能比数组差一个数量级。
1.3 一张表看明白两者的取舍
| 维度 | 数组 | 双向链表 |
|---|---|---|
| 内存布局 | 连续 | 离散 |
| 随机访问 | O(1) | O(n) |
| 插入/删除(已知位置) | O(n),需搬移 | O(1),改指针 |
| 缓存友好度 | 高 | 低 |
| 额外内存开销 | 几乎无 | 每节点至少一个指针 |
| 扩容 | 需要拷贝或realloc | 动态分配,无整体搬迁 |
| 适用场景 | 频繁查询、数据量稳定 | 频繁增删、数据量不确定 |
这个表不是我随便画的,是实际性能测试得出来的结论。我见过一个真实的优化案例:某个消息队列模块用数组实现,巅峰时每秒要处理上万条消息,每次入队都要memmove好几百个字节,CPU占用高得离谱。后来改成双向链表,入队出队只改几个指针,CPU占用直接降了40%。但也见过反例:一个频繁遍历查找的场景,链表改数组之后速度提升了近10倍,纯粹是因为缓存命中率的差距。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 双向链表的经典实现:那些必须刻在脑子里的指针操作
2.1 节点结构定义
先摆最基础的结构体:
c复制struct node {
int data;
struct node *prev;
struct node *next;
};
很多教材还会定义一个头指针(指向第一个节点)或者头节点(一个不存数据的哨兵节点)。这两种写法我都用过,推荐带头节点的版本:头节点的prev指向最后一个节点,next指向第一个节点,这样就构成了循环链表。好处是首尾插入删除的操作逻辑完全统一,不需要为“第一个节点特殊处理”写分支。
2.2 插入操作:别搞反指针的顺序
插入一个节点到pos节点之后,核心代码是这样的:
c复制static inline void insert_after(struct node *pos, struct node *new_node) {
new_node->next = pos->next;
new_node->prev = pos;
pos->next->prev = new_node;
pos->next = new_node;
}
顺序为什么重要?我之前带过一个实习生,他写的代码先把pos->next改了,然后再用pos->next->prev = new_node,这时候pos->next已经变成新节点了,结果就是新节点的prev被赋成了它自己,链表直接断掉。
记住一个原则:先更新new_node自身的两个指针,再去动前后两个节点的指针。只要new_node的指针还没接好,中间哪一步出错了链表还能练,一旦先改别人的再把新节点晾在半空,就彻底乱了。
在头部插入就是insert_after(head, new_node),尾部插入就是insert_after(head->prev, new_node),循环链表的好处在这体现得淋漓尽致——不需要为头尾单独写函数。
2.3 删除操作:双向链表的优势时刻
删除一个已知节点:
c复制static inline void delete_node(struct node *del) {
del->prev->next = del->next;
del->next->prev = del->prev;
free(del);
}
注意两个指针的更新顺序在这里无所谓,因为del自身被删掉后不再参与连接。但有一点坑:函数结束后del这个指针本身还在,而且del->next、del->prev里保存的还是原来的地址,形成了所谓的“悬空指针”。如果后续不小心用了这个指针,轻则逻辑错误,重则崩溃。稳妥的做法是在free之后把调用方的指针置NULL,或者干脆在删除节点时把del->prev和del->next都赋为NULL,这样一旦误用会更容易暴露问题。
2.4 遍历的边界条件
遍历循环链表的标准写法:
c复制for (struct node *p = head->next; p != head; p = p->next) {
// 处理节点
}
关键是终止条件p != head,回到头节点就算遍历结束。很多人第一次写会写成p != NULL,在循环链表里这就成了死循环。
如果要遍历过程中删除节点,不能直接free完就跑,因为p = p->next这行代码在p被释放之后才会执行,读已经释放的内存是未定义行为。得先把下一个节点保存下来:
c复制struct node *p = head->next;
while (p != head) {
struct node *tmp = p->next;
// 处理并使用 p
free(p);
p = tmp;
}
内核里专门提供了list_for_each_safe就是这个原因,后面会展开讲。
3. 从教科书链表到Linux内核链表:一场设计思想的革命
3.1 教科书链表的死穴:写不出通用的
前面写的struct node里直接放了int data,这套代码只能存int。第二天产品经理说数据库课程要存char*,你得重新写一套;再后来要存一个学生结构体,还得再写一套。C语言没有模板、没有泛型,教科书里那种“数据放节点里”的写法,注定没法通用。
那能不能用void *data?行是行,但每次用都要手动强转类型,而且void *指向的数据是单独malloc的,节点一个内存块、数据一个内存块,增删的时候得记得释放两个地方,漏一个就是内存泄漏。更麻烦的是,数据本身是栈上的局部变量时,void *指向的地址可能随时失效,这个方案用起来很别扭。
Linux内核的解法完全跳出了“数据放节点里”的框框:我不存业务数据,我就放两个指针。你有数据要管理,就把这个链表节点结构体塞进你的业务结构体里。整个链表只关心节点之间的连接关系,完全不管业务数据长什么样。
3.2 核心理念:小结构体嵌入大结构体
内核链表的节点结构体极简:
c复制struct list_head {
struct list_head *next, *prev;
};
它本身不携带任何数据。你的业务结构体这么写:
c复制struct student {
int id;
char name[64];
struct list_head list; // 链表节点嵌入其中
};
学生和学生的“连接关系”通过list这个字段串起来,而id、name这些业务字段只是挂在链表节点上的附庸。这和面向对象里的“组合”有相似之处,但更彻底——链表本身是一个独立的子系统,不依赖任何业务类型。
这个设计有什么好处?第一,一套链表代码万能复用,不管你的结构体里有3个字段还是30个字段,list_head只管它自己那部分。第二,内存管理简单,业务结构体一次malloc,list_head的指针就在里面,释放也一次搞定,不存在“节点活着但数据死了”的问题。
3.3 从节点地址逆推结构体地址:container_of
链表只管list_head,但遍历的时候我们最终要拿的是student结构体的数据,怎么从list_head的地址得到整个student的地址?这里就要靠container_of宏:
c复制#define container_of(ptr, type, member) ({ \
const typeof(((type *)0)->member) *__mptr = (ptr); \
(type *)((char *)__mptr - offsetof(type, member)); })
拆开来看就两步。第一步,算出member字段在type结构体里的偏移量offsetof(type, member);第二步,用链表节点指针减去这个偏移量,就得到整个结构体的起始地址。
offsetof宏本身也是经典:
c复制#define offsetof(TYPE, MEMBER) ((size_t) &((TYPE *)0)->MEMBER)
它把地址0强转成一个TYPE *,然后取MEMBER成员的地址,这个地址的值天然就是偏移量。这是个纯粹的编译期计算,不访问任何内存,所以不会崩溃。这个技巧在C语言里算得上“奇技淫巧”级别的经典,但在内核里就是常规操作。
container_of还用了一个GNU C的扩展语法:({ ... })语句表达式,整个块的值取最后一行的表达式。这个东西在标准C里没有,但gcc和clang都支持,Linux内核明确要求用GNU C编译,所以可以放心用。用户态写代码如果怕编译器不兼容,可以拆成两个宏分别算偏移量和地址,或者用__builtin_offsetof这个内置函数。
3.4 链表初始化与核心操作
初始化一个链表头:
c复制#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;
}
初始化后,头节点的next和prev都指向自己,表示空链表。判断空链表就一个条件:head->next == head。
插入操作用的是__list_add:
c复制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(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_head。删除操作也类似:
c复制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);
}
注意list_del之后,entry->next和entry->prev还是指向原来那两个节点,形成野指针。内核专门有个list_del_init,删除后把entry的指针重新指向自己,防止误用。在遍历中删节点时我强烈建议用list_del_init,这样即使后续代码误遍历到了这个节点,也能因为遍历终止条件pos == head而停下来,不会无限循环。
3.5 遍历宏:把“从链表节点到业务节点”的转换藏起来
拿到链表头之后怎么遍历出每个学生?看内核的list_for_each_entry宏:
c复制#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))
其中:
c复制#define list_first_entry(ptr, type, member) \
list_entry((ptr)->next, type, member)
#define list_entry(ptr, type, member) \
container_of(ptr, type, member)
#define list_next_entry(pos, member) \
list_entry((pos)->member.next, typeof(*pos), member)
用的时候是这样的:
c复制struct student *s;
list_for_each_entry(s, &student_list, list) {
printf("%d %s\n", s->id, s->name);
}
for循环三句话里,初始化是取出第一个业务节点,终止条件是回到链表头,迭代是让pos走到下一个业务节点。使用者完全不用管list_head的存在,也不用自己调container_of,宏把所有脏活累活都干完了。
这里有一个小陷阱:typeof(*pos)要求pos必须是结构体类型的指针,不能是void *。如果编译器提示隐式声明或者类型错误,先检查循环变量有没有声明成具体结构体指针。
4. 动手实操:自己实现一个内核风格通用链表
4.1 准备开发环境
不用真的进内核开发板就能练。普通的Linux环境加gcc就行,我用的是Ubuntu 22.04,命令如下:
bash复制gcc --version
mkdir -p ~/list_demo && cd ~/list_demo
vim list.h
vim main.c
gcc -o demo main.c && ./demo
如果用的是其它发行版也没关系,这套代码不依赖任何平台特性,纯C+GNU扩展,甚至Windows上用MinGW也能编译过。
4.2 手写一个精简版list.h
这里我写一个开发调试用的精简版,内核源码的完整版还包含list_for_each_safe、list_for_each_entry_reverse等一堆变体,先掌握核心的这几个:
c复制#ifndef __LIST_H
#define __LIST_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(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);
}
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);
}
static inline void list_del_init(struct list_head *entry) {
__list_del(entry->prev, entry->next);
INIT_LIST_HEAD(entry);
}
#define list_entry(ptr, type, member) container_of(ptr, type, member)
#define list_first_entry(ptr, type, member) \
list_entry((ptr)->next, type, member)
#define list_for_each(pos, head) \
for (pos = (head)->next; pos != (head); pos = pos->next)
#define list_for_each_safe(pos, n, head) \
for (pos = (head)->next, n = pos->next; pos != (head); \
pos = n, n = pos->next)
#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))
#endif
这段代码我加了list_empty和list_for_each_safe两个常用工具,实际工程里几乎必用。list_for_each_safe多一个n参数,提前保存下一个节点,就是为了遍历时能安全删除当前节点。
4.3 业务代码接入:用学生链表完整走一遍
c复制#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "list.h"
struct student {
int id;
char name[64];
struct list_head list;
};
int main(void) {
LIST_HEAD(student_list);
// 尾部插入3个学生
struct student *s;
int ids[] = {1001, 1002, 1003};
const char *names[] = {"zhangsan", "lisi", "wangwu"};
for (int i = 0; i < 3; i++) {
s = (struct student *)malloc(sizeof(*s));
s->id = ids[i];
snprintf(s->name, sizeof(s->name), "%s", names[i]);
list_add_tail(&s->list, &student_list);
}
// 遍历打印
printf("=== after add_tail ===\n");
list_for_each_entry(s, &student_list, list) {
printf("id=%d name=%s\n", s->id, s->name);
}
// 头插一个
s = (struct student *)malloc(sizeof(*s));
s->id = 1000;
snprintf(s->name, sizeof(s->name), "%s", "zhaoliu");
list_add(&s->list, &student_list);
printf("=== after list_add ===\n");
list_for_each_entry(s, &student_list, list) {
printf("id=%d name=%s\n", s->id, s->name);
}
// 删除id为1002的节点(演示遍历中删除)
struct student *tmp;
printf("=== delete id=1002 ===\n");
list_for_each_entry_safe(s, tmp, &student_list, list) {
if (s->id == 1002) {
list_del(&s->list);
free(s);
// 注意:free之后s本身还在,但不应再访问s->list
}
}
list_for_each_entry(s, &student_list, list) {
printf("id=%d name=%s\n", s->id, s->name);
}
// 清空链表
printf("=== cleanup ===\n");
list_for_each_entry_safe(s, tmp, &student_list, list) {
list_del(&s->list);
free(s);
}
if (list_empty(&student_list)) {
printf("list is empty\n");
}
return 0;
}
编译运行:
bash复制gcc -o demo main.c
./demo
输出如下(节选):
text复制=== after add_tail ===
id=1001 name=zhangsan
id=1002 name=lisi
id=1003 name=wangwu
=== after list_add ===
id=1000 name=zhaoliu
id=1001 name=zhangsan
...
整个流程走下来,你再回头看内核源码里的那些宏,就不会觉得它们神秘了。本质就是一堆指针操作+一个地址换算,内核不过是把这两个能力封装成了优雅的接口。
4.4 内存布局验证:用地址打印加深理解
为了让大家彻底理解“结构体内嵌list_head”的内存布局,我加一段打印:
c复制s = (struct student *)malloc(sizeof(*s));
printf("student addr = %p\n", s);
printf("list field addr = %p\n", &s->list);
printf("offset = %lu\n", (unsigned long)((char *)&s->list - (char *)s));
运行后你会发现两个地址差了一个固定值。在你的机器上,如果struct student包含int id和char name[64],这个偏移量通常是68(4字节int+64字节name,没有额外对齐的话)。container_of干的事情,就是把&s->list往回挪这68个字节,找回student的头。这也提醒了结构体字段顺序会影响offsetof的大小,如果你以后用__attribute__((packed))或者遇到对齐,记得先确认偏移量是否符合预期。
5. 踩坑实录与排查技巧
5.1 遍历中删除节点导致崩溃
这个坑我几乎每次教学都会被问。直接看现象:用list_for_each_entry遍历时删除当前节点,然后继续迭代,程序崩了或者死循环。
原因很简单:list_for_each_entry在迭代时会用pos->member.next作为下一个位置,但这个next已经被list_del改掉了,而且节点可能已经被free。读已释放内存是未定义行为。
解决办法就一个:用list_for_each_entry_safe,它用临时变量提前存好下一个位置。内核里凡是遍历删除的场景都强制要求safe版本,不是没有道理的。
5.2 拿头节点当业务节点
我刚用内核链表时犯过这个错:遍历时对链表的每个list_head都调container_of,结果从头节点开始,container_of把内存偏移量往前算,算出来一个根本不存在的结构体指针,读了垃圾数据。
正确做法是:list_for_each_entry起始就是第一个业务节点,内部已经跳过了头节点。手工写遍历循环时,也必须先判断pos != head再用list_entry。如果遍历中发现某些节点的数据乱掉,先检查是不是把head算进去了。
5.3 结构体对齐和packed的影响
offsetof依赖编译器的布局规则,默认情况下结构体成员按自然对齐排列。如果你的结构体里混了char和int,中间可能被编译器插入填充字节。大多数时候这没问题,因为offsetof算的就是实际布局,但如果你用了#pragma pack或者__attribute__((packed)),成员偏移量会改变,container_of返回的地址仍然是对的,因为它用的offsetof是编译期算好的当前布局。
真正的坑在于:如果你把一个结构体通过某种协议写进文件或者通过网络发到另一端,而两端编译器对结构体布局的处理不一致,那offsetof的值就会不同,反序列化时数据就会错位。这类问题不只在链表里出现,是所有结构体二进制序列化方式的通病。遇到这种场景,要么明确约定对齐方式,要么用序列化库,别手搓结构体直接传输。
5.4 到底什么时候用数组,什么时候用链表
根据我这些年维护多个项目的经验,决策顺序可以这样来:
第一看操作模式。查询远多于增删,用数组;增删频繁且位置随机,用链表。第二看数据量级。几百个元素以内,数组随便造,链表反而因为malloc开销更慢;几十万以上且需要频繁插入删除,链表优势才明显。第三看分配约束。实时性要求极高的场景,链表频繁malloc可能导致延迟抖动,干脆用固定大小的数组池子。第四看内存限制。每个链表节点多8字节指针,百万级节点就是8MB额外开销,嵌入式场景要算清楚。
有个实际的项目例子:之前做的一个嵌入式配置管理模块,数据结构早期用的是双向链表,因为要频繁增删配置项。后来发现配置项总数最多不超过128个,而且系统初始化后几乎不再变化,但查询开销很高。我把链表改成数组+二分查找,启动时间差不多,查询快了30多倍,内存占用还少了。反过来,某个会话管理模块,会话数动态增长、随时创建销毁,数组方案频繁realloc导致内存碎片化严重,换链表之后稳定多了。
所以选数组还是链表,不是“谁先进用谁”,而是“谁更匹配业务特征”。理解了这个,面试官再怎么变换问法你都能接住。
再补充一个内核里常见的技巧:hlist(哈希链表)。它针对哈希表场景做了优化,表头只保留next指针(因为哈希桶不需要反向遍历),比普通list_head省了一半内存。如果你在做嵌入式或驱动开发,遇到哈希表场景优先考虑hlist而不是普通链表,这也是内核设计者推荐的做法。标准链表和哈希链表的取舍,本质上还是“空间换时间”在不同场景下的变体。
关于内核链表,我最后想强调的一点是:读源码别只读API,要读设计动机。为什么__list_add分三步?为什么遍历要分entry级和node级?为什么要有_safe变体?这些琐碎的细节,背后全是对边界情况和并发风险的精确管理。我当年花了一个周末把list.h从头到尾注释了一遍,之后的面试和项目里遇到链表问题基本不再翻车。你现在拿到的这套精简版,就是那个周末沉淀下来的核心子集,建议你也找一个下午,照着源码注释一遍,收获绝对对得起那点时间。
