Linux内核链表——看这一篇文章就够了

本文从最基本的内核链表出发,引出初始化INIT_LIST_HEAD函数,而后介绍list_add,经过改变链表位置的问题引出list_for_each函数,而后为了获取容器结构地址,引出offsetof和container_of宏,并对内核链表设计缘由做出了解释,一步步引导到list_for_each_entry,而后介绍list_del函数,经过在遍历时list_del链表的不安全行为,引出list_for_each_entry_safe函数,经过本文,我但愿读者能够获得以下三个技能点:缓存

1.可以熟练使用内核链表的相关宏和函数,并应用在项目中;安全

2.明白内核链表设计者们的意图,为何要那样去设计链表的操做和提供那样的函数接口;数据结构

3.可以将内核链表移植到非GNU环境。ide

 

大多数人在学习数据结构的时候,链表都是第一个接触的内容,笔者也不列外,虽然本身实现过几种链表,可是在实际工做中,仍是Linux内核的链表最为经常使用(同时笔者也建议你们使用内核链表,由于会了这个,其余的都会了),故总结一篇Linux内核链表的文章。函数

阅读本文以前,我假设你已经具有基本的链表编写经验。性能

 内核链表的结构是个双向循环链表,只有指针域,数据域根据使用链表的人的具体需求而定。内核链表设计哲学:学习

既然链表不能包含万事万物,那么就让万事万物来包含链表。测试

假设以以下方式组织咱们的数据结构:fetch

 建立一个结构体,并将链表放在结构体第一个成员地址处(后面会分析不在首地址时的状况)。spa

 1 #include <stdio.h>
 2 #include <stdlib.h>
 3 
 4 #include "list.h"
 5 
 6 struct person 
 7 {
 8     struct list_head list;
 9     int age;
10 };
11 
12 int main(int argc,char **argv)
13 {
14     int i;
15     struct person *p;
16     struct person person1;
17     struct list_head *pos;
18     
19     INIT_LIST_HEAD(&person1.list);
20 
21     for (i = 0;i < 5;i++) {
22         p = (struct person *)malloc(sizeof(struct person ));
23         p->age=i*10;
24         list_add(&p->list,&person1.list);
25     }
26 
27     list_for_each(pos, &person1.list) {
28         printf("age = %d\n",((struct person *)pos)->age);
29     }
30     
31     return 0;
32 }

咱们先定义struct person person1;此时person1就是一个咱们须要使用链表来连接的节点,使用链表以前,须要先对链表进行初始化,LIST_HEAD和INIT_LIST_HEAD均可以初始化一个链表,二者的区别是,前者只须要传入链表的名字,就能够初始化完毕了;然后者须要先定义出链表的实体,如前面的person1同样,而后将person1的地址传递给初始化函数便可完成链表的初始化。内核链表的初始化是很是简洁的,让前驱和后继都指向本身。

完成了初始化以后,咱们能够像链表中增长节点,先以头插法为例:

 

 list_add函数,能够在链中增长节点,改函数为头插法,即每次插入的节点都位于上一个节点以前,好比上一个节点是head->1->head,本次使用头插法插入以后,链表结构变成了 head->2->1->head。也就是使用list_add头插法,最后第一个插入的节点,将是链表结构中的第一个节点。

 list_add函数的实现步骤也很是简洁,必定要本身去推演一下这个过程,O(1)的时间复杂度,就4条指针操做,不本身去推演一下这个过程你会少不少心得体会,尤为是在接触过其余的还要考虑头部和尾部特殊状况的链表以后,更会以为内核链表设计简洁的妙处。list_add函数的第一个参数就是要增长到头结点链表中的数据结构,第二个参数就是头结点,本例中为&person1.list。

本例中增长5个节点,头结点的数据域不重要,能够根据须要利用头结点的数据域,通常而言,头结点数据域不使用,在使用头结点数据域的状况下,通常也仅仅记录链表的长度信息,这个在后面咱们能够本身实现一下。

在增长了5个节点以后,咱们须要遍历链表,访问其数据域的内容,此时,咱们先使用list_for_each函数,遍历链表。

 该函数就是遍历链表,直到出现pos == head时,循环链表就编译完毕了。对于其中的prefetch(pos->next)函数,若是你是在GNU中使用gcc进行程序开发,能够不作更改,直接使用上面的函数便可;但若是你想把其移植到Windows环境中进行使用,能够直接将prefetch(pos->next)该条语句删除便可,由于prefetch函数它经过对数据手工预取的方法,减小了读取延迟,从而提升了性能,也就是prefetch是gcc用来提升效率的函数,若是要移植到非GNU环境,能够换成相应环境的预取函数或者直接删除也可,它并不影响链表的功能。

list_for_each的第一个参数pos,表明位置,须要是struct list_head * 类型,它其实至关于临时变量,在本例中,定义了一个指针pos, struct list_head *pos;用其来遍历链表。

能够遍历链表以后,那么就须要对数据进行打印了。

 本例中的输出,将pos强制换成struct person *类型,而后访问age元素,获得程序输出入下:

 能够发现,list_add头插法,果真是最后插入的先打印,最早插入的最后打印。

其次,为何笔者要使用printf("age = %d\n",((struct person *)pos)->age);这样的强制类型转换来打印呢?能这样打印的原理是什么呢?

如今回到咱们的数据结构:

struct person 
{
  struct list_head list;
  int age;
};

因为咱们将链表放在结构体的首地址处,那么此时链表list的地址,和struct person 的地址是一致的,因此经过pos的地址,将其强制转换成struct person *就能够访问age元素了。

前面说到,内核链表是有头结点的,通常而言头结点的数据域咱们不使用,但也有使用头结点数据域记录链表长度的实现方法。头结点其实不是必需的,但做为学习,咱们能够实现一下,了解其过程:

 1 #include <stdio.h>
 2 #include <stdlib.h>
 3 
 4 #include "list.h"
 5 
 6 struct person_head
 7 {
 8     struct list_head list;
 9     int len;
10 };
11 
12 struct person 
13 {
14     struct list_head list;
15     int age;
16 };
17 
18 int main(int argc,char **argv)
19 {
20     int i;
21     struct person *p;
22     struct person_head head;
23     struct list_head *pos;
24     
25     INIT_LIST_HEAD(&head.list);
26     head.len=0;
27 
28     for (i = 0;i < 5;i++) {
29         p = (struct person *)malloc(sizeof(struct person ));
30         p->age=i*10;
31         list_add(&p->list,&head.list);
32     }
33 
34     list_for_each(pos, &head.list) {
35         printf("age = %d\n",((struct person *)pos)->age);
36         head.len++;
37     }
38     printf("list len =%d\n",head.len);
39     
40     return 0;
41 }
View Code

 本例中定义了person_head结构,其数据域保存链表的长度,因为list_for_each会遍历链表,本例仅做为功能说明的实现,记录了链表的长度信息,并打印了链表长度。若是实际开发中须要记录链表的长度或者其余信息,应该封装成相应的函数,同时,增长节点的时候,增长len的计数,删除节点的时候,减小len的计数。

在笔者最先接触到将链表放在结构体第一个成员地址处时,以为Linux内核链表后面的container_of,offsetof宏为何如此多余,由于按照上面的方法,根本再也不须要container_of,offsetof这样的宏了,甚至当时还以为内核为何这么笨,还不更新代码(固然,这也是当时听了某个老师的课说现代的链表已经发展成为上面例子的状况,而内核链表处于不断发展的过程,并无使用这样最新的方式)。因此笔者在学生时代时学到这里就收手没有再继续下去了,由于我当时认为按照这样的方法就够用了。但是,当我进入到企业工做以后,我发现并非这样的,由于没有人能够保证链表能够放在结构体的第一个成员地址处,哪怕可以保证,那么在复杂数据结构中,有多个链表怎么办?哪怕你可以保证有一个链表位于结构体的首地址处,那其余的链表怎么办呢?直到那时,我才发现Linux内核那帮设计者们并不笨,而是本身当时的知识面太窄而且项目经验不足(这样一样证实了一个授课老师的知识水平,对学生的影响是很大的,固然,瑕不掩瑜,我心里仍是很是感谢当初那位老师的,只是,我须要更强大的力量了^_^)。内核链表设计者们,考虑到了不少状况下,咱们根本不能保证每一个链表都处于结构体的首地址,因此,也就出现了container_of,offsetof这两个广为人知的宏。

试想,若是将我上面代码中的person结构体位置更改一下:

 将链表不放置在结构体的首地址处,那么前面的代码将不能正常工做了:

由于此时强制类型转换获得地址再也不是struct person结构的首地址,进行->age操做时,指针偏移不正确。

 果真,运行以后代码获得的age值不正确,为了解决这一问题,内核链表的开发者们设计出了两个宏:

 咱们先来分析offsetof宏,其语法也是很是简洁和简单的,该宏获得的是TYPE(结构体)类型中成员MEMBER相对于结构体的偏移地址。可是,其中有一个知识点须要注意:为何((TYPE *)0)->MEMBER这样的代码不会出现段错误,咱们都知道,p->next,等价于(*p).next;那么((TYPE *)0)->MEMBER,不是应该等价于(*(TYPE *)0).MEMBER吗?这样不就出现了对0地址的解引用操做吗?为何内核使用这样的代码却没有问题呢?

为了解释这个问题,咱们先作一个测试:

 

 没有问题,如今咱们把for_test的参数改成NULL,看看会不会出现段错误:

 注意,此时传递给for_test的参数为NULL,同时为了显示偏移数,我将地址以%u打印,程序输出以下:

 你发现了什么?对,程序并无奔溃,并且获得了age和list在struct person中偏移量,一个为0,一个为8(笔者的Linux是64bit的)。为何传递NULL空指针进去,并无发生错误,难道是咱们以前学习的C语言有问题?

没有发生错误,是由于在ABI规范中,编译器处理结构体地址偏移时,使用的是以下方式:

 在编译阶段,编译器就会将结构体的地址以如上方式组织,也就是说,编译器去取得结构体某个成员的地址,就是使用的偏移量,因此,即便传入NULL,也不会出现错误,也就是说,内核的offsetof宏不会有任何问题。

 

 那么offsetof之因此将0强制类型转换,就是为了获得TYPE结构体中MEMBER的偏移量,最后将偏移量强制类型转换为size_t,这就是offsetof。那么为何要这样求偏移呢?前面说到了,想在结构体中获得链表的地址,怎么获得地址呢?若是咱们知道了链表和结构体的偏移量,那么即便链表不位于结构体首地址处,咱们也可使用链表了啊。

下面,咱们对container_of宏作解析:

 其中typeof是GNU中获取变量类型的关键字,若是要将其移植到Windows中,能够再添加一个参数解决,有兴趣的可自行实验。

如今咱们来看,第一句,其实第一句话没有也彻底不影响该宏的功能,可是内核链表设计者们为何要增长这个一个赋值的步骤呢?这是由于宏没有参数检查的功能,增长这个const typeof( ((type *)0)->member ) *__mptr = (ptr)赋值语句以后,若是类型不匹配,会有警告,因此说,内核设计者们不会把没用的东西放在上面。

如今咱们来讲一下该宏的三个参数,ptr,是指向member的指针,type,是容器结构体的类型,member就是结构体中的成员。用__mptr强制转换成char *类型 减去member在type中的偏移量,获得结果就是容器type结构体的地址,这也就是该宏的做用。你可能会想,type的地址不是直接取地址获得吗?为何还要这么麻烦使用这个宏呢?

要解答这个问题,咱们先来看一下这两个宏的应用场景。

前面说到在链表不放在结构体首地址时的问题,如今咱们使用内核链表的list_entry宏来解决这个问题:

 list_entry宏其实就是container_of。回忆前面咱们的问题:

 前面说到这里获取age是错误的,就是由于pos的地址不位于结构体首地址了,试想,若是咱们可以经过将pos指针传递给某个宏或者函数,该函数或者宏可以经过pos返回包含pos容器这个结构体的地址,那么咱们不就能够正常访问age了吗。很显然, container_of宏,就是这个做用啊,在内核中,将其又封装成了 list_entry宏,那么咱们改进前面的代码:

 如今运行以后,便可以获得正确的结果了。

 细心的读者可能发现了,为何以前我使用gcc编译时都加上了-std=c99,可是上图中并无使用c99标准,这也是须要注意的,此时使用c99标准进行编译或报错,至于出错缘由:

/* 在编译时加上-std=c99,使用c99标准,对内核链表进行编译,会报语法错误,那是由于c99并不支持某些gcc的语法特性,若是想在GNU中启用c99标准,可使用-std=gnu99,使用这个选项以后,会对gnu语法进行特殊处理,并使用c99标准 */

如今咱们对内核链表作分析:

使用list_entry以后,咱们能够获得容器结构体的地址,因此天然能够对结构体中的age元素进行操做了。前面说到,容器结构的地址,咱们直接使用取地址符&不就好了吗,为何还要使用这个复杂的宏list_entry去取地址呢?结合上面的应用场景,你想一想,此时你能容易取到容器结构体的地址吗?显然,在链表中,尤为是在内核链表这种没有数据域的链表结构中,获取链表的地址是容易的,可是获取包含链表容器结构的地址须要额外的存储操做,因此内核链表的设计者们设计出的list_entry宏,可谓精妙。

在上面的代码中,咱们使用:

这样的循环遍历链表,获取容器地址,取出相应结构体的age元素,内核链表设计者早已考虑到了这一点,因此为咱们封装了另外一个宏:list_for_each_entry

 

 list_for_each_entry,经过其名字咱们也能猜想其功能,list_for_each是遍历链表,增长entry后缀,表示遍历的时候,还要获取entry(条目),即获取链表容器结构的地址。该宏中的pos类型为容器结构类型的指针,这与前面list_for_each中的使用的类型再也不相同,不过这也是情理之中的事,毕竟如今的pos,我要使用该指针去访问数据域的成员age了;head是你使用INIT_LIST_HEAD初始化的那个对象,即头指针,注意,不是头结点;member就是容器结构中的链表元素对象。使用该宏替代前面的方法:

 运行结果以下:

 

 在此以前,咱们都没有使用删除链表的操做,如今咱们来看一下删除链表的内核函数list_del:

 

#include <stdio.h>
#include <stdlib.h>

#include "list.h"



struct person 
{
    int age;
    struct list_head list;
};

int main(int argc,char **argv)
{
    int i;
    struct person *p;
    struct person head;
    struct person *pos;
    
    INIT_LIST_HEAD(&head.list);

    for (i = 0;i < 5;i++) {
        p = (struct person *)malloc(sizeof(struct person ));
        p->age=i*10;
        list_add(&p->list,&head.list);
    }
    
    list_for_each_entry(pos,&head.list,list) {
        if (pos->age == 30) {
            list_del(&pos->list);
            break;
        }
    }
    
    list_for_each_entry(pos,&head.list,list) {
        printf("age = %d\n",pos->age);
    }
    return 0;
}
View Code

链表删除以后,entry的前驱和后继会分别指向LIST_POISON1和LIST_POISON2,这个是内核设置的一个区域,可是在本例中将其置为了NULL。运行结果以下:

 

 能够发现,正确地删除了相应的链表,可是注意了,若是在下面代码中不使用break;会发生异常。

 

 为何会这样呢?那是由于list_for_each_entry的实现方式并非安全的,若是想要在遍历链表的时候执行删除链表的操做,须要对list_for_each_entry进行改进。显然,内核链表设计者们早已给咱们考虑到了这一状况,因此内核又提供了一个宏:list_for_each_entry_safe

 使用这个宏,能够在遍历链表时安全地执行删除操做,其原理就是先把后一个节点取出来使用n做为缓存,这样在还没删除节点时,就获得了要删除节点的笑一个节点的地址,从而避免了程序出错。

 

 使用list_for_each_entry_safe宏,它使用了一个中间变量缓存的方法,实现更为安全的变量链表方法,其执行效果以下:

 

 对于内核链表的宏和函数而言,其语法都是很是简洁和简单的,就再也不具体分析每个语句的做用了,我相信读者也能轻松地阅读明白这些代码,在笔者以前的学习中,就是缺乏一个练习使用这些链表的过程,因此必定要本身去写一个程序推演一下整个过程。

list_del让删除的节点前驱和后继指向LIST_POISON1和LIST_POISON2的位置,本例中为NULL,内核同时提供了:

list_del_init

 根据业务须要,能够自行选择适合本身的函数。

 

如今,我再来讲另外一种插入方式:尾插法,若是原来是head->1->head,尾插法一个节点以后变成了head->1->2->head。

内核提供的函数接口为:list_add_tail

 咱们将前面代码的list_add改成list_add_tail以后,获得:

 

 对于多核系统上,内核还提供了list_add_rcu和list_add_tail_rcu等函数,其具体实现机制(主要是内存屏障相关的)须要根据cpu而定。

下面咱们介绍:list_replace,经过其名字咱们就能知道,该函数是替换链表的:

 1 #include <stdio.h>
 2 #include <stdlib.h>
 3 
 4 #include "list.h"
 5 
 6 
 7 
 8 struct person 
 9 {
10     int age;
11     struct list_head list;
12 };
13 
14 int main(int argc,char **argv)
15 {
16     int i;
17     struct person *p;
18     struct person head;
19     struct person *pos,*n;
20     struct person new_obj={.age=100}; 
21     
22     INIT_LIST_HEAD(&head.list);
23 
24     for (i = 0;i < 5;i++) {
25         p = (struct person *)malloc(sizeof(struct person ));
26         p->age=i*10;
27         list_add_tail(&p->list,&head.list);
28     }
29     /*
30     list_for_each_entry(pos,&head.list,list) {
31         if (pos->age == 30) {
32             list_del(&pos->list);
33             break;
34         }
35     }*/
36     
37     list_for_each_entry_safe(pos,n,&head.list,list) {
38         if (pos->age == 30) {
39             //list_del(&pos->list);
40             list_replace(&pos->list,&new_obj.list);
41             //break;
42         }
43     }
44     list_for_each_entry(pos,&head.list,list) {
45         printf("age = %d\n",pos->age);
46     }
47     return 0;
48 }
View Code

 

 因为list_replace没有将old的前驱和后继断开,因此内核又提供了:list_replace_init

 这样,替换以后会将old从新初始化,使其前驱和后继指向自身。显然咱们一般应该使用list_replace_init。

当项目中另一个地方处理完成一个同类型的节点数据时,能够直接使用list_replace_init替换想要处理的节点,这样能够再也不作拷贝操做。

 内核链表还提供给咱们:list_move

 有了前面的知识累积,咱们能够和轻松地明白,list_move就是删除list指针所处的容器结构节点,而后将其从新以头插法添加到另外一个头结点中去,head能够是该链表自身,也能够是其余链表的头指针。

 既然有头插法的list_move,那么也一样有尾插法的list_move_tail:

 将测试函数改成:

 注意,在这里lis_move和list_move_tail都有删除操做,可是这里却能够不使用list_for_each_entry_safe而直接使用list_for_each_entry,想一想这是为何呢?

这是由于move函数,后面有一个添加链表的操做,将删除的节点前驱后继的LIST_POISON1和LIST_POISON2(本例中为NULL),从新赋值了。

值得注意的是,若是链表数据域中的元素都相等,使用list_for_each_entry_safe反而会无限循环,list_for_each_entry却能正常工做。可是,在一般的应用场景下,数据域的判断条件不会是所有相同链表,例如在本身使用链表实现的线程中,经常使用线程名字做为move的条件判断,而线程名字确定不该该是相同的。因此,具体的内核链表API,须要根据本身的应用场景选择。list_for_each_entry_safe是缓存了下一个节点的地址,list_for_each_entry是无缓存的,挨个遍历,因此在删除节点的时候,list_for_each_entry须要注意,若是没有将删除节点的前驱后继处理好,那么将引起问题,而list_for_each_entry_safe一般不用关心,可是在你使用的条件判断进行move操做时,不该该使用各个节点可能相同的条件。

 有list_for_each_entry日后依次遍历,那么也有list_for_each_entry_reverse往前依次遍历:

 

测试代码以下:

 

 

 

 运行结果,一个日后遍历,一个往前遍历:

 一样,有安全的日后遍历:list_for_each_entry_safe,那么也有安全的往前遍历:list_for_each_entry_safe_reverse

 

 测试代码:

 运行结果和前面的一致。

另外一方面,内核链表还提供了获得第一个条目的宏:

还提供了判断链表是不是最后一个或者链表是否为空的函数

 

 对于将GNU上的链表移植到Windows环境,须要注意的是,将预取指函数删除,或者换成你所使用的环境中能够达到相同效果的指令或函数,还有就是,typeof是gcc的特殊关键字,在Windows环境下,能够经过将相应的内核链表宏增长一个参数,该参数用来表示类型。

最后说两句:

动手实践一次,比眼看100次更有收获。

talk is cheap,show me the code.

相关文章
相关标签/搜索