浅析Linux Kernel中的那些链表
浅析Linux Kernel中的那些链表
链表是Linux内核中最重要的数据结构,但Linux内核中的链表与传统的数据结构书中看到的链表结构略有不同。这里简单写一下我对于Linux内核中链表的理解,不足之处欢迎路过的大牛给出批评意见。
1.传统形式的链表
数据结构书中的链表一般是下面这种形式:
struct list { struct list *pre; struct list *next; void *data; };
每一个链表结构中都包括两个同类型的指针,分别指向链表的上一个节点和下一个节点。这样当该节点处于一个循环链表中时,链表的首节点一般不用于保存数据,但首节点也需要是一个struct list类型,当结构庞大时首节点也必须分配同样大小的空间,这样就造成了内存的浪费。
2. Linux内核中的双向循环链表
Linux内核中定义了一个struct list_head类型用于保存链表信息指针:
struct list_head { struct list_head *next, *prev; };
如此在链表中链表头只需用一个struct list_head类型来表示即可,不管链表中节点的数据结构多么庞大,链表头只需要占8个字节,链表中的节点则如下定义:
struct list { struct list_head list_head; void *data; };
如此一来类似于list_add()和list_del()这些函数只需要接收struct list_head类型的参数即可,不失通用性。
在链表这种结构中最好玩的地方在于对于这种形式的链表的遍历,Linux中定义的以下的宏:
#define __list_for_each(pos, head) \\ for (pos = (head)->next; pos != (head); pos = pos->next)
这个宏其实没什么神奇的地方,很典型的对于循环链表的迭代,但对于Linux内核中的链表,迭代的指针只能是一个struct list_head类型,对于这个类型来说我们只能对它做一些移动或者添加的操作,并不能取出该list_head对应的节点。这个迭代过程对于节点的删除操作其实是不安全的,假设我们在迭代中移除了pos节点,则进入下一次迭代时,pos = pos->next这个就不知会指向哪里去了,Linux中也定义了对于删除安全的迭代宏:
#define list_for_each_safe(pos, n, head) \\ for (pos = (head)->next, n = pos->next; pos != (head); \\ pos = n, n = pos->next)
这个宏中使用了n这个struct list_head类型的临时变量,每次迭代之后pos的下一个节点储存在临时变量n中,则在迭代中删除pos节点后,下一次迭代会重新给pos赋值为临时变量n,然后再做迭代。这样在迭代的过程中就可以安全地删除节点pos了。
刚才说过这种迭代每次迭代的变量都只是一个struct list_head类型,而更多时候我们遍历一个链表是为了读取或者修改链表的节点数据,这个时候我们就需要用到另外一种宏:
#define list_for_each_entry(pos, head, member) \\ for (pos = list_entry((head)->next, typeof(*pos), member); \\ prefetch(pos->member.next), &pos->member != (head); \\ pos = list_entry(pos->member.next, typeof(*pos), member))
在这个宏里面每次迭代pos所指向的都是struct list_head所对应的节点对象,该节点是通过list_entry()这个宏来获取的,接下来看它的定义:
#define list_entry(ptr, type, member) \\ container_of(ptr, type, member)
继续看下去就是container_of()这个宏,该宏在/include/linux/kernel.h里面定义。
#define container_of(ptr, type, member) ({ \\ const typeof( ((type *)0)->member ) *__mptr = (ptr); \\ (type *)( (char *)__mptr - offsetof(type,member) );})
该宏的作用就是返回一个结构体中的某成员变量对应的包含它的结构体的指针。在这里ptr这个参数便是一个struct list_head类型,type为包含这个struct list_head成员变量的结构体的类型,member为ptr这个参数作为成员变量的变量名。
第一句话的作用比较有意思,它跟offsetof()这个函数采用了同样的方法,通过欺骗编译器,告诉它在内存地址0处有一个type类型的结构,然后取出member的数据类型(当然,在这里我们要讨论member是个struct list_head类型,该函数不失通用性),定义一个该类型的临时指针让它指向ptr。第二句话将_mptr转换成一个char*指针(大家都知道char是一个字节,当我没说),通过offsetof()宏取出member在结构体中的节字偏移,也就是_mptr在它的父结构体中的字节偏移,于是_mptr减去它偏移的字节便是它的父结构体的内存地址,做一下指针类型转换便得到了父结构体的地址,很有趣哈。
既然说到了这里,不防也看一下offsetof()这个函数的实现,可通过man 3 offsetof命令查看offsetof函数的介绍,这里我们简单看下它的实现。
#define offsetof(TYPE, MEMBER) ((size_t) &((TYPE *)0)->MEMBER)
这一句话就可以搞定,TYPE为父类型的类型名,MEMBER为子类型的变量名,通过欺骗编译器在内存地址0处有一个TYPE类型的对象,然后强制转换成TYPE类型,取出MEMBER的地址,这个内存地址便是相对于0地址的偏移字节数,刚才我们已经假设TYPE类型在0地址处,这样取出的内存地址便是子类型在父类型中的偏移字节数,整个过程实际上就是对编译器的欺骗,我们并没有对这些不合法的内存地址进行写操作,所以该宏是安全的。
3. 用于散列表中的双向链表。
众所周知,在哈希节点冲突的时候需要把冲突的节点存储在链表中,如此说来便会有大量的链表存在(例如HASH路由表),这样链表头的8个字节对于Linux开发者来说可能也会成为一种负担,于是他们设计了另外一种链表,用来将链表头缩减为只有一个字节,看来面这种定义:
/* 链表头 */ struct hlist_head { struct hlist_node *first; }; /* 链表节点 */ struct hlist_node { struct hlist_node *next, **pprev; };
链表头只有一个指向第一个节点的指针,链表节点分别有指向下一个和上一个节点的指针,如此上一个节点便不能和下一个节点使用相同的类型,因为第一个节点的上一个节点是一个struct hlist_head类型而不是hlist_node类型,于是这里巧妙地使用了指向上一个节点的next指针的地址作为上一个节点的指针,我们知道在获取上一个节点的时候一般是为了对它进行插入操作,而插入操作只需要操作上一个节点的next指针(hlist_head和hlist_node的指向下个节点的指针类型相同,这样便可以在插入和删除操作对于首节点和普通节点不失通用性了),而不需要操作pre指针,于是这种设计便足够使用了,为上一个节点的next指针赋值时只需要为*(node->pprev)赋值始可。参考如下函数:
/* next must be != NULL */ static inline void hlist_add_before(struct hlist_node *n, struct hlist_node *next) { n->pprev = next->pprev; n->next = next; next->pprev = &n->next; *(n->pprev) = n; }
对于hlist链表操作的方法与list操作的方法大同小异,只不过在include/linux/list.h中没有定义对hlist逆向迭代的方法。