内核数据结构
本章介绍几种Linux内核常用的内建数据结构,其中最常用的有:
链表
队列
映射
二叉树
1. 链表
链表是一种存放和操作可变数量节点的数据结构。
链表和静态数组的区别:
1. 链表包含的元素都是动态创建并插入链表的,在编译时不知道需要创建多少个元素。
2. 因为链表中每个节点的创建时间各不相同,所以各节点在内存中无需占用连续内存区
单向链表和双向链表
单向链表:
在链表中,每个节点都包含有一个指向下一个节点的指针,只能向后连续的链表,即单向链表。如下:
// 单向链表中的一个节点
struct list_element {
void *data; // 有效数据
struct list_element *next; // 指向下一个节点的指针
};
双向链表:
在链表中,每个节点都包含一个指向下一个和指向前一个节点的指针,可以同时向前和向后相互连接,即双向链表。如下:
// 双向链表中的一个节点
struct list_element {
void *data; // 有效数据
struct list_element *next; // 指向下一个节点的指针
struct list_element *prev; // 指向前一个节点的指针
};
环形链表
通常情况下,链表的最后一个节点没有下一个节点,所以链表末尾节点的向后指针设置为NULL。如果将链表末尾节点的向后节点指向链表的首节点,链表首尾相连,即构成环形链表。
在环形双向链表中,尾节点的向后指针指向链表的首节点,链表首节点的向前指针指向链表的尾节点,构成环形双向链表。
Linux内核的标准链表采用环形双向链表形式实现。具有最大的灵活性。
沿链表移动
链表的访问方法是沿链表线性移动。即先访问某个元素,再沿该节点的向后指针访问下一个节点,依次向后移动。
在有的链表中,首元素会用一个特殊指针表示,即头指针,构成有头链表。可以利用头指针找到链表的起始节点。
在非环形链表中,向后指针指向NULL的节点是尾节点。
在环形链表中,向后指针指向头节点的节点是尾节点。
遍历一个双向链表需要线性地访问从第一个节点到最后一个节点之间的所有节点。也可以从链表中的任意指定节点开始向前或向后访问别的节点。
Linux内核中的实现
普通的链表实现方式,通过在数据结构体中添加向前和向后的节点指针,将节点串联在链表中。如:
struct node {
int data; // 节点数据
struct node *next; // 指向下一个节点的指针
struct node *prev; // 指向上一个节点的指针
};
Linux内核中的链表实现方式不一样,不是将数据结构填入链表节点,而是将链表节点填入数据结构中。
1. 链表数据结构
include <linux/list.h>
struct list_head {
struct list_head *next;
struct list_head *prev;
};
内核链表使用方法:
include <linux/list.h>
struct node {
int data; // 节点数据
struct list_head list; // 双向循环链表头
};
在Linux中提供了一组对链表的操作,这些操作只接受list_head结构作为参数。
2. 定义一个链表
list_head本身没有意义,需要被嵌入到数据结构中使用。如上所示。
链表需要在使用前初始化,动态创建并初始化链表:
struct node *list_node;
list_node = kmalloc(sizeof(*list_node), GFP_KERNEL);
list_node->data = 1;
INIT_LIST_HEAD(list_node->list);
如果结构在编译期静态创建:
struct list_node {
.data = 1;
.list = LIST_HEAD_INIT(lise_node.list);
};
3. 链表头
链表的链表头,用来作为指向整个链表的索引。
static LIST_HEAD(list_node);
定义并初始化一个名为list_node的链表。
操作链表
Linux内核提供的链表操作,在<linux/list.h>文件中实现。所有函数的时间复杂度都是O(1)。
1. 向链表中增加一个节点
list_add(struct list_head *new, struct list_head *head); // 在head节点后插入new节点
list_add_tail(struct list_head *new, struct list_head *head); // 在head节点前插入new节点
因为链表是环形循环的,并且没有首尾节点概念,所以可以把任何一个节点当成head.
2. 从链表中删除一个节点
list_del(struct list_head *entry); // 将entry节点从链表中删除
注意,该操作不会释放entry或者包含entry的数据结构占用的内存,还需要再撤销这些内存。
从链表中删除一个节点并对其重新初始化:
list_del_init();
list_del_init(struct list_head *entry); // 删除entry节点,并初始化entry节点
3. 移动和合并链表节点
把节点从一个链表移到另一个链表:
list_move(struct list_head *list, struct list_head *head); // 从一个链表中移除list节点,并将其加入到另一个链表的head节点后
list_move_tail(struct list_head *list, struct list_head *head); // 从一个链表中移除list节点,并将其加入到另一个链表的head节点前
检查链表是否为空:
list_empty(struct list_head *head); // 检查链表是否为空
如果链表为空,返回非0值;否则返回0.
把两个未连接的链表合并为一个链表:
list_splice(struct list_head *list, struct list_head *head); // 合并两个链表,将list指向的链表插入到指定链表的head节点后
list_splice_init(struct list_head *list, struct list_head *head); // 合并两个链表,将list指向的链表插入到指定链表的head节点后,并初始化list指向的链表
遍历链表
链表是一个能够包含重要数据的容器,需要用链表移动来访问指定的节点数据。
遍历链表的时间复杂度为O(n),n为链表包含节点数目。
1. 基本方法
遍历链表最简单的方法是使用list_for_each()宏。使用两个list_head类型的参数,第一个参数用来指定当前项,临时变量;第二个参数是需要遍历的链表以头节点形式存在的list_head。每次遍历时,第一个参数在链表中不断移动指向下一个节点,知道链表中所有节点都被访问为止。用法如下:
struct list_head *p;
list_for_each(p, list) {
// p指向链表中的节点
}
通过list_for_each()宏,可以得到指向链表结构的指针,但是这个指针对于用户来说并无实际用处,用户需要的是一个指向包含list_head的结构体的指针,而不是list_head类型的指针。
可以通过list_entry()宏来通过list_head指针获取到包含给定list_head的数据结构。如:
struct list_head *p;
struct fox *f;
list_for_each(p, &node_list) {
f = list_entry(p, struct fox, list);
}
2. 可用的方法
Linux内核采用list_for_each_entry()宏来遍历链表,该宏内部使用了list_entry()宏。
list_for_each_entry(pos, head, member);
pos是一个指向包含list_head节点对象的指针,可以当做是list_entry()宏的返回值。
head是一个指向头节点的指针,即遍历开始的位置。
member是pos中list_head结构的变量名。
用法如下:
struct list_node *f;
list_for_each_entry(f, &node_list, list) {
// ...
}
3. 反向遍历链表
list_for_each_entry_reverse()和list_for_each_entry()类似,不同点是,反向遍历。用法相同:
list_for_each_entry_reverse(pos, head, member);
可能用到反向遍历链表的情况:
1. 性能原因,如:已知节点在搜索起始点前边
2. 顺序很重要时,如:堆栈中的先进先出
4. 遍历的同时删除
list_for_each_entry_safe(pos, next, head, member);
比list_for_each_entry(pos, head, member)安全是因为,标准的链表遍历在遍历链表的同时是不能删除的。
// 反向遍历链表的同时删除节点
list_for_each_entry_safe_reverse(pos, n, head, member);
注意:可能会有并发地删除链表中的不同节点,list_for_each_entry_safe()删除节点时需要锁定链表。
2. 队列
生产者和消费者模型中,生产者创造数据,而消费者读取消息和处理包,或者以其他方式消费这些数据。
最简单的实现方法是队列,生产者将数据推进队列,消费者从队列中读取数据。消费者获取数据的顺序和生产者推入队列的顺序一致。
队列,即FIFO(First in first out,先进先出)。Linux内核通用队列实现称为kfifo,在kernel/kfifo.c文件中实现,声明在<linux/kfifo.h>文件中。
1. kfifo
linux的kfifo,两个主要操作:enqueue(入队列)和dequeue(出队列)。
有两个偏移量:入口偏移(下一次入队列时的位置)和出口偏移(下一次出队列时的位置)。出口偏移总是小于等于入口偏移。
enqueue操作拷贝数据到队列中的入口偏移位置,并将入口偏移加上推入元素数目。
dequeue操作从队列中出口偏移处拷贝数据,并出口偏移减去摘取的元素数目。
队列空:出口偏移等于入口偏移时。
队列满:入口偏移等于队列长度时。
2. 创建队列
定义和初始化
// 动态
int kfifo_alloc(struct kfifo *fifo, unsigned int size, gfp_t gfp_mask); // 创建并初始化一个大小为size的kfifo
内核使用gfp_mask标识分配队列。成功返回0;错误返回一个负数错误码。例如:
// 创建一个队列,名为fifo,大小为PAGE_SIZE
struct kfifo fifo;
int ret;
ret = kfifo_alloc(&kfifo, PAGE_SIZE, GFP_KERNEL);
if (ret) {
return ret;
}
或者
void kfifo_init(struct kfifo *fifo, void *buffer, unsigned int size);
// 创建并初始化一个kfifo对象,将使用由buffer指向的size字节大小的内存
kfifo_alloc()和kfifo_init()的size必须是2的幂。
// 静态声明
DECLARE_KFIFO(name, size); // size必须是2的幂
INIT_KFIFO(name);
3. 推入队列数据
unsigned int kfifo_in(struct kfifo *fifo, const void *from, unsigned int len);
// 把from指针所指的len字节数据拷贝到fifo所指的队列中。成功返回推入数据字节大小。
4. 摘取队列数据
unsigned int kfifo_out(struct kfifo *fifo, void *to, unsigned int len);
// 从fifo所指的队列中拷贝出长度为len字节的数据到to所指的缓冲中。成功返回拷贝的数据长度。
kfifo_out操作后的队列中不再有读取的数据。
unsigned int kfifo_out_peek(struct kfifo *fifo, void *to, unsigned int len, unsigned offset);
// offset指向队列中的索引位置
// 和kfifo_out类似,不同之处是出口偏移不增加,即摘取的数据在下次摘取时还在队列中。
5. 获取队列长度
// 获取kfifo队列的空间总体大小(以字节为单位)
static inline unsigned int kfifo_size(struct kfifo *fifo);
// 获取kfifo队列中已推入的数据大小
static inline unsigned int kfifo_len(struct kfifo *fifo);
// 获取kfifo队列中剩余可用空间大小
static inline unsigned int kfifo_avail(struct kfifo *fifo);
// 判断kfifo队列是否为空
static inline int kfifo_is_empty(struct kfifo *fifo);
// 判断kfifo队列是否满
static inline int kfifo_if_full(struct kfifo *fifo);
// 这两个函数判断为空或满时,返回非0值;否则返回0
6. 重置和撤销队列
重置kfifo,即抛弃队列中的所有内容
static inline void kfifo_reset(struct kfifo *fifo);
撤销使用kfifo_alloc()分配的队列
void kfifo_free(struct kfifo *fifo);
撤销使用kfifo_init()创建的队列时,需要释放相关的缓冲
7. 队列使用举例
已经创建一个名为fifo的8KB大小的kfifo
// 向队列中推入数据
unsigned int i;
for (i = 0; i < 32; i++) {
kfifo_in(fifo, &i, sizeof(i));
}
// 查看队列中数据
unsigned int val;
int ret;
ret = kfifo_out_peek(fifo, &val, sizeof(val), 0);
if (ret != sizeof(val)) {
return -EINVAL;
}
printk(KERN_INFO "%u\n", val);
// 摘取并打印kfifo中的所有数据
while (kfifo_avail(fifo)) {
unsigned int val;
int ret;
ret = kfifo_out(fifo, &val, sizeof(val));
if (ret != sizeof(val)) {
return -EINVAL;
}
printk(KERN_INFO "%u\n", val);
}
3. 映射
一个映射,也称为关联数组。是一个由唯一键组成的集合,每个键必然关联一个特定的值,这种键到值的关联关系称为映射。
映射至少支持三个操作:
Add (key, value)
Remove (key)
value = Lookup (key)
还没有学明白,明白之后再补充上
4. 二叉树
树结构是一个能提供分层的树型数据结构的特定数据结构。
二叉树是每个节点最多只有两个子节点的树。
1. 二叉搜索树
二叉搜索树(简称BST)是一个节点有序的二叉树,有以下法则:
1. 根的左分支节点值都小于根节点值
2. 右分支节点值都大于根节点值
3. 所有的子树也都是二叉搜索树
2. 自平衡二叉搜索树
平衡二叉搜索树是一个所有叶子节点深度差不超过1的二叉搜索树。
自平衡二叉搜索树是指其操作都试图维持(半)平衡的二叉搜索树。
1. 红黑树
红黑树是一种自平衡二叉搜索树。Linux主要的平衡二叉树数据结构就是红黑树。
之所以能维持半平衡结构,是因为有以下属性:
1. 所有的节点要么着红色,要么着黑色
2. 叶子节点都是黑色
3. 叶子节点不包含数据
4. 所有非叶子节点都是两个子节点
5. 如果一个节点是红色,则它的子节点都是黑色
6. 在一个节点到其叶子节点的路径中,如果总是包含同样数目的黑色节点,则该路径相比其他路径是最短的。
上述条件保证了最深的叶子节点的深度不会大于两倍的最浅叶子节点的深度。所以红黑树总是半平衡的。
2. rbtree
Linux实现的红黑树称为rbtree。定义在lib/rbtree.c文件,声明在<linux/rbtree.h>文件。
rbtree的根节点由数据结构rb_root表示。
// 创建一个红黑树
struct rb_root root = RB_ROOT;
树里的其他节点由结构rb_node表示。
红黑树的时间复杂度是对数关系。
rbtree的实现没有提供搜索和插入例程,需要用户定义。
5. 数据结构以及选择
Linux中最重要的四种数据结构:链表、队列、映射、红黑树。
优先选择链表的情况:
1)对数据集合的主要操作是遍历数据。
2)当性能并非首要考虑因素时
3)需要存储相对较少的数据项时
4)需要和内核中其他使用链表的代码交互时
5)需要存储一个大小不明的数据集合时
选择使用队列的情况:
1)符合生产者/消费者模式(即FIFO)时
2)需要一个定长缓冲时
选择使用映射的情况:
1)需要映射一个UID到一个对象时
2)Linux的映射接口针对UID到指针的映射
3)需要处理发给用户空间的描述符时
选择使用红黑树的情况:
1)需要存储大量数据,并且检索迅速时
如果没有执行太多次时间紧迫的查找操作,最好使用链表。
Linux还有一些其他的数据结构,比如:基树(trie类型)和位图。
6. 算法复杂度
渐进行为,指当算法的输入变得非常大或接近于无限大时算法的行为。
算法的伸缩度,当输入增大时算法执行的变化。
1. 算法
算法,是一系列的指令,可能有一个或多个输入,最后产生一个结果或输出。
从数学角度讲,一个算法好比一个函数。
2. 大O符号
大O符号用来描述增长率。
4. 时间复杂度
7. 小结
本节主要讲述了链表、队列、映射、二叉树数据结构。应该重用已经存在的内核基础设施。