【读书笔记】Linux内核设计与实现--内核数据结构

1.链表

链表是Linux内核中最简单、最普通的数据结构。
链表是一种存放和操作可变数量元素(节点)的数据结构。
链表和静态数组的不同之处在于,它所包含的元素都是动态创建并插入链表的,在编译时不必指导具体需要多少元素,同时也因为链表中的每个元素的创建时间不同,所以它们在内存中无须占用连续内存区。
因为元素是不连续存放,所以各元素需要通过某种方式被连接在一起,于是每个元素都包含一个指向下一个元素的指针。

1.1 单向链表和双向链表

/* 一个链表中的一个元素 */
struct list_element {
	void *data;					/* 有效数据 */
	struct list_element *next;	/* 指向下一个元素的指针 */
};

只能向后连接链表称为单向链表
在这里插入图片描述
Q:什么是双向链表
A:在有些单向链表中,每个元素还包含一个指向前一个元素的指针,它们可以同时向前和向后相互连接。

表示双向链表的一种数据结构如下:

/* 一个链表中的一个元素 */
struct list_element {
	void *data;					/* 有效数据 */
	struct list_element *next;	/* 指向下一个元素的指针 */
	struct list_element *prev;	/* 指向前一个元素的指针 */
};

在这里插入图片描述

1.2 环形链表

通常情况下,因为链表中最后一个元素不再有下一个元素,所以将链表尾元素中的向后指针设置为NULL,以此表明它是链表中的最后一个元素。

Q:何为环形链表
A:有些链表,末尾元素并不指向特殊值NULL,相反,它指回链表的首元素,首尾相连的链表被称为环形链表

环形链表也存在双向链表和单向链表两种形式。

在这里插入图片描述

ps:因为环形双向链表提供了最大的灵活性,所以Linux内核的标准链表就是采用环形双向链表形式实现的。

1.3 沿链表移动–线性移动

沿链表移动只能是线性移动–先访问某个元素,然后沿该元素的向后指针访问下一个元素,不断重复这个过程,就可以沿链表向后移动了。

ps:如果需要随机访问数据,一般不使用链表。使用链表存放数据的理想情况是,需要遍历所有数据或需要动态加入和删除数据时。

1.4 Linux内核中的实现

存储一个结构到链表里的通常方法是在数据结构中嵌入一个链表指针,如

struct fox {
	unsigned long tail_length;		/* 尾巴长度,以厘米为单位 */
	unsigned long weight;			/* 重量,以千克为单位 */
	bool 		  is_fantastic;		/* 这只狐狸奇妙吗 */
	struct fox	  *next;			/* 指向下一个狐狸 */
	struct fox	  *prev;			/* 指向前一个狐狸 */
};

而Linux内核方式与众不同,它不是将数据结构塞入链表,而是将链表节点塞入数据结构。

Linux链表数据结构链表代码在头文件<linux/list.h>中声明,其数据结构很简单:

struct list_head {
	struct list_head *next;		/* 指向下一个链表节点 */
	struct list_head *prev;		/* 指向前一个链表节点 */
};

现在如何将链表节点塞入数据结构?
如下:

struct fox {
	unsigned long 	 tail_length;		/* 尾巴长度,以厘米为单位 */
	unsigned long 	 weight;			/* 重量,以千克为单位 */
	bool		  	 is_fantastic;		/* 这只狐狸奇妙吗 */
	struct list_head list;				/* 所有fox结构体形成链表 */
};

链表目前已经成型,为了更方便使用,内核提供了一组链表操作方法。
list_add()方法加入一个新节点到链表中。这组方法只接受list_head结构作为参数。

ps:使用宏container_off()可以很方便的从链表指针找到父结构中包含的任何变量。因为在C语言中,一个给定结构中的遍历偏移在编译时地址就被ABI固定下来了。

list_head本身其实并没有意义–它需要被嵌入到指定的数据结构中才能生效。

Q:如何初始化链表?
A:使用宏INIT_LIST_HEAD,eg:
动态:INIT_LIST_HEAD(&red_fox->list)
静态:

struct fox red_fox {
	.tail_length = 40,
	.weight = 6,
	.list = LIST_HEAD_INIT(red_fox.list),
};

链表头:一个标准的索引指针指向整个链表,即是链表的头指针

1.5 操作链表

1.向链表中增加一个节点

list_add(struct list_head *new, struct list_head *head)

该函数向指定链表的head节点后插入new节点。
如果把“最后”一个节点当做head的话,可以用该函数实现一个

list_add_tail(struct list_head *new, struct list_head *head)

该函数向指定链表的head节点前插入new节点。
如果把“第一个”元素当做head的话,可以用该函数实现一个队列

2.从链表中删除一个节点

1.6 遍历链表

内核提供了一组可以用来遍历链表和引用链表中的数据结构体。
ps:和链表操作函数不同,遍历链表的复杂度为O(n),n是链表所包含的元素数目。

1.基本方法
list_for_each()宏是遍历链表最简单的方法。
如下所示:

struct list_head *p;		// p用来指向当前项(临时变量)
list_for_each(p,list){		//list是需要遍历的链表的以头节点形式存在的list_head
	/* p指向链表中的元素 */	//每次遍历中,第一个参数p在链表中不断移动指向下一个元素,直到链表中的所有元素都被访问为止。
}

list_entry()宏可以用来获取包含给定list_head的数据结构,如下所示:

struct list_head *p;
struct fox *f;

list_for_each(p,&fox_list) {
	/* 当list被遍历的时候 p 指向 包含当前list的结构体*/
	f = list_entry(p, struct fox, list);
}

2.可用的方法
多数内核代码采用list_for_each_entry()宏遍历链表,更为灵活。
该宏内部也使用list_entry()宏,但简化了遍历过程:

list_for_each_entry(pos, head, member);

pos是一个指向包含list_head 节点对象的指针(可以看做list_entry宏的返回值);
head是遍历开始的位置;
member是list_head在结构中的命名。
如:

struct fox *f;

list_for_each_entry(f, &fox_list, list) {
	/* 在每次遍历中,f 指向下一个fox_list结构体 */
}

3.反向遍历链表
宏list_for_each_entry_reverse()的工作和list_for_each_entry()类似,不同点在于它是反向遍历链表的。即不是沿着next指针向后遍历,而是沿着prev指针向前遍历。用法相同:

list_for_each_entry_reverse(pos, head, member);

4.遍历的同时删除
标准的链表遍历方法在你遍历链表的同时要想删除节点时是不行的。因为标准的链表方法建立在你操作不会改变链表项这一假设上,所以如果当前项在遍历循环中被删除,那么接下来的遍历就无法活动next(或者prev)指针了。
Linux内核提供了遍历的同时删除函数:

list_for_each_entry_safe(pos, next, head, member);

该函数的实现是通过在潜在的删除操作之前存储next(或者previous)指针到一个临时变量中,以便能执行删除操作。
同样内核提供了list_for_each_entry_safe_reverse()宏在需要反向遍历链表的同时删除的函数。

list_for_each_entry_safe_reverse(pos, next, head, member);

ps:考虑到并发任务的操作,list_for_each_entry()的安全版本只能保护在循环体重从链表删除数据,并不能处理并发任务的处理,因此,如果有并发操作,需要锁定链表。

5.其他链表方法–参考<linux/list.h>。

2.队列

生产者和消费者的编程模型在内核中少不了,在该模式中,生产者创造数据,消费者读取处理数据。

实现生产者和消费者模型的最简单方式是使用队列。生产者将数据推进队列,然后消费者从队列中摘取数据。
消费者获取数据的顺序和推入队列的顺序一致。
即第一个进入队列的数据一定是第一个离开队列的,这样队列也被称为FIFO(先进先出的缩写)。

在这里插入图片描述
Linux内核通用队列实现称为kfifo–kernel fifo,它实现在文件kernel/kfifo.c中,声明在<linux/kfifo.h>中。

2.1 kfifo

Linux的kfifo提供了两个主要操作:enqueue(入队列)和dequeue(出队列).
kfifo对象维护了两个便宜量:入口偏移和出口偏移。
入口偏移是指下一次入队列时的位置;
出口偏移是指下一次出队列时的位置。
出口偏移总是小于等于入口偏移(队列中有可用元素)。

enqueue操作拷贝数据到队列的入口偏移位置,拷贝完成后,入口偏移加上推入的元素数目。
dequeue操作从队列中出口偏移处拷贝数据,拷贝完成后,出口偏移减去摘取的元素数目。

当出口偏移等于入口偏移时,说明队列空了;
当入口偏移等于队列长度时,说明队列满了。

ps:讲道理,说白了就是条形缓冲。

2.2 创建队列–定义和初始化

和多数内核对象一样,有动态或者静态方法以供选择。
ps:动态代码临时控制,静态就是事先写好的全局。可以这样粗糙理解?

动态创建kfifo如下:

int kfifo_alloc(struct kfifo *fifo, unsigned int size, gfp_t gfp_mask);	/* 初始化一个大小为size的kfifo. 内核使用gfp_mask表示分配内存  成功返回0,失败返回一个负数错误码 */

Q:如何自己分配缓冲?
A:如下:

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

静态声明kfifo如下:

DECLARE_KFIFO(name, size);
INIT_KFIFO(name);

ps:对于kfifo_alloc()和kfifo_init,size必须式2的幂(静态声明的size也是一样)。

2.3 推入队列数据–kfifo_in()

完成kfifo对象创建和初始化后,方法kfifo_in()完成推入数据到队列的功能。

unsigned int kfifo_in(struct kfifo *fifo, const void *from, unsigned int len);

该函数把from指针所指的len字节数据拷贝到fifo所指的队列中,如果成功,则返回推入数据的字节大小。
ps:如果队列中的空闲字节小于len,则该函数值最多可拷贝队列可用空间那么多的数据,当返回值小于len,甚至0的时候,意味着没有任何数据被推入。

2.4 摘取队列数据–kfifo_out()

unsigned int kfifo_out(struct kfifo *fifo, void *to, unsigned int len);

该函数从fifo所指向的队列中拷贝出长度为len字节的数据到to所指的缓冲中。成功返回拷贝的数据长度。
如果队列中数据大小小于len,则拷贝出的数据必然小于需要的数据大小len。

ps:内核提供了kfifo_out_peek()函数用来查看队列中数据,该函数和kfifo_ou()类似,但出口偏移不增加,这样摘取的数据仍然可被下次kfifo_out获取。

unsigned int kfifo_out_peek(struct kfifo *fifo, void *to, unsigned int len, unsigned offset); //参数offset指向队列中的索引位置

2.5 获取队列长度–kfifo_size()等

函数名 原型 说明
kfifo_size static inline unsigned int kfifo_szie(struct kfifo *fifo); 获取用于存储kfifo队列的空间的总体大小(以字节为单位)
kfifo_len static inline unsigned int kfifo_len(struct kfifo *fifo); 返回kfifo队列中已推入的数据大小
kfifo_avail static inline unsigned int kfifo_avail(struct kfifo *fifo); 获取kfifo队列中海油多少可用空间
kfifo_is_empty static inline int kfifo_is_empty(struct kfifo *fifo); 判断是否为空,0代表空,非0相反
kill_is_full static inline int kfifo_is_full(struct kfifo *fifo); 判断是否满,0代表满 ,非0相反

2.6 重置和撤销队列–kfifo_reset/kfifo_free

函数名 原型 说明
kfifo_reset static inline void kfifo_reset(struct kfifo *fifo); 重置kfifo,抛弃队列中的所有内容
kfifo_free void kfifo_free(struct kfifo *fifo); 撤销一个使用kfifo_alloc()分配的队列

ps:如果使用的是kfifo_init()方法创建的队列,需要自己负责释放相关缓冲。

3.映射(key-value 键值对)–idr

一个映射,也常称关联数组,即是一个由唯一键组成的集合,而每个键必然关联一个特定的值。这种键到值的关联关系称为映射。

映射至少要支持三个操作:

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

Linux内核提供了简单、有效的映射数据结构,但它并发是一个通用的映射。
因为其目标是:映射一个唯一的标识数(UID)到一个指针。

Linux内核提供的映射除了提供了三个标准的映射操作外,还在add操作基础上实现了allocate操作。
allocate操作不但向map中加入了键值对,而且还可以产生UID

Linux内退提供的映射被命名为idr,idr数据结构用于映射用户空间的UID

3.1 初始化一个idr–idr_init()

Q:如何建议一个idr?
A:

  1. 静态定义或者动态分配一个idr数据结构;
  2. 调用idr_init()。

eg:

struct idr id_huh;	/* 静态定义idr结构 */
dir_init(&id_huh);	/* 初始化idr结构 */

3.2 分配一个新的UID

Q:如何分配一个新的UID?
A:

  1. 告诉idr需要分配新的UID,允许其在必要时调整后备树的大小
  2. 真正请求新的UID

Q:如何做?
A:
调整后备树大小使用idr_pre_get()方法

int idr_pre_get(struct idr *idp, gfp_t gfp_mask);

该函数将在需要时进行UID的分配工作:调整由idp指向的idr的大小。
如果真的需要调整大小,则内存分配例程使用gfp标识:gfp_mask。

ps:
1.不需要对并发访问该方法进行同步保护。
2.idr_pre_get()成功时返回1,失败时返回0。(与一般的一起内核函数返回值相反)

实际执行获取新的UID,并且将其加到idr的方法是idr_get_new():

int idr_get_new(struct idr *idp, void *ptr, int *id);

该方法使用idp所指的idr去分配一个新的UID,并且将其关联到指针ptr上。
成功返回0,并且将新的UID存于id。
错误返回非0的错误码,错误码是 -EAGIN。
如果idr已满,错误码是-ENOSPC。

ps:
函数 idr_get_new_above()使得调用者可指定一个最小的UID返回值

int idr_get_new_above(struct idr *idp, void *ptr, int stating_id, int *id);

该函数的作用和idr_get_new()相同,除了它确保新的UID大于或等于starting_id外。使用这个变种方法允许idr的使用者确保UID不会被重用,允许其值不但在当前分配的ID中唯一,而且还保证在系统的整个运行期间唯一。

3.3 查找UID–idr_find()

Q:如何查找已存在的UID?
A:使用idr_find()方法。

void *idr_find(struct idr *idp, int id);

该函数成功,返回id关联的指针;
错误返回空指针。

notice:
如果使用idr_get_new()或者idr_get_new_above()将空指针映射给UID,那么该函数在成功时也返回NULL。(最好不要将UID映射到空指针上,这样无法区分idr_find函数成功还是失败)

eg:

struct my_struct *ptr = idr_find(&idr_huh, id);
if(!ptr)
	return -EINVAL;	/* 错误 */

3.4 删除UID–idr_remove()

Q:如何删除UID?
A:使用方法idr_remove()

void idr_remove(struct idr *idp ,int id);

如果idr_remove执行成功,则将id关联的指针一起从映射中删除。
ps:由于返回值是void,所以失败并不知道。

3.5 撤销idr–idr_destroy()

Q:如何撤销idr?
A:使用idr_destroy()方法

void idr_destroy(struct idr *idp)

该方法成功,则只释放idr中未使用的内存。
它并不释放当前分配给UID使用的任何内存。
通常,内核代码不会撤销idr,除非关闭或者卸载,而且只有在没有其他用户(也就没有更多的UID)时才能删除,但是可以调用idr_remove_all()方法强制删除所有UID:

void idr_remove_all(struct idr *idp);

1:对idp指向的idr调用idr_remove_all();
2:调用idr_destroy()。

通过上述两步骤这样就可以使idr占用的内存都被释放。

4.二叉树

树的相关概念和基础知识可以看看这里:深入学习二叉树(一) 二叉树基础

树结构是一个能提供分层的树型数据结构的特定数据结构。

在数学意义上,树是一个无环的、连接的有向图,其中任何一个顶点(在树里叫节点)具有0个或者多个出边以及0个或者1个入边。
一个二叉树是每个节点最多只有两个出边的树–即,一个数,其节点具有0个、1个或者2个子节点。

在这里插入图片描述

4.1 二叉搜索树

一个儿叉搜索树(BST)是一个节点有序的二叉树,其顺序通常遵循下列法则:

  1. 根的左分支节点值都小于根节点值;
  2. 右分支节点值都大于根节点值;
  3. 所有的子数也都是二叉搜索树。

一个二叉搜索树所有节点必然都有序,且左子节点小于其父节点值,而右子节点大于其父节点值的二叉值的二叉树。所以,在树中搜索一个给定值或者按序遍历树都相当快捷(算法分别是对数和线性的)。

4.2 自平衡二叉搜索树

一个节点的深度是指从其根节点起,到达它一共需经过的父节点数目。处于树底层的节点(再也没有子节点)称为叶子节点。
一个树的高度是指树中的处于最底层节点的深度。
一个平衡二叉搜索树是一个所有叶子节点深度不超过1的二叉搜索树。
一个自平衡树是指其操作都试图维持(半)平衡的二叉搜索树。

在这里插入图片描述

红黑树:是一种自平衡二叉搜索树
Linux主要的平衡二叉树数据结构就是红黑树。红黑树具有特殊的着色属性,或红色或黑色。
红黑树因遵循下面六个属性,所以能维持半平衡结构:

  1. 所有的节点要么着红色,要么着黑色;
  2. 叶子节点都是黑色;
  3. 叶子节点不包含数据。
  4. 所有非叶子节点都有两个子节点。
  5. 如果一个节点是红色,则它的子节点都是黑色;
  6. 在一个节点到其叶子节点的路径中,如果总是包含同样数目的黑色节点,则该路径相比其他路径是最短的。

Linux实现的红黑树称为rbtree.
定义在文件lib/rbtree.c中,声明在文件<linux/rbtree.h>中。
Linux的rbtree类似于前面所描述的经典红黑树,即保持了平衡性,所以插入效率和树中节点数目呈对数关系。

5.数据结构以及选择

Linux中存在最重要的四种数据结构:链表、队列、映射和红黑树。
Q:如何对景选用数据结构?
A:
1:如果对数据结合的主要操作是遍历数据,就使用链表。事实上没有数据结构可以提供比线性算法复杂度更好的算法遍历元素。当性能并非首要考虑因素时,或者当需要存储相对较少的数据项时,或者当需要和内核中其他使用链表的代码交互时,也该优先选用链表
2:如果代码符合生产者/消费者模式(先入先出),则使用队列(特别是有一个定长缓冲)。
3:如果需要映射一个UID到一个对象,则使用映射。映射结构使得映射工作简单有效,而且映射能够维护和分配UID。 ps:Linux的映射接口是针对UID到指针的映射,并不适合其他场景。若是在处理发给用户空间的描述符,则考虑映射
4:如果需要存储大量数据,并且检索迅速,则红黑树最好。红黑树可确保搜索时间复杂度是对数关系,同时也能保证按需遍历时间复杂度是线性关系。并且内存开销情况并不是太糟。检索迅速是重点。

ps:Linux内核还实现了一些较少使用的数据结构,如基树(trie类型)和位图。当寻遍所有内核提供的数据结构都不能满足时,才需要设计对应的数据结构。

经常在独立的源文件中实现的一种常见数据结构是散列表

6.算法复杂度–算法的复杂度(或伸缩度)量化表示

算法复杂度的量化表示最常用的技术是研究算法的渐近行为(asymptotic behavior)。
渐近行为是指当算法的输入变得非常大或者接近于无限大时算法的行为。
渐近行为充分显示了当一个算法的输入逐渐变大时,该算法的伸缩带如何。

6.1 算法

算法就是一系列的指令,它可能有一个或多个输入,最后产生一个结果或输出。

在Linux内核中,页换出和进程调度都是算法的例子。
从数学角度讲,一个算法好比一个函数。

6.2 大o符号

一种很有用的渐近表示法就是上限–是一个函数,其值自从一个起始点之后总是超过所研究的函数的值,也就是说上限增长等于或者快于研究的函数。
一个特殊符号,大o符号用来描述这种增长率。
函数f(x)可写作O(g(x)),读为“f是g的大o”。即完成f(x)的时间总是短于或等于完成g(x)的时间和任意常量(至少,只要输入的x值大于某个初始值X’)的乘积

6.3 大0符号

6.4 时间复杂度

如果有n个人(假设一秒数一个人),需要花n秒来数他们–算法复杂度为O(n).

常见的时间复杂度表如下:

O(g(x)) 名称
1 恒量(理想的伸缩度)
log n 对数的
n 线性的
n^2 平方的
n^3 立方的
2^n 指数的
n! 阶乘

ps:
1.应避免使用复杂度为O(n!)或O(2^n)的算法;
2.用复杂度为O(1)的函数代替复杂度为O(n)的函数通常会提高执行性能;
3.在比较算法性能时,还要考虑输入规模
4.时刻注意算法的负载和典型输入集合大小的关系。

发布了91 篇原创文章 · 获赞 17 · 访问量 5万+

猜你喜欢

转载自blog.csdn.net/qq_23327993/article/details/105390310