深入浅出 Linux 内核模块,写一个内核版的 Hello World

0 阅读9分钟

本篇文章,我将会从最简单的 Hello World 内核模块入手,这虽然只是一个简单的代码演示,但我们绝不能仅仅停留在这点代码上面。我们要深入了解内核模块的本质,内核空间与用户空间的区别,编译加载机制,以及他们蕴含的 Linux 系统原理,这样才能为后续更复杂的驱动开发打下坚实的基础。

1. Linux 内核模块

1.1 内核模块的定义

Linux 内核模块 (Linux Kernel Module, LKM) 是一种可以动态加载到运行中的 Linux 内核、或者从内核中移除的编译好的目标文件。它们用于在运行时扩展内核的功能,而无需重新编译整个内核或重启系统。常见的应用场景包括设备驱动程序、文件系统、网络协议等。

现代 Linux 内核默认将许多功能(比如文件系统,设备驱动,网络协议)编译成模块,而不是直接静态链接进内核。这样内核镜像可以保持比较小的规模,提高启动速度和内存效率。

当需要一个新功能时,只需要加载模块就可以,不需要重启。

对于开发来讲也是非常便利的,每个模块可以独立编译,加载,卸载,便于调试。如果出错了直接卸载模块就行了。

不经常使用的功能以模块的形式存在,只在需要时才让它占用内存,对节省内存也有着重要意义。

1.2 用户空间与内核空间

  1. 用户空间是应用程序运行的环境,每个程序都有自己独立的虚拟地址空间,受内核严格管理,无法直接访问硬件或操作内核数据。如果程序崩溃,通常只会影响自身。
  2. 内核空间是内核运行的环境,拥有对所有硬件和系统资源的完全访问权限,并提供各种系统服务(比如内存管理,进程调度,设备驱动等)。所有进程共享同一个内核空间,内核中的错误可能导致系统崩溃 (Kernel Panic)。

内核模块运行在内核空间,这意味着内核模块拥有与内核同样高的权限,可以直接操作硬件和内核数据结构。这也意味着,内核模块的任何错误都可能导致系统不稳定甚至崩溃,因此编写时需要格外小心。

1.3 一些基本的管理命令

下面介绍一些常用的命令,这些命令都需要 root 权限:

  1. lsmod:列出已经加载的模块。
  2. modinfo:查看模块的详细信息。
  3. insmod:加载模块,需要完整路径,不处理模块的依赖关系。
  4. rmmod:卸载模块,不处理依赖。
  5. modprobe:智能加载或卸载模块,可以自动解析依赖。

2. 编写内核模块

我们现在要写的也可以说是最简单的内核模块,一个最简单的内核模块通常包含以下几个核心部分:

#include <linux/module.h>
#include <linux/kernel.h>static int __init hello_init(void)
{
    printk(KERN_INFO "Hello Linux!\n");
    return 0;
}
​
static void __exit hello_exit(void)
{
    printk(KERN_INFO "Bye Linux!");
    printk(KERN_EMERG "END\n");
}
​
module_init(hello_init);
module_exit(hello_exit);
​
MODULE_LICENSE("GPL");
MODULE_AUTHOR("XLP");
MODULE_DESCRIPTION("A Hello Linux Kernel Module");
​

2.1 代码核心内容分析

module_init(): 这是一个宏,用于指定当模块被加载时(使用 insmod 命令),内核应该执行哪个函数。并且这个函数必须返回 int 类型。如果返回 0,表示模块加载成功。如果返回非 0,表示加载失败。通常,在这个函数中完成资源的申请和初始化工作。

module_exit(): 这是一个宏,用于指定当模块被卸载时(使用 rmmod 命令),内核应该执行哪个函数。这个函数必须返回 void 类型。通常,在这个函数中完成资源的释放和清理工作。

可以看到,上面的代码中hello_init函数和hello_exit函数的返回类型分别为intvoid,已经替大家试过了,类型不对编译会失败。

hello_inithello_exit 函数前,我们看到了 __init__exit。这两个是GCC特有的宏,指示编译器将这些函数放置在内核镜像中的特定段。

__init标记的函数在模块加载成功后,其占用的内存可以被内核释放,这有助于减少内核的内存占用。

__exit标记的函数只在模块编译为可卸载模块时才有效,如果模块被静态编译进内核,__exit 标记的函数将被忽略。

2.2 内核打印函数 printk

printk() 是内核空间版本的 printf() 函数,用于将消息打印到内核日志缓冲区。其用法与 printf() 类似,但它支持不同的日志级别,用于指示消息的重要性。

下面介绍一下printk()函数的日志级别:

printk 的第一个参数通常是一个字符串,用于指定消息的优先级。这个字符串可以是下面这些:

  • <0> KERN_EMERG: 系统无法使用。
  • <1> KERN_ALERT: 需要立即采取行动。
  • <2> KERN_CRIT: 临界条件。
  • <3> KERN_ERR: 错误条件。
  • <4> KERN_WARNING: 警告条件。
  • <5> KERN_NOTICE: 正常但重要的条件。
  • <6> KERN_INFO: 普通信息性消息。这个是我们最常用的。
  • <7> KERN_DEBUG: 调试级别消息。

KERN_EMERG的级别最高,KERN_DEBUG的级别最低。如果使用printk时未指定日志级别,默认会使用 KERN_DEFAULT,其具体值取决于内核配置。在开发调试阶段,KERN_INFOKERN_DEBUG 是最常用的。通过使用不同的级别,我们可以控制哪些信息最终显示在终端或日志文件中。

2.3 模块元数据宏

  1. MODULE_LICENSE("GPL"): 一定要注意,这个是强制要求的。这是在声明模块的许可证,如果缺失,加载时会收到警告。其他常见的许可证还有 "GPL v2", "Dual BSD/GPL" 等,遵循许可证规定对于内核的兼容性和社区贡献至关重要。
  2. MODULE_AUTHOR(): 声明模块的作者,有没有问题不大。
  3. MODULE_DESCRIPTION(): 简要描述模块的功能,可有可无。
  4. MODULE_VERSION(): 模块的版本号,可有可无,上面代码中我就没用这个。

这些宏提供了关于模块的重要信息,虽然咱们练习时有的可以不写,但是写上还是比较好的。我们可以在命令行通过 modinfo 命令查看模块的一些信息,使用方法如下:

modinfo hello.ko

hello.ko这个内核模块是我们的c程序编译后生成的,后面会讲编译过程。

使用这条命令后的结果如下图:

1. modinfo结果.png

3. 编译内核模块

编译内核模块与编译普通用户空间程序有所不同,因为编译内核模块需要与内核源代码或内核头文件进行链接。通常,我们会使用一个特殊的 Makefile进行编译。

在编写Makefile之前,必须要保证当前使用的虚拟机系统上面有你要运行该内核模块的设备对应内核版本的头文件。

将上面的 C 代码保存为hello.c。下面就可以编写Makefile了:

KERNEL_DIR := /home/xlp/workspace/kernel  #这个要根据实际情况
​
PWD := $(shell pwd)
​
obj-m += hello.o
​
all:
    make -C $(KERNEL_DIR) M=$(PWD) ARCH=arm64 CROSS_COMPILE=aarch64-linux-gnu- modules
​
clean:
    make -C $(KERNEL_DIR) M=$(PWD) clean
​

KERNEL_DIR := /home/xlp/workspace/kernel这行是关键,这是一个硬编码的绝对路径,指向我们手动下载并进行管理的内核源码和该内核版本对应的内核构建目录,这个目录包含了编译内核模块所需的所有头文件和配置信息。

PWD := $(shell pwd)定义了一个变量 PWD,其值为当前工作目录的绝对路径。

obj-m += hello.o指定要构建的模块对象,这里表示从 hello.c编译生成一个名为 hello.ko 的内核模块。

Kbuild 系统读取这个变量后,会自动编译 hello.c得到 hello.o,然后链接生成 hello.ko,以及生成配套的 .mod.c.mod.o 等中间文件。

剩下的allclean是内核模块 Makefile 的构建目标部分,定义了当运行 makemake clean 时要执行的具体操作。它们是整个 Makefile 的执行入口,负责调用内核的 Kbuild 系统来编译或清理模块。

我们将这个Makefile文件与刚才的hello.c文件保存在同一目录下。切回命令行在该目录下执行make,就会开始编译了:

2. 编译.png

编译结束后我们就得到了hello.kohello.ko 就是我们最终需要加载的模块文件。

4. 加载,查看与卸载模块

4.1 insmod 加载模块

使用 insmod 命令加载编译好的 hello.ko 模块:

sudo insmod hello.ko

sudo 是必需的,因为加载内核模块需要root权限,如果加载成功,通常不会有任何输出,但如果 module_init 返回了非零值,insmod 会报错。

3. 加载模块.png

可以看到,执行后没有任何输出,这说明模块已经加载成功了。

4.2 查看模块信息

lsmod: 列出当前系统中所有已加载的内核模块。用法如下:

lsmod

执行这条命令后结果如下,可以看到显示了我们刚才加载的那个模块:

4. 查看模块(1).png

modinfo: 查看模块的详细信息,包括作者、描述、版本、许可证等,这些信息就是我们在 C 程序中通过宏定义的:

5. 查看模块(2).png

4.3 查看内核日志

还记得我们在 printk 中打印的“Hello, Linux!”吗?这些消息不会直接显示在终端,而是记录在内核日志缓冲区中。使用 dmesg 命令可以查看:

dmesg | tail

这条命令可以查看最新的 10 条日志。结果如下:

6. 内核日志(1).png

从图中可以看出,“Hello Linux!”已经成功打印到内核日志中了。

4.4 卸载模块

使用 rmmod 命令卸载模块:

sudo rmmod hello

结果如下:

7. 内核日志(2).png

执行这条命令后,终端弹出了一条消息:“END”。这是因为我们在使用printk打印这条消息时使用了KERN_EMERG宏,这个宏是优先级最高的,所以会在终端显示。

我们再查看内核日志时,可以同时看到KERN_INFO优先级的“Bye Linux!”KERN_EMERG优先级的“END”

5. 总结

到此,本篇文章就结束了。我们已经成功迈出了 Linux 内核模块开发的第一步,不仅学会了如何编写、编译、加载和卸载一个简单的内核模块,更重要的是深入理解了内核模块的运行环境、与用户空间的区别以及其背后的基本原理。

接下来的文章中,我们将不再局限于一个简单的内核模块,而是开始探索如何让内核模块真正与用户空间进行交互,感兴趣的朋友可以留一手关注。

💪