4、第一个内核模块:编写Hello World模块、Makefile编写、编译加载卸载流程及dmesg日志查看

内核模块(Kernel Module)是Linux内核提供的一种动态加载机制,允许我们在系统运行期间向内核添加功能,而无需重新编译整个内核或重启系统。编写第一个“Hello World”模块是理解内核模块开发流程的经典起点。本章将围绕模块的基本结构、Makefile的编写规范、编译加载卸载的标准操作流程以及通过dmesg查看内核日志的方法展开,帮助读者建立起内核模块开发的最小闭环认知。

Hello World 内核模块开发链路 hello.c module_init / module_exit printk(KERN_INFO ...) Makefile obj-m += hello.o KDIR := /lib/modules/$(shell uname -r)/build make 生成 hello.ko 编译内核模块 编写 执行 insmod hello.ko 加载模块到内核 调用 module_init dmesg 查看内核环形缓冲区 Hello, World! rmmod hello 卸载模块 调用 module_exit 拷贝到目标环境(或本机) 输出 验证后 关键命令 make → insmod hello.ko → dmesg | tail → rmmod hello → dmesg | tail

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文件系统或注册字符设备,进一步深入内核编程的世界。