4、第一个内核模块:编写Hello World模块、Makefile编写、编译加载卸载流程及dmesg日志查看
内核模块(Kernel Module)是Linux内核提供的一种动态加载机制,允许我们在系统运行期间向内核添加功能,而无需重新编译整个内核或重启系统。编写第一个“Hello World”模块是理解内核模块开发流程的经典起点。本章将围绕模块的基本结构、Makefile的编写规范、编译加载卸载的标准操作流程以及通过dmesg查看内核日志的方法展开,帮助读者建立起内核模块开发的最小闭环认知。
4.1 内核模块的基本结构:从入口到出口
一个标准的内核模块至少包含两个核心函数:模块加载入口(module_init)和模块卸载出口(module_exit)。这两个函数通过宏定义告知内核在何时执行初始化与清理工作。与用户态程序不同,内核模块没有main()函数,其执行流由内核在模块加载和卸载时主动回调。
Hello World模块的完整代码如下:
#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>
static int __init hello_init(void)
{
printk(KERN_INFO "Hello, World! Module loaded.\n");
return 0; // 返回0表示加载成功
}
static void __exit hello_exit(void)
{
printk(KERN_INFO "Goodbye, World! Module unloaded.\n");
}
module_init(hello_init);
module_exit(hello_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple Hello World kernel module");
MODULE_VERSION("1.0");
代码中的__init和__exit是内核提供的属性标记,用于优化内存布局:__init标记的函数在初始化完成后其内存可以被释放,__exit标记的函数在模块编译进内核(而非动态加载)时会被丢弃。这种设计体现了内核开发对内存使用的极致追求。
printk是内核态的打印函数,与用户态的printf类似,但输出目标是内核环形缓冲区(kernel ring buffer),而非标准输出。KERN_INFO是日志级别宏,表示这是一条常规信息。日志级别决定了消息是否会被立即显示在控制台上,以及其在dmesg中的优先级。
核心原理:模块加载时,内核会为模块分配内存空间,解析其符号表,并调用module_init指定的函数。如果该函数返回0,则模块加载成功;返回负数则加载失败,内核会回滚所有已分配的资源。卸载时,内核调用module_exit函数,执行清理工作,然后释放模块占用的内存。
4.2 Makefile编写:连接源码与内核构建系统
内核模块的编译不能直接使用gcc,而必须借助内核的Kbuild构建系统。Makefile的核心作用是告诉Kbuild系统“我需要编译哪个模块”,并指定内核源码树的位置。一个最小可用的Makefile如下:
obj-m := hello.o
KDIR := /lib/modules/$(shell uname -r)/build
PWD := $(shell pwd)
all:
$(MAKE) -C $(KDIR) M=$(PWD) modules
clean:
$(MAKE) -C $(KDIR) M=$(PWD) clean
逐行解析:obj-m := hello.o声明了目标模块对象文件;KDIR指向当前运行内核的构建目录(通常是一个符号链接,指向完整的内核源码树或内核头文件包);PWD记录当前工作目录。all目标调用内核的Makefile,并通过M=$(PWD)参数指定外部模块的源码位置。clean目标用于清理编译产物。
编译完成后,目录下会生成hello.ko文件,这就是最终的内核模块二进制。此外还会生成hello.mod.c、hello.mod.o、modules.order等中间文件,这些是Kbuild系统的辅助产物,通常不需要手动关注。
| 编译产物 | 说明 | 是否需要保留 |
|---|---|---|
| hello.ko | 最终的内核模块文件 | 是,用于加载 |
| hello.o | 目标文件,由hello.c编译生成 | 否,可清理 |
| hello.mod.c | 模块元信息源文件(自动生成) | 否,可清理 |
| hello.mod.o | 模块元信息目标文件 | 否,可清理 |
| modules.order | 模块编译顺序记录 | 否,可清理 |
| .hello.ko.cmd / .hello.o.cmd | 编译命令记录文件 | 否,可清理 |
经验提醒:如果目标系统与编译系统的内核版本不一致,必须使用目标系统的内核源码树进行交叉编译。对于嵌入式开发,通常需要设置ARCH和CROSS_COMPILE环境变量。例如:make ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf-。
4.3 编译、加载、卸载与日志查看:完整操作流程
下面通过一个完整的操作序列,演示从编译到验证的全过程。假设当前工作目录为~/hello_module,包含hello.c和Makefile两个文件。
# 1. 编译模块
$ make
make -C /lib/modules/5.15.0-91-generic/build M=/home/user/hello_module modules
make[1]: Entering directory '/usr/src/linux-headers-5.15.0-91-generic'
CC [M] /home/user/hello_module/hello.o
MODPOST /home/user/hello_module/Module.symvers
CC [M] /home/user/hello_module/hello.mod.o
LD [M] /home/user/hello_module/hello.ko
make[1]: Leaving directory '/usr/src/linux-headers-5.15.0-91-generic'
# 2. 查看生成的模块文件
$ ls -l hello.ko
-rw-r--r-- 1 user user 12480 Jan 15 10:30 hello.ko
# 3. 加载模块(需要root权限)
$ sudo insmod hello.ko
# 4. 验证模块是否已加载
$ lsmod | grep hello
hello 16384 0
# 5. 查看内核日志
$ dmesg | tail -5
[ 1234.567890] Hello, World! Module loaded.
# 6. 卸载模块
$ sudo rmmod hello
# 7. 再次查看日志,确认卸载信息
$ dmesg | tail -3
[ 1234.567890] Hello, World! Module loaded.
[ 1298.123456] Goodbye, World! Module unloaded.
# 8. 清理编译产物
$ make clean
上述流程中,insmod和rmmod是加载和卸载模块的传统命令。现代Linux系统也支持modprobe命令,但modprobe会处理模块依赖关系,对于无依赖的简单模块,两者效果相同。lsmod命令用于列出当前已加载的所有模块,其输出包括模块名、内存占用大小和引用计数。
风险提示:如果insmod后没有任何输出,且dmesg中也没有日志,请检查:1)当前用户是否有权限读取内核日志(可能需要sudo dmesg);2)内核是否开启了CONFIG_PRINTK选项;3)日志级别是否被/proc/sys/kernel/printk过滤。此外,如果模块加载失败,insmod会返回错误码,可通过echo $?查看,并结合dmesg中的错误信息定位问题。
4.4 案例拆解:一次典型的模块开发排障过程
下面通过一个实际开发场景,展示当模块加载失败时如何系统性地排查问题。
背景与约束:某开发者在Ubuntu 22.04(内核版本5.15.0-91-generic)上编写了一个Hello World模块,但执行sudo insmod hello.ko时,终端没有任何输出,模块也没有加载成功。
初始现象:
$ sudo insmod hello.ko
$ echo $?
1
$ lsmod | grep hello
(无输出)
分析判断过程:返回码1表示操作失败。首先查看内核日志:
$ sudo dmesg | tail -10
[ 3456.789012] hello: module verification failed: signature and/or required key missing - tainting kernel
[ 3456.789013] hello: module loaded successfully
日志显示“module verification failed”,但模块仍然加载了。这说明问题可能出在insmod的返回值上。进一步检查发现,insmod返回1是因为模块加载后立即被系统自动卸载了——原因是模块的module_init函数返回了非零值。
实际操作步骤:检查hello.c源码,发现hello_init函数中有一行return -1;(原本应返回0)。修正为return 0;后重新编译加载:
$ make clean && make
$ sudo insmod hello.ko
$ echo $?
0
$ dmesg | tail -3
[ 3500.123456] Hello, World! Module loaded.
结果对比:修正前模块加载失败且无明确提示;修正后模块正常加载,日志输出正确。insmod的返回码从1变为0,lsmod也能看到模块信息。
风险与代价:如果module_init返回负值,内核会认为初始化失败并自动执行清理操作,但不会自动调用module_exit。因此,在module_init中已分配的资源(如内存、注册的设备号)必须手动释放,否则会造成资源泄漏。
复盘经验:1)insmod的返回码是判断加载是否成功的第一指标;2)dmesg是定位模块问题的核心工具,任何异常都应先查看日志;3)module_init的返回值必须谨慎处理,成功时返回0,失败时返回负的错误码(如-ENOMEM、-EINVAL)。
4.5 常见误区与总结复盘
在编写和编译第一个内核模块时,初学者常遇到以下几类问题:
- 头文件缺失:忘记包含
<linux/module.h>或<linux/init.h>,导致编译报错。这两个头文件是模块开发的基础依赖。 - Makefile缩进错误:Makefile中的命令必须以Tab键开头,不能使用空格。这是初学者最常犯的错误之一。
- 内核版本不匹配:使用
uname -r获取的版本与KDIR指向的源码树版本不一致,导致编译失败或加载时出现Invalid module format错误。 - 权限不足:加载和卸载模块需要root权限,普通用户执行
insmod会提示Operation not permitted。 - printk日志级别混淆:误将
KERN_INFO写成KERN_ALERT,导致日志显示行为不同。日志级别从高到低为:KERN_EMERG、KERN_ALERT、KERN_CRIT、KERN_ERR、KERN_WARNING、KERN_NOTICE、KERN_INFO、KERN_DEBUG。
总结复盘:编写第一个内核模块的核心要点可以归纳为“一个结构、两个函数、三个命令、四个检查”。一个结构指模块的module_init/module_exit框架;两个函数指printk和return的正确使用;三个命令指make、insmod、rmmod;四个检查指检查内核版本、检查Makefile缩进、检查dmesg日志、检查lsmod输出。掌握这些基础,后续开发字符设备、网络驱动等复杂模块时就能驾轻就熟。
通过本章的学习,读者应能够独立完成一个内核模块从源码编写到编译、加载、验证、卸载的完整生命周期管理。下一阶段的学习中,可以尝试在模块中增加参数传递、访问/proc文件系统或注册字符设备,进一步深入内核编程的世界。