Linux内核模块补充知识

  • 在正式编写内核模块第一个程序之前,还需要了解一些关于Linux内核的知识。包括如下:
    • Linux内核模块与Linux用户程序的对比
    • Linux内核模块的可用的函数
    • Linux内核空间与用户空间
    • Linux内核模块的命名空间与Linux的代码空间(虚拟内存在内核和进程上的体现)
    • Linux内核模块最常见的一类 设备驱动程序相关的介绍

内核模块与用户程序对比

  • 用户程序:
    • 用户程序启动一般是从start->libc_start_main->libc_start_call_main->main这样的一个启动流程
    • 用户程序结束一般是执行完main函数之后返回到libc_start_call_main这边再调用exit函数从而结束一个进程的运行。
    • 用户程序的库函数:用户程序使用的printf()等库函数都是需要进行系统调用这一步。库函数完全由用户空间运行,为系统调用提供一个方便的接口。
  • 内核模块:
    • 内核模块的启动:一般就是从init_module或者module_init指定的函数作为入口。这个入口函数会告诉内核模块提供了什么功能并进行设置然后返回,之后模块处于空闲状态,当内核需要用到该模块的代码时就会执行该模块的代码。
    • 内核模块的结束:一般就是从cleanup_module或者module_exit指定的函数作为结束。这个退出函数是撤销入口函数所做的一切,注销已注册的功能。注意:每个模块都必须同时拥有入口函数和出口函数
    • 内核模块的函数:内核模块的符号在insmod时解析,符号的定义来自内核本身,要查看导出的符号,可以查看/proc/kallsyms这个文件目录。注意:可以通过编写替换掉内核系统调用模块,这样用户程序在进行某个进行系统调用的时候执行的并不是正常的系统调用,而是被替换掉的,这种技术在rootkitbootkit很常见;但是替换的目的也可以用于好的地方
  • 使用cat /proc/kallsyms命令查看内核符号表:

image-20260605230412027

内核模块可用函数

  • 对于监控用户程序的系统调用情况,可以使用strace命令对用户程序进行监控。
1
2
3
4
5
6
7
8
#include <stdio.h>
int main()
{
printf("hello");
return 0;
}

// gcc -Wall -o test test.c
  • 使用命令strace ./test,就会显示如下输出,strace是用于跟踪一个进程执行过程中发生的系统调用接收到的信号

image-20260604220143393

内核空间与用户空间

  • 内核管理对硬件资源的访问,如显卡、硬盘和内存。CPU可以在不同的模式下运行,每种模式提供不同程度的自由度。其中Intel 80386架构有4种这样的模式,称为环。Unix使用其中两种:环0即超级用户模式,允许一切操作;最低环即用户模式。

image-20260605234549921

  • 库函数在用户模式下运行,它们调用系统调用,系统调用作为内核的一部分在超级用户模式下执行。完成后,执行回到用户模式。
  • 所以根据上面的介绍,可以给用户态内核态下一个定义:
    • 用户态:CPU 运行在 ring3 + 用户进程运行环境上下文。
    • 内核态:CPU 运行在 ring0 + 内核代码运行环境上下文。

模块命名空间与代码空间

  • 在小型C程序中,变量名在局部范围内使用。但是对于大型C项目中的代码,全局变量可能会冲突。这个问题被称为命名空间污染

    • 内核代码(即使是最小的模块)需要链接到整个内核,因此命名空间污染需要非常重视。
    • 最佳做法有下面两种:
      • 将所有变量声明为static 并使用定义良好的小写前缀。
      • 可以声明一个符号表将其注册到内核。
  • 现代操作系统都有虚拟内存机制,并且对于国内操作系统课程来说,内存管理好像都是在讲操作系统管理硬件的,似乎都没有讲操作系统对进程的内存管理。对于操作系统对进程的内存管理,学PWN的确实比较容易理解

    • 对于进程来说:
      • 当进程被创建时,内核为其分配代码、变量、堆栈等所需的物理内存。这块内存从0x00000000开始,并根据需要扩展。
      • 但是因为虚拟内存机制,不同进程的内存空间不重叠,每个进程访问地址0xdeadbeff的时候,实际上是访问不同的物理内存位置。也就是说每个进程的内存空间是相互独立的。并且进程通常不能访问另一个进程的空间。
    • 对于内核来说:
      • 内核拥有自己的内存空间。由于模块是动态插入/移除的内核代码,它共享内核的代码空间,而不是拥有自己的空间。
      • 因此,如果某个模块发生段错误,内核也会段错误,这就导致内核崩溃在Windows上常见的是蓝屏。因此一个差错就会破坏内核数据或代码,这适用于任何具有单体内核的操作系统。
      • 微内核则为模块提供了各自的代码空间。
  • 下面贴一张图表示虚拟内存代码的具体布局,从用户态到内核态,是32位Linux下的:

image-20260608122236937

  • 接下来这张图片是64位Linux下的虚拟内存布局:

image-20260608123815852

设备驱动程序

  • 在Linux内核模块中,最常见的一种模块类型就是设备驱动程序,为迎接(如电视卡或串口)提供功能。
  • Unix上,每个硬件都由/dev目录中的一个称为设备文件的文件夹表示。

image-20260606001558922

  • 驱动程序有主设备号以及次设备号,接下来以VM虚拟机中的/dev/cpu这个文件夹为例子,该虚拟机我定义了2个处理器,每个处理器4核,所以会出现如下:
    • 第一个数字203、202是主设备号,表示处理此硬件的驱动程序。
    • 第二给数字0、1、2是次设备号,表示驱动程序用于区分不同的硬件。
    • 注意:在这里应该算是两个驱动程序处理8个硬件,也就是cpuid、msr这两个驱动程序处理0~8个CPU核

image-20260606003242872

  • 设备一般分为两种类型:
    • 字符设备:字符设备则根据需要使用任意数量的字节,大多数设备是字符设备。
    • 块设备:块设备有请求缓冲区,可以优化请求顺序,这对存储设备非常重要,因为连续读取/写入附近扇区更快。块设备以固定大小的块接受输入/输出
    • 使用ls -l输出中的第一个字符b表示块设备,c表示字符设备,标识了设备类型。上面的cpu就显示为字符设备。
    • 已经分配的主设备号列表可以查看:/usr/src/linux/Documentation/devices.txt
  • 设备文件类型和普通文件类型是不一样的,所以不能使用mkdir创建设备文件,要使用mknod创建一个设备文件。
    • 例如:要创建一个名为coffee、主设备号为12、次设备号为2的新字符设备,就可以使用这样的一个命令:mknod /dev/coffee c 12 2
    • 注意:一般设备文件都是放在/dev这个目录下面的。
    • 注意:在测试的时候,通常是在工作目录下面创建设备文件。
  • 在给设备文件命名的时候一般会标识硬件类型或是某种标号,比如下面:
    • 下面这两种都是块设备,由同一个驱动程序处理,主设备号是2都代表同一个软盘驱动器
    • 其中一个软盘是标准的1.44MB格式,另一个是超级格式化1.68MB变体。
1
2
3
% ls -l /dev/fd0 /dev/fd0u1680
brwxrwxrwx 1 root floppy 2, 0 Jul 5 2000 /dev/fd0
brw-rw---- 1 root floppy 2, 44 Jul 5 2000 /dev/fd0u1680

字符设备文件与驱动

  • 补充了内核模块的一些相关知识后,接下来就开始写一个字符设备驱动程序。编写字符设备驱动程序的时候需要了解以下几点:
    • file_operationsfile结构体
    • 注册设备注销设备

file_operations结构体

  • file_operations结构体,该结构体定义在linux/fs.h中,包含指向驱动程序函数的指针:
    • 这些函数对设备会执行各种操作。
    • 每个字段对应处理特定请求操作的驱动程序函数地址。
  • 每个字符驱动程序都需要一个读函数,该结构体保存的都是函数地址,下面这个是在内核5.15.1中的file_operations结构体:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
struct file_operations {
struct module *owner;
loff_t (*llseek) (struct file *, loff_t, int);
ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
ssize_t (*read_iter) (struct kiocb *, struct iov_iter *);
ssize_t (*write_iter) (struct kiocb *, struct iov_iter *);
int (*iopoll)(struct kiocb *kiocb, bool spin);
int (*iterate) (struct file *, struct dir_context *);
int (*iterate_shared) (struct file *, struct dir_context *);
__poll_t (*poll) (struct file *, struct poll_table_struct *);
long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);
long (*compat_ioctl) (struct file *, unsigned int, unsigned long);
int (*mmap) (struct file *, struct vm_area_struct *);
unsigned long mmap_supported_flags;
int (*open) (struct inode *, struct file *);
int (*flush) (struct file *, fl_owner_t id);
int (*release) (struct inode *, struct file *);
int (*fsync) (struct file *, loff_t, loff_t, int datasync);
int (*fasync) (int, struct file *, int);
int (*lock) (struct file *, int, struct file_lock *);
ssize_t (*sendpage) (struct file *, struct page *, int, size_t, loff_t *, int);
unsigned long (*get_unmapped_area)(struct file *, unsigned long, unsigned long, unsigned long, unsigned long);
int (*check_flags)(int);
int (*flock) (struct file *, int, struct file_lock *);
ssize_t (*splice_write)(struct pipe_inode_info *, struct file *, loff_t *, size_t, unsigned int);
ssize_t (*splice_read)(struct file *, loff_t *, struct pipe_inode_info *, size_t, unsigned int);
int (*setlease)(struct file *, long, struct file_lock **, void **);
long (*fallocate)(struct file *file, int mode, loff_t offset,
loff_t len);
void (*show_fdinfo)(struct seq_file *m, struct file *f);
#ifndef CONFIG_MMU
unsigned (*mmap_capabilities)(struct file *);
#endif
ssize_t (*copy_file_range)(struct file *, loff_t, struct file *,
loff_t, size_t, unsigned int);
loff_t (*remap_file_range)(struct file *file_in, loff_t pos_in,
struct file *file_out, loff_t pos_out,
loff_t len, unsigned int remap_flags);
int (*fadvise)(struct file *, loff_t, loff_t, int);
} __randomize_layout;

注意:对于未实现的操作应将对应项设置为NULL,例如一个显卡驱动不会区读取目录。

  • 对于给这些结构体元素进行赋值操作,可以有以下两种办法:

    • 使用GCC扩展允许在现代驱动程序中看到的比较方便的赋值风格:
    1
    2
    3
    4
    5
    6
    struct file_operations fops = {
    read: device_read,
    write: device_write,
    open: device_open,
    release: device_release
    };
    • 使用C99语法而非GNU扩展,建议使用这种语法,这种语法可移植性比较强。
    1
    2
    3
    4
    5
    6
    struct file_operations fops = {
    .read = device_read,
    .write = device_write,
    .open = device_open,
    .release = device_release
    };
  • 对于任何未显式赋值的结构体成员都会被gcc初始化为NULL

  • struct file_operations的实例通常命名为fops

file结构体

  • 每个设备在内核中由一个file结构体表示,该结构体在linux/fs.h表示。这是内核级别的结构体,与glibc中的FILE不同。
    • 内核级别的file结构体:
      +
      +
      +
    • 用户级别的file结构体:
      • 有用户态的缓冲
      • glibc中的FILE通过int _fileno字段持有一个文件描述符,这个fd就是内核struct file在进程文件描述表中的索引。
  • 下面是内核版本为5.15.1版本file结构体:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
struct file {
union {
struct llist_node fu_llist;
struct rcu_head fu_rcuhead;
} f_u;
struct path f_path;
struct inode *f_inode; /* cached value */
const struct file_operations *f_op;

/*
* Protects f_ep, f_flags.
* Must not be taken from IRQ context.
*/
spinlock_t f_lock;
enum rw_hint f_write_hint;
atomic_long_t f_count;
unsigned int f_flags;
fmode_t f_mode;
struct mutex f_pos_lock;
loff_t f_pos;
struct fown_struct f_owner;
const struct cred *f_cred;
struct file_ra_state f_ra;

u64 f_version;
#ifdef CONFIG_SECURITY
void *f_security;
#endif
/* needed for tty driver, and maybe others */
void *private_data;

#ifdef CONFIG_EPOLL
/* Used by fs/eventpoll.c to link all the hooks to this file */
struct hlist_head *f_ep;
#endif /* #ifdef CONFIG_EPOLL */
struct address_space *f_mapping;
errseq_t f_wb_err;
errseq_t f_sb_err; /* for syncfs */
} __randomize_layout
__attribute__((aligned(4))); /* lest something weird decides that 2 is OK */
  • struct file的实例通常命名为filp,因此要避免使用struct file file这样的命名。
  • 大多数成员不直接由设备驱动程序使用,驱动程序只需使用在别处创建的file结构体。

注册与注销设备

  • 注册设备,字符设备通过设备文件,通常位于/dev访问。

    • 主设备号标识哪个驱动程序处理该设备文件;次设备号在驱动程序内部区分不同的设备。
    • 注册设备是通过linux/fs.h中的register_chrdev完成:
      • unsigned int major:请求的主设备号
      • const char *name:设备名称,将显示在/proc/devices
      • struct file_operations *fops:指向驱动程序的file_operations表的指针。
      • 注意1:返回值如果是负数就表示注册失败。
      • 注意2:次设备号没有被传入,内核不使用它,只有驱动程序需要。
      • 注意3:传入0作为主设备号会请求动态分配的主设备号,该主设备号会作为注册函数的返回值返回。这种方式分配的主设备号缺点就是无法提前创建设备文件。解决方案如下:
        • 驱动程序打印分配的号码,手动创建设备文件
        • 设备出现在/proc/devices中,使用shell脚本读取并创建文件。
        • 驱动程序可以在注册后自行调用mknod,在cleanup_module中调用rm
    1
    int register_chrdev(unsigned int major, const char *name, struct file_operations *fops);
  • 注销设备,在设备文件打开时一处内核模块会导致调用指向无效内存的位置,可能会跳转到另一个已加载的模块代码中,产生未定义行为或者直接段错误。

    • 可以使用计数器跟踪有多少进程正在使用该模块(会在/proc/modules的第三列中显示)。如果不为零,rmmod会失败。可以通过一下两个函数管理计数器:
      • try_module_get(THIS_MODULE):增加使用计数。
      • module_put(THIS_MODULE):减少使用计数。
    • 检查在sys_delete_module中自动进行,保持计数器的准确至关重要如果丢失,将永远无法卸载模块,这个时候就要重启电脑了。

示例程序

  • 接下来了解上面这几个之后,来创建一给示例程序来简单了解一下这个程序。该示例程序是一个名为chardev的字符驱动程序。

    • 读取其设备文件会返回一条消息,显示该设备已经被读取的次数。
    • 不支持写入设备的操作,如果进行写入设备的请求就会返回错误。
  • 首先创建一个chardev.h文件并写入如下内容:

1
2
3
4
5
6
7
8
9
10
11
12
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/fs.h>
#include <asm/uaccess.h>

int init_module(void);
void cleanup_module(void);
static int device_open(struct inode *, struct file *);
static int device_release(struct inode *, struct file *);
static ssize_t device_read(struct file *, char *, size_t, loff_t *);
static ssize_t device_write(struct file *, const char *, size_t, loff_t *);

  • 接着创建一个chardev.c文件并写入如下内容:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
/*
chardev.c 创建一个只读字符设备,显示从该设备文件读取的次数
*/

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/fs.h>
#include <asm/uaccess.h>
#include "chardev.h"


#define SUCCESS 0
#define DEVICE_NAME "chardev" /* 设备名,显示在/proc/devices中 */
#define BUF_LEN 80 /* 设备消息的最大长度 */

// 按照标准实验static定义变量
static int Major; /* 主设备号 */
static int Device_Open = 0; /* 设备是否打开 */
static char msg[BUF_LEN]; /* 从设备读取的消息 */
static char *msg_Ptr; /* 消息指针 */

static struct file_operations fops = {
.read = device_read,
.write = device_write,
.open = device_open,
.release = device_release
};

int init_module(void) {
// 调用register_chrdev注册字符设备,主设备号为0表示由系统分配
// register_chrdev在库函数 linux/fs.h 中声明
Major = register_chrdev(0, DEVICE_NAME, &fops);

// 注册失败返回负数,并进行错误处理
if (Major < 0){
printk(KERN_ALERT "Registering char device failed with %d\n", Major);
return Major;
}

//
printk(KERN_INFO "I was assigned major number %d. To talk to\n", Major);
printk(KERN_INFO "the driver, create a dev file with\n");
printk(KERN_INFO "'mknod /dev/%s c %d 0'.\n", DEVICE_NAME, Major);
printk(KERN_INFO "Try various minor numbers. Try to cat and echo to\n");
printk(KERN_INFO "the device file.\n");
printk(KERN_INFO "Remove the device file and module when done.\n");
return SUCCESS;
}


/*
模块卸载时调用函数
*/
void cleanup_module(void) {
// 注销设备
unregister_chrdev(Major, DEVICE_NAME);
}


// 实现设备文件的相关函数
// 当进程尝试打开设备文件时调用,例如" cat /dev/chardev"
static int device_open(struct inode *inode, struct file *file) {
static int counter = 0;

if (Device_Open)
return -EBUSY; // 设备已被打开,返回错误

Device_Open++;
// 将消息传递给缓冲区,该消息还会统计并输出打开该设备的次数
sprintf(msg, "I already told you %d times Hello world!\n", counter++);
msg_Ptr = msg; // 将消息指针指向消息缓冲区的开始
try_module_get(THIS_MODULE); // 增加模块的使用计数,防止模块被卸载
return SUCCESS;
}


static int device_release(struct inode *inode, struct file *file) {
Device_Open--; // 设备关闭,减少使用计数
module_put(THIS_MODULE); // 释放模块
return 0;
}

// 当已打开设备文件的进程尝试从中读取数据时调用
static ssize_t device_read(struct file *filp, char *buffer, size_t length, loff_t * offset)
{
// 实现写入缓冲区的字节数
int bytes_read = 0;

// 如果已经达到消息末尾,返回0表示文件结束
if (*msg_Ptr == 0)
return 0;

// 将实际数据放入缓冲区

while (length && *msg_Ptr) {
// 将一个字节从消息指针复制到用户空间缓冲区
put_user(*(msg_Ptr++), buffer++);
length--;
bytes_read++;
}
return bytes_read; // 返回读取的字节数
}

// 当进程写入设备文件时,例如 使用 echo "hi" > /dev/hello,会调用下面的函数
static ssize_t device_write(struct file *filp, const char *buff, size_t len, loff_t * off)
{
printk(KERN_ALERT "Sorry, this operation isn't supported.\n");
return -EINVAL; // 返回错误码,表示不支持写操作
}

MODULE_LICENSE("GPL");

  • 接着将该模块移动到bosybox文件系统中,并进行打包,然后使用qemu进行模拟,并使用inmod 命令加载模块。
  • 接着使用dmes | tail查看模块输出的信息,发现是init_module函数输出的信息

image-20260608104938678

  • 上面的信息告诉我们,如果要与该内核模块交互,我们先要使用mknod /dev/chardev c 242 0/dev目录下创建一个chardev的字符设备文件。

image-20260608105638260

  • 接下来我们对/dev/chardev这个文件的操作,就会调用相关的模块函数。这就揭示了Linux万物皆文件的思想,对模块的操作就是对文件的操作
  • 先来使用cat /dev/chardev命令,就会调用前面的device_open函数。

image-20260608105942489

  • 接着使用echo "hi" > /dev/chardev命令就会出现如下现象,也就是调用device_write的结果。

image-20260608110317532