Linux Kernel Development 笔记(五)内核数据结构

来源:互联网 发布:hsk网络考试 编辑:程序博客网 时间:2024/04/28 12:47

Linux提供了内建的数据结构以及基本元素供内核开发者使用,并以此鼓励代码重用,这里重点讨论:Linked lists, Queues, Maps, Binary Trees。

Linked lists:

Linked lists是Linux内核中最普遍最简单采用的一种数据结构。它是一种存储以及管理一系列元素(成为List的节点)的数据结构。它是动态创建的,其元素也是

动态插入到列表的,而不像一般数组一样是固定的。故此,此列表的元素个数可以在编译时是未知的。因为其元素是在不同时间点创建的,故此其不可能占用一个

连续的内存空间,故此每个元素都必须连接起来,这是靠节点的next指针。节点的删除以及插入,next指针就要做相应的调整。Linked lists分为但链接或双链接。

单链接Linked list仅仅指向下一个节点,而双链接即指向下一个节点,也指向上一个节点。还有一种是循环Linked list。通常的linked list的末尾节点的next指针都是

指向null以表示此节点是尾节点。循环linked lists的尾节点next指针则指向头节点。但链接或双链接linked lists都可以变成循环linked lists。linked lists的访问是线性的。

循着next指针访问下一个节点,一个一个的访问。这种访问方式是linked list最合适的访问方式,故此linked lists不是很适合用到随机接入的用例中。一般用在需要枚举的,以及需要动态增删节点的地方。在linked lists的实现上,一般会以一个特殊的指针代表linked lists的头节点,就是head。在非循环链表中,next为null的节点代表尾节点。

Linux内核的链表实现方式有点特殊,它不是简单的把一个普通的数据结构简单的增加next或prev来让普通的数据变成一个linked lists结构,而是镶嵌一个linked lists节点

到这个结构中去。Linux官方定义的linked lists结构为:

struct list_head

{

struct list_head *next;

struct list_head *prev;

};

通过嵌进一个linked lists到普通结构那里,就可以让内核提供一套标准的函数来处理链表。如 list_add方法,增加一个节点到一个链表那里去。这个方法只接受list_head这样结构类型,并通过container_of这个宏来找到这个linked lists所嵌的数据结构。这是利用了一个原理:在C语言中,一个给定的结构内数据项在结构体内存中的偏移量是固定的。list_entry宏采用这个宏来获得linked list所在的数据结构体。链表在使用前必须初始化,一般用INIT_LIST_HEAD的宏来做(一般都是某数据结构中对应的linked list项)。静态创建链表可以采用如下方式:

struct fox red_fox = 

{

.tail_length = 40,

.weight = 6,

.list = INIT_LIST_HEAD(red_fox.list),

};

在使用Linked list的时候,需要一个明确的指向这个list的指针,这个就是head指针。内核linked list的一个优秀的设计方面是,通过内嵌linked list到数据结构中,每一个节点在linked list是难以区分的,你可以从任何一个节点访问所有节点(循环)。但往往有些时候,你需要一个指向linked list头的指针而不是指向一个节点的指针。这个特殊的指针就是list_head。一般用定义如下:

static LIST_HEAD(fox_list); 

这个语句定义了一个名字叫fox_list的list_head。一般的linked list操作方法都是接受一个或两个参数,head 节点或head节点加一个list节点。linked list的操作函数,是以inline的c函数实现的,都是O(1)复杂度的,也即是说,执行时间都与size以及任何其他输入无关。如增加或删除一个节点,对于3个节点或3000个节点的linked list都是固定的执行时间的。

一般的操作如下:

list_add(new, head): 把new节点放在head节点之后。因为linked list是循环的,故此head这个参数可以是任何节点。如果选择是末节点,则可以用来作为栈的实现。一般head都是指向list的头节点。

list_add_tail(new, head)把new节点放在head节点之前。

list_del(entry)把entry节点给去掉,但并没有释放entry节点的内存,仅仅是把该节点从list上摘除。

list_move(list,head)把list从其原list摘除并放到head节点之后。对应放在之前的是list_move_tail

list_empty(head)检测list是否为空。

list_splice(list, head)拼接两个list,把list所在的list拼接到head之后。

如果本身定义的数据结构中含有next,prev指针,则以上的调用的方法可以换成前面增加双下划线的调用如:__list_del(prev, next)这样就可以省却增加不必要的代码(不用嵌入list了)

遍历整个链表的复杂度是O(n),跟拥有的节点数n相关。最基本的遍历是采用宏list_for_each(),这个宏采用两个参数,一个是指向当前的节点,一个临时变量(必须提前声明)。一个是list_head作为链表的头节点。使用方法如下:

struct list_head *p;

struct fox *f;

list_for_each(p, fox_list)

{

 f = list_entry(p, struct fox, list);

}

这样就可以逐个遍历所有链表的节点所属的原数据结构了。以上的方法还不够优美,更好的做法就是采用宏

list_for_each_entry(pos, head, member), pos是指向包含struct list_head的原结构体,就是上面list_entry返回的值。head是指向遍历起始的节点,member是list_head结构在pos中的对应的名字,如list。这个宏是向前搜索,还有对应的向反方向搜索的宏为:list_for_each_entry_reverse。采用反向的遍历,一般是考虑到效率(如果知道反向会更快搜索到节点)或为了实现特殊的功能(如后进先出)。在前面的遍历方法中,都必须有一个前提假设,就是此时不对链表进行节点移除,否则操作就会失败。内核提供了一个宏list_for_each_entry_safe来保存当前节点指向的下一个节点,提供一种在遍历过程中删除当前节点的可能。例子:

struct iotify_watch *watch, *next;

mutex_lock(&inode->inotify_mutex);

list_for_each_entry_safe(watch, next, &inode->inotify_watches, i_list)

{

struct inotify_handle *ih = watch->ih;

mutex_lock(&ih->mutex);

inotify_remove_watch_locked(ih, watch);

mutext_unlock(&ih->mutex);

}

mutex_unlock(&inode->inotify_mutex);

这个例子是遍历所有节点,同时删除之。如果用list_for_each_entry会引入一个错误,因为此时当前的watch节点被释放资源了,无法再访问了。其余的list操作可以参考<linux/list.h>。


Queues:

在任何操作系统的内核里,都有一种编程模式,producer 和 consumer。producer 是制造数据,如错误信息或网络数据包。consumer是消耗数据,如读取错误信息,处理网络数据包。而实现此种模式最简单的办法就是采用Queues。producer 把数据填入Queues,consumer则从Queues有序的取出数据。第一个送进去的数据,总是第一个取出来的,因此Queues也称为FIFO。在Linux的实现是kfifo,定义在kerne/kfifo.c那里。如大部分抽象Queues一样,它提供了enqueue以及dequeue接口,分别是填数据以及取数据。kfifo对象维护了两个偏移量: in 偏移以及out偏移。in偏移指向下一个填入数据的位置,out偏移指明下一个要取得数据的位置。out偏移总是小于等于in偏移。enqueue往queue的in指向的位置填入数据,然后in加如填如数据的数目。dequeue则从out偏移指向的位置读取数据,然后out偏移减去读取的数量。当in与out相等时,queue是空的,不能在读取数据。当in等于queue的长度,则不能在写入。要使用kfifo,必须初始化并创建它。创建既可以动态也可以静态,一般都是动态创建。调用

kfifo_create(struct kfifo *fifo, unsigned int size, gfp_t gfp_mask);

创建并初始化size的queue,成功返回0,失败返回负数。此方法的内存是由gfp_mask指定的方法申请的。如果希望用自己给定的缓存区来创建kfifo,则用下面接口

kfifo_init(struct kfifo *fifo, void *buffer, unsigend int size)

创建并初始化指定size大小的buffer来创建queue,必须明确的是,两种方式创建的size必须是2的整数倍。

静态创建的方式为 DECLEAR_KFIFO(name, size);INIT_KFIFO(name);

写入Queue的方法为

kfifo_in(fifo, void *from, unsigned int size) 从from地址拷贝size大小的数据到fifo代表的Queue中去。成功返回写入的数据大小。如果Queue空的数量比len小,则只会写入尽可能多的数据,应此返回可能会比len小或为0,代表无法写入。读取Queues的方法为 kfifo_out(struct kfifo *fifo, void *to, unsigned int len); 这个方法是尽可能的从fifo拷贝最多len大小字节的数据并返回到to指定的缓存里。一旦采用此方法读取,则原有的数据则不存在了(被移除掉)。用户可以采用kfifo_out_peek的方法来窥视一下数据,而不会导致数据被移除。此方法还带有一个offset参数,会指定从那里读起。还有别的方法分别是获取队列的大小 kfifo_size 或 kfifo_len,获取还剩余多少空间可写 kfifo_avail以及一些判断是否为空或满的方法。还可以调用kfifo_reset来重置queue。如果queue是由kfifo_alloc创建的,则需要kfifo_free来释放。


Maps 映射:

map也算是一种联合数组,是各种不同key的组合,每一个key对应一个指定的值。key和值的关系称为一个映射(mapping)。Maps支持至少三种操作:

Add(key,value),Remove(key), value=Lookup(key)

虽然Hash表算是一种map,但不是所有的map都是hashes,还可以是一种自平衡二叉查找树。虽然hash可以多数情况下的复杂度是接近线性的,但二叉树可以在最坏的情况下提供更好的处理。二叉树还可以保留次序,让用户可以更有效的以存储的次序遍历整个集合。最后,二叉树可以不需要hash函数,只要key可以支持<=逻辑判断即可。虽然从名字上来说,map是指关键值对应指定的值,但更多的是指二叉树实现的集合。linux内核提供了一种简单有效的map结构,但并不是给一般目的map用的,而是针对一种特殊的情况:把唯一的身份id(UID)对应于一个指针。linux除了实现上面提到的三种接口外,还在add的操作之前加上了创建的操作,创建一个UID。

idr数据结构是用在映射用户空间UID的map,例如inotify watch或POSIX timer ID,映射到关联内核的数据结构,如inotify_watch或k_itimer结构。

设置idr数据结构是很容易的,在动态或静态创建idr之后,可以调用idr_init来初始化。如

struct idr id_huh;

idr_init(&id_huh);

申请一个新的UID要分两步走,第一步是告诉idr你要申请一个新的UID,让它有需要可以重新改变背后的树结构大小。第二步是申请一个新的UID。这种繁琐的步骤是为了让你可以执行初始的改变大小的动作,这个内存的改变方式可以由你指定。第一步调用的方法 idr_pre_get(struct idr *idp, gfp_t gfp_mask),为了满足申请新UID,这个方法可以更改idr的大小。更改大小变动的内存动作可以由gfp_mask指定。这个函数也不需要同步操作,而且有一点要注意的是,这个函数跟绝大部分的linux内核函数相反的是返回结果。其返回1代表成功,返回0代表失败,要注意。第二步,调用idr_get_new(struct idr *idp, void *ptr, int *id),为申请一个新的UID,并指派一个pointer与这个UID关联起来。该方法成功后返回0,失败返回一个非零。为了要保证UID是从未采用过的,保证UID在整个系统时间内是唯一的,而不仅仅是目前申请中的UID当中唯一。可以调用方法 idr_get_new_above(struct idr *idp, void *ptr, int starting_id, int *id)来获得比starting_id大或相等的id数值。在创建好UID后,就可以采用 idr_find(struct *idr idp, int id)的方法来查询该UID对应的指针。成功返回对应的指针,失败则返回NULL。从idr中移除UID可以调用 idr_remove。当idr不存在任何UID的时候,可以调用 idr_destroy来释放idr。虽然有UID的时候也可以调用,但里面的UID申请的内存是不会释放的。可以采用 idr_remove_all的方式来释放所有的UID。

Binary Trees(二叉树)

tree是一种形状如树状的一种结构。二叉树是一种其节点有至多两个输出边,也就是说有0到两个的子节点。二叉搜索树是一种含有带指定次序的节点的树,其次序是如下安排:

左子树包含的节点值比根要小。右子树包含的节点值比根要大。所有子树都是二叉搜索树。二叉搜索树定义为:所有的节点都被排序,按照左节点都比父节点要小,右节点都比父节点要大。

节点的深度是指从该节点到根节点一共有多少个父节点。不含任何子节点的节点称为leaves。一棵树的高度,是众多节点中最深的节点的深度。所谓的平衡二叉搜索树,是指所有leaves的深度相差都不超过1。自平衡二叉搜索树是尝试去保持达到平衡。

Red-Black Trees(红黑树)

红黑树是一种自平衡二叉搜索树,是linux主要的二叉树数据结构。红黑树有一个特殊的颜色属性,要么是黑色,要么是红色。通过一下六点,红黑树保持半平衡状态。

1. 所有的节点,要么红要么黑

2. leaf节点是黑色

3. leaf节点不含数值

4. 非leaf节点含有2个子节点

5. 如果该节点为红色,则其两个子节点必为黑色

6. 从一个节点到它的其中一个leaf所包含的黑色节点数与到他的任意leaf中最短的路径的深度一样。

有以上特性的保证,最深的leaf的深度不会超过最浅leaf深度的两倍。因此,这棵树总是半平衡的。因为第五条,红节点不能成为红节点父节点或子节点,以及第六条,所有从树到leaf的路径都有相同的黑节点数目。最长的路径中,红黑节点总是交替的,因此,最短的路径为了确保包含相同的黑节点数目,只能仅包含黑节点。因为从root到leaf最长的路径不会比从root到leaf最短路径的超过两倍。如果保证节点的插入以及删除操作能依据这六点,则树是保持半平衡的。linux的红黑树称为rbtrees,定义在lib/rbtree.c。除了增加了优化外,rbtree个经典的红黑树是相似的。他们通过保持平衡来满足插入节点的复杂度总是log(n),其中n是树的节点数。rbtree的根节点是rb_root。要创建一颗新树,必须创建rb_root,并用RB_ROOT特殊的值初始化它。如:

struct rb_root = RB_ROOT;

树的节点以rb_node结构体表示,我们可以通过其指向相同名字的指针来把它放在左节点或右节点。rbtree并没有实现查找以及插入函数,用户必须自己定义自己。这是因为用C语言实现一般的编程不是简单的,同时linux内核开发者认为最有效的查找以及插入操作是用户自己去实现,借用rbtree的帮助函数,以自己定义的比较操作来实现。下面采用一个例子来说明:

查找inode的红黑树中匹配的offset值。

struct page *rb_search_page_cache(struct inode *inode, unsigned long offset)

{

struct rb_node *n = inode->i_rb_page_cache.rb_node;

while(n)

{

struct page *page =rb_entry(n, struct page, rb_page_cache);

if(offset < page->offset)

n = n->rb_left;

else if(offset > page->offset)

n = n->rb_right;

else

return page;

}

return NULL;

}

以上的例子,while循环中遍历整个rbtree,依据需要从树的左节点或右节点遍历。if else语句那里实现了比较函数,确保树的次序。下面的例子,除了查找外,还实现了插入,虽然不是很简单的一个例子,但可以当成插入的一个参考。


当找不到的时候,就调用rb_link_node把节点插入到给定的节点(parent),再调用rb_insert_color来做复杂的重新平衡算法。


下面要重点讨论,以上的数据结构的使用方式以及时机。

当你主要的访问方式是遍历所有数据的时候,建议使用链表Linked List, 直觉上来说,没有比Linked list的线性复杂度更好的方式来遍历每一个元素了。同时也要注意,用Linked List的场合一般是效率要求不高,存储的数据相对来说较少,或需要与内核的其他代码用到Linked List的时候配合。如果你的访问方式是 producer/consumer的方式,则使用Queue,特别是你希望是固定大小的。Queue的元素的增加删除非常高效,还提供了FIFO机制,这是用在 producer/consumer 模式下。但如果用在存储不固定数量的数据项时候,或需要动态增加数据项时候,Linked List会比Queue更有效。如果你需要映射一个UID到一个对象时候,就使用Map结构。Linux的Map机制是UID与指针的映射。如果需要存储大量的数据,并希望有效的查找,请使用红黑二叉树。红黑二叉树,不但提供log的搜索复杂度,也提供线性的数据遍历复杂度。虽然其实现复杂,但对内存的影响不大,但如果是用在一些对时间要求不高的时候,建议用Linked List替代红黑树。