Linux内核入门:内核中常用的数据结构和算法

Linux内核代码中广泛使用了数据结构和算法,其中最常用的就是链表红黑树.

1.链表

        Linux内核中大量使用了链表这种数据结构.在此对链表的基础知识不再赘述,在此之前可以去复习一下单向链表双向链表的基本使用即可.

        单向链表和双向链表在实际使用时有一些局限性,如数据区必须是固定数据,而实际需求是多种多样的.而C语言不比C++那样有模版这个特性,所以无法构建出一套通用的链表,因为每个不同的数据区需要一套链表.为此,Linux内核把所有链表操作方法的共同部分提取出来,把不同的部分留给代码编程人员去处理.

        Linux内核实现了一套纯链表的封装,链表节点数据结构只有指针区而没有数据区,另外还封装了各种操作函数,如创建节点函数,插入节点函数,删除节点函数,遍历节点函数等.

1.内核链表的简单描述

Linux内核链表使用 struct list_head 数据结构来描述.

<include/linux/types.h>

struct list_head
{
    struct list_head *next, *prev;
};

        struct list_head 结构体不包含链表节点的数据区,通常是嵌入其他数据结构,如 struct page 数据结构中嵌入了一个lru链表节点,通常是把page数据结构挂入LRU链表.

<include/linux/mm_types.h>

struct page
{
    ...
    struct list_head lru;
    ...
};

2.链表初始化       

对链表头的初始化有两种方法,一种是静态初始化,还有一种动态初始化.

把 next 和 prev 指针都初始化指向自己,这样便初始化了一个带头节点的空链表.

静态初始化

<include/linux/list.h>

//静态初始化
#define LIST_HEAD_INIT(name) { &(name), &(name) }

#define LIST_HEAD(name) \
    struct list_head name = LIST_HEAD_INIT(name)

        静态初始化使用了两个宏函数来实现

        LIST_HEAD(name) 展开是这样的:

struct list_head name = { &(name), &(name) }

        这是结构体初始化方式中的顺序初始化.进一次转换等价于:

struct list_head name = 
{ 
    struct list_head *next = &(name), 
    struct list_head *prev = &(name) 
}

动态初始化

//动态初始化
static inline void INIT_LIST_HEAD(struct list_head *list)
{
    list->next = list;
    list->prev = list;
}

        这个容易理解,把提前定义好的list传入初始化函数,使其两个成员都指向自己.

3.链表节点的添加

        添加节点到一个链表中,内核提供了几个接口函数,如 list_add() 是把一个节点添加到表头, list_add_tail()是插入到表尾.

<include/linux/list.h>

void list_add(struct list_head *new, struct list_head *head);
list_add_tail(struct list_head *new, struct list_head *head);

4.链表的遍历

        对链表的操作少不了遍历,因此,遍历节点也有接口函数

#define list_for_each(pos, head) \
    for (pos = (head)->next; pos != (head); pos = pos->next)

        当初对于只会用宏进行一些简单替换的我看到这个用宏来代替for循环简直高兴不已没想到宏还能这样用(可能博主太菜了哈哈哈).

        不过这个宏只是遍历一个一个节点的当前位置,那么如何获取节点本身的数据结构呢?

这里还需要使用 list_entry()宏:

#define list_entry(ptr, type, member) \
    container_of(ptr, type, member)

//container_of() 宏定义在 kernel.h 头文件中
#define container_of(ptr, type, member) ({                  \
    const typeof( ((type *)0)->member ) *__mptr = (ptr);    \
    (type *)( (char *)__mptr - offsetof(type,member) );})

#define offsetof(TYPE, MEMBER) ((size_t) &((TYPE *)0)->MEMBER)

这里的代码有点难以理解,但底层逻辑其实就是通过结构体变量的成员的地址来获得这个变量的地址.在此我们慢慢分析:

首先看向 offsetof() 宏, 它接收两个参数:

                TYPE: 某种类型

                MEMBER: 这个类型的一个成员

        它首先 把 0 转换为该类型的指针,然后指向这个成员,因为此时TYPE是一个空结构体,所以现在,这个指针的值就是MEMBER在TYPE中的偏移量.可以这样理解:此时在地址0临时有一个该类型的结构体变量,然后这个成员的地址就是偏移量的值.

示例:

#include <stdio.h>

#define offsetof(TYPE, MEMBER) ((size_t) &((TYPE *)0)->MEMBER)
typedef struct student
{
    int age;
    char name[10];
    int grade;
}student;

int main()
{
    student s1 = {1, "abc", 5};
    size_t r = offsetof(student, grade);
    printf("%zu\n", &s1);
    printf("%zu\n", r);
    printf("%zu\n", &s1.grade);
}

 结果:      

        可以思考一下为什么是 16.

然后 分析container_of()这个宏, 先说结果:返回type类型变量的地址

        它接收三个参数:

                ptr:  一个指针,并且这个指针指向结构体变量的member成员.

                type: 某种结构体的类型

                member: 这个结构体类型的一个成员

        const typeof( ((type *)0)->member ) *__mptr = (ptr);

        typeof在上一篇讲过,它的主要用途是在宏定义中动态地确定参数的类型.所以这个宏定义了一个常量指针,其类型是: 把0转换为该类型的指针并指向这个成员再通过typeof宏转换.此时这个类型即这个成员的数据类型,以这个类型定义指针 __mptr,并赋值为指向这个成员的指针.

        (type *)( (char *)__mptr - offsetof(type,member) );

      将上一步的__mptr指针转换为char*类型,因为char是C语言中最小的基本类型,因此 char* 类型的指针可以访问内存中的任意地址.然后把__mptr减去在上面算出来的这个成员在结构体中的偏移量,就得到了这个结构体变量的真实地址.

       

2.红黑树

        红黑树是一种特殊的二叉树,被广泛应用在内核的内存管理和进程调度中,用于将排序的元素组织到树中.

        简单介绍一下红黑树的特征:

  • 每个节点或红或黑,但根节点一定是黑色.
  • 每个叶子结点是黑色的
  • 如果节点都是红色,那么两个子节点都是黑色
  • 从一个内部节点到叶节点的简单路径上,对所有的叶子节点来说,黑色节点的数目都是相同的.

        红黑树的一个优点是:所有的重要操作(如插入,删除,搜索)都可以在O(log n)时间内完成, 其中n 为节点数.

        现在来简化一下内核的红黑树并简单实现其功能

1.数据结构的定义

//红黑树节点
struct rb_node
{
    struct rb_node* rb_left;//左孩子
    struct rb_node* rb_right;//右孩子
    unsigned long __rb_parent_color;//父节点颜色以及其他信息
#define RB_RED      0
#define RB_BLACK    1
    //...
}__attribute__((aligned(sizeof(long))));
typedef struct rb_node rb_node;

// 红黑树根节点
typedef struct rb_root {
    struct rb_node *rb_node;
}rb_root;

typedef struct mytype
{
    struct rb_node node;
    int key;
    int data;
}mytype;

rb_root mytree = RB_ROOT;//根节点

其中比较重要的是 红黑树节点的成员组成:

  • rb_subtree_last:指向当前节点的右子树的最后一个节点
  • rb_left:指向当前节点的左子结点
  • rb_right:指向当前节点的右子节点
  • rb_parent_color:保存父节点的地址和颜色信息.其中,最低位表示节点颜色(RB_RED/RB_BLACK)
  • rb_rightmost:指向当前节点的右子树的最右节点
  • rb_leftmost:指向当前节点的左子树的最左节点

最后结构体定义使用了8字节对齐分配方式.

2.红黑树的查找

//根据key来查找节点
mytype *my_search(rb_root *root, int new)
{
    rb_node *node = root->rb_node;//从根节点开始遍历

    while (node)    //若当前节点不为空
    {
        mytype *data = container_of(node, mytype, node);//返回 “node” 所在的结构体定义的变量的地址

        if (data->key > new)
        {
            node = node->rb_left;//所寻的节点在当前节点的左边
        } else if (data->key < new)
        {
            node = node->rb_right;//所寻的节点在当前节点的右边
        } else
            return data;//找到了     
    }
    return NULL;//没找到
}

代码和普通的平衡二叉树的查找差不多,但是这里代入函数的是红黑树根节点,而返回的是我们定义的mytype数据结构的指针.所以使用了上面学的container_of()宏.

此时可能会疑惑,为什么不返回 rb_node 的指针?

        因为内核最底层的红黑树的除根节点外的节点都是 rb_node 类型的,而我们要运用内核的红黑树时就可能需要定义新的数据类型,所以这个新的数据类型必须包括我们需要的数据成员还有指向红黑树节点的指针.所以查找等其他对内核红黑的实际运用都必须对底层红黑树节点的和对新数据结构的操作相结合,即能互相找到其地址并对其成员进行操作,如上面例子所示:在不知道这个包含key的mytype变量 保存在哪里,但是它是用红黑树保存起来的,能否通过红黑树根节点key值来修改mytype结构中data的值呢?显然是可以的,通过container()这个宏.(个人理解欢迎指正)

        可以把内核的红黑树理解成C++中的父类,而我们定义的的新类型就是继承这个父类并有自己的成员,mytype就是子类,key和data是子类自己的成员.而内核中的红黑树节点rb_node是不包含key的,只有颜色,因此此红黑树只是一个特殊的二叉树而已,但是加入这个有key的子类后,这个红黑树就变成了我们所能理解的自平衡二叉树!

3.红黑树的节点的添加

int my_insert(rb_root *root, mytype *data)
{
    rb_node **new = &(root->rb_node), *parent = NULL;

    //寻找可以添加新节点的位置
    while (*new)
    {
        mytype *this = container_of(*new, mytype, node);//获取当前 *new 所在的mytype变量的地址

        parent = *new;
        if (this->key > data->key)
        {
            new = &((*new)->rb_left);
        } else if (this->key > data->key)
        {
            new = &((*new)->rb_right);
        } else
            return -1;
    }

    //添加新节点
    rb_link_node(&data->node, parent, new);
    rb_insert_color(&data->node, root);

    return 0;
}

有了上面的解释后理解这段代码也不是很难了,只需注意:使用内核的数据结构都是为我们自己的数据服务的.

对红黑树底层的操作都在内核自带的函数(函数名以rb开头)中(如旋转).

4.红黑树的初始化

static int __init my_init(void)
{
    int i;
    mytype *data;
    rb_node *node;

    //插入元素
    for (i = 0; i < 20; i+=2)
    {
        data = kmalloc(sizeof(mytype), GFP_KERNEL);
        data->key = i;
        data->data = i + 1;
        my_insert(&mytree, data);
    }

    //遍历红黑树并打印所有节点并打印所有节点的data的值
    for (node = rb_first(&mytree); node; node = rb_next(node))
    {
        printk("data = %d\n", rb_entry(node, mytype, node)->data);
    }
    return 0;  
}
module_init(my_init);

函数的定义中,函数名和返回值类型中间的 "__init":

        这是一个特殊标记,用于向Linux内核表明这是一个初始化函数.当编译器遇到这种标记时,会将相应的函数放入特殊的初始化函数列表中,在模块加载时,内核会遍历这个列表,并执行列表中所有的函数,一旦执行完毕,这些函数所占用的呢存将被释放,以避免浪费系统资源.

简单说一下这里使用的一些非红黑树函数

        kmalloc : 是Linux内核中的一种内存分配函数,用于分配连续的物理内存.

void *kmalloc(size_t size, gfp_t flags);
  • size: 分配的大小,以字节为单位
  • flags: 分配标志,用于控制分配行为

       常用的分配标志有:

  • GFP_KERNEL:内核空间分配标志,通常用于分配常规内存.
  • GFP_ATOMIC:原子分配标志,用于在内核空间中进行非阻塞的内存分配.
  • GFP_NOWAIT:立即分配标志,用于在分配内存时使调用者立即返回,如果无法立即分配内存,则返回错误
  • GFP_DMA:DMA分配标准,用于分配适合DMA(直接内存访问)的内存.

        因为kmalloc分配的内存地址是8字节对齐的,这意味着分配的内存地址的低3位都是0,这样可以提高内存访问效率.

        与malloc不同的是,kmalloc分配的内存位于内存空间而不是用户空间,因此应用程序不能直接访问,但是可以通过内核函数(如:kfree)来释放分配的内存.

        printk:作用类似于C语言中的printf函数,但它主要用于向控制台输出调试信息和错误信息.

        除了和printf一样有可变参数列表和格式化字符串的功能外,它还能允许你指定消息的优先级.

        如:

  • KERN_EMERG:紧急消息,对应的日志级别为0,通常用于记录系统崩溃,无法恢复的错误等情况.
  • KERN_INFO:信息性消息,对应的日志级别为6,通常用于记录系统状态,操作结果等.
  • KERN_ERR:错误消息,对应的日志级别为3,通常用于记录错误情况.

        使用优先级:

printk(KERN_EMERG "这是一个紧急消息!\n");

        输出一条调试消息:

printk(KERN_DEBUG "这是一条调试消息\n");

        通过使用不同的优先级前缀,可以控制消息在控制台上的输出,从而更好地调试和监视系统的运行状态.       

5.红黑树的销毁

static void __exit my_exit(void)
{
    mytype *data;
    rb_node *node;
    for (node = rb_first(&mytree); node; node = rb_next(node))
    {
        data = rb_entry(node, mytype, node);
        if (data)
        {
            rb_erase(&data->node, &mytree);
            kfree(data);
        }
    }
}
module_init(my_exit);

其中rb_erase是从红黑树中删除节点的函数并保持红黑树的平衡.

3.无锁环形缓冲区

        生产者与消费者模型是计算机里一个比较常见的一种模型.生产者产生数据,消费者消耗数据,如一台网络设备,硬件设备接收网络包,然后应用程序读取网络包.

        环形缓冲区是实现生产者和消费者模型的经典算法.环形缓冲区通常有一个读指针和一个写指针.读指针相当于消费者,指向环形缓冲区中可读的数据,写指针相当于生产者,指向环形缓冲区中可写的数据.通过移动读指针和写指针实现缓冲区数据的读取和写入.

        在Linux内核中,KFIFO是采用无锁环形缓冲区实现.FIFO我们都知道,是先进先出的数据结构,第一个就想到管道(Pipe)这一数据结构.它采用环形缓冲区的方法来实现,并提供了一个无边界的字节流服务.而采用环形缓冲区的好处是:当一个数据元素被消耗后,其余的数据元素不需要移动其存储位置,从而减少了复制带来的开销,提高了效率.

1.创建KFIFO

        在使用KFIFO之前需要进行初始化,这里有静态初始化和动态初始化两种方式.

动态初始化:

<include/linux/kfifo.h>

struct kfifo* kfifo_alloc(unsigned int size, gfp_t gfp_mask, spinlock_t* lock);

        参数说明:

  • size:指定环形缓冲区的大小,单位为字节.
  • gfp_mask:用于内存分配的标志,上面有说过.
  • lock:可选的互斥锁,用于保护环形缓冲区的并发访问

静态初始化:

        可以使用下面的宏:

#define DEFINE_KFIFO(fifo, type, size)
#define INIT_KFIFO(fifo)

2.入列

        把数据写入KFIFO环形缓冲区可以使用kfifo_in()函数接口.

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

        该函数把buf指针指向的n个数据复制到KFIFO环形缓冲区.

        参数说明:

  • fifo:指向内核环形缓冲区的指针.
  • buf:指向要添加到环形缓冲区中的数据的指针
  • len:要添加的数据的长度,单位为字节.

        函数说明:

        该函数会将数据从 buf 指向的内存区域复制到环形缓冲区中,最多复制 len 字节.若环形缓冲区已满,那么只复制部分数据,直到缓冲区中有足够的空间来容纳所有数据.函数返回实际添加到环形缓冲区中的数据量,单位为字节,若返回的长度小于 len ,那么表示环形缓冲区已满,无法再添加更多的数据.

3.出列

        从KFIFO环形缓冲区中列出或者摘取数据可以使用kfifo_out()函数接口.

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

      

  • fifo:指向内核环形缓冲区的指针.
  • buf:指向用于存放环形缓冲区中取出的数据的指针
  • len:要取出的数据的最大长度,单位为字节.

        顾名思义同入列一样,如果缓冲区中的数据元素小于len个,那么复制出去的数据元素小于len个.

4.获取缓冲区大小

        KFIFO提供了几个接口函数来查询环形缓冲区的状态.

#define kfifo_size(fifo)
#define kfifo_len(fifo)
#define kfifo_is_empty(fifo)
#define kfifo_is_full(fifo)
  •  kfifo_size()用来获取环形缓冲区的大小,也就是最大可以容纳多少个数据元素.
  •  kfifo_len()用来获取当前环形缓冲区有多少个有效数据元素.
  •  kfifo_is_empty()判断环形缓冲区是否为空.
  •  kfifo_is_full()判断环形缓冲区是否为满.

5.与用户空间数据交互

        KFIFO还封装了两个函数与用户空间数据交互.

#define kfifo_from_user(fifo, from, len, copied)
#define kfifo_to_user(fifo, to, len, copied)
  •  kfifo_from_user()是把from指向的用户空间的len个数据元素复制到KFIFO中,最后一个参数copied 表示成功复制了几个数据元素.
  • kfifo_to_user()则相反,把KFIFO的数据元素复制到用户空间.

        虚拟FIFO设备的驱动程序实现时会采用这两个接口函数.

4.总结

        Linux内核使用的数据结构最主要的就是这些,但需要知道这些数据结构只拥有它们最基本的一些功能,相当于C++中的"父类",我们要使用的话只能创造"子类",而C语言没有"类"这个概念,想要实现就得另辟蹊径,container_of()这个宏就是为此而诞生,不得不佩服C语言的强大之处.

  • 12
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值