第一个内核模块:编写Hello World模块、Makefile编写、insmod/rmmod/dmesg调试、模块参数传递
从零开始学习Linux驱动编程,第一个里程碑就是亲手编写并加载一个内核模块。内核模块(Kernel Module)是Linux内核支持动态扩展功能的核心机制,它允许我们在不重新编译整个内核的情况下,将设备驱动、文件系统、网络协议等代码片段动态加载到运行中的内核里。本章将围绕一个最简单的Hello World模块,完整讲解模块的代码结构、Makefile编写规范、加载与卸载命令、日志调试方法,以及如何通过模块参数向内核空间传递用户态数据。这是后续所有字符设备驱动、平台驱动、中断处理等复杂主题的基石。
上图展示了内核模块从编写、编译到加载、调试、卸载的完整工程链路。用户态通过 insmod 和 rmmod 命令与内核态交互,模块内部通过 printk 输出日志到内核环形缓冲区,用户态使用 dmesg 查看。模块参数则允许在加载时向模块传递配置数据。下面我们逐步深入每个环节。
4.1 内核模块的核心原理与代码结构
内核模块本质上是一个可重定位的ELF目标文件(.ko),它并不像普通应用程序那样拥有独立的地址空间和main函数入口。模块被加载时,内核会将其代码段和数据段映射到内核地址空间,并解析符号引用。模块必须通过 module_init() 和 module_exit() 两个宏来声明初始化函数和退出函数,这两个宏在 <linux/module.h> 中定义。
一个最基础的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 from kernel module!\n");
return 0;
}
static void __exit hello_exit(void)
{
printk(KERN_INFO "Goodbye, kernel module unloaded.\n");
}
module_init(hello_init);
module_exit(hello_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple Hello World module");
MODULE_VERSION("1.0");
代码中 __init 和 __exit 是内核提供的属性标记。 __init 会将初始化函数放入特殊的 .init.text 段,该段在模块加载完成后会被释放,以节省内存。 __exit 标记的退出函数在模块编译进内核(非模块方式)时会被丢弃,因为内核本身不会卸载。 printk 是内核空间的打印函数,与用户态的 printf 类似,但输出目标是内核环形缓冲区,而不是标准输出。 KERN_INFO 是日志级别宏,表示普通信息。
模块还通过 MODULE_LICENSE 声明许可证,如果不声明GPL,内核会认为模块污染了内核,加载时会出现“tainted”警告。 MODULE_AUTHOR、MODULE_DESCRIPTION、MODULE_VERSION 是元信息,可通过 modinfo 命令查看。
printk 的日志级别非常重要。如果不指定级别,默认使用 DEFAULT_MESSAGE_LOGLEVEL,可能导致日志被内核的日志级别过滤掉而无法显示。建议在开发阶段使用 KERN_INFO 或 KERN_DEBUG,并配合 dmesg -n 8 设置控制台日志级别。
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:告诉Kbuild将hello.c编译成内核模块hello.ko。如果模块由多个源文件组成,例如hello_main.o和hello_func.o,则需要写成hello-objs := hello_main.o hello_func.o,且obj-m := hello.o。KDIR:指向当前运行内核的构建目录。/lib/modules/$(uname -r)/build是一个符号链接,指向完整的内核源码树或内核头文件包。在Ubuntu上需要安装linux-headers-$(uname -r)包。PWD:当前工作目录,用于告诉Kbuild模块源码的位置。$(MAKE) -C $(KDIR) M=$(PWD) modules:这是编译命令。-C切换到内核源码目录,M=$(PWD)指定模块源码目录,modules是目标。
编译时,Kbuild会先读取内核源码的顶层Makefile,然后进入 M 指定的目录,查找 obj-m 变量,并执行编译。整个过程会生成 hello.o、hello.mod.o、hello.mod.c、Module.symvers 等中间文件,最终生成 hello.ko。
| 生成文件 | 作用 |
|---|---|
| hello.o | 模块的目标文件,包含编译后的代码和数据 |
| hello.mod.o | 模块元信息目标文件,包含模块依赖和许可证信息 |
| hello.mod.c | 自动生成的C文件,定义模块的元数据(如依赖、许可证) |
| Module.symvers | 符号版本表,用于模块间符号引用检查 |
| hello.ko | 最终的内核模块文件,可被insmod加载 |
KDIR 指向的内核版本必须与运行内核完全一致。如果使用 uname -r 获取版本,但系统安装了多个内核头文件包,务必确认 /lib/modules/$(uname -r)/build 链接正确。版本不匹配会导致 insmod 报错 Invalid module format。
4.3 insmod/rmmod/dmesg调试实战
模块编译成功后,就可以进行加载和卸载操作。以下是完整的操作流程:
- 加载模块:使用
sudo insmod hello.ko。如果加载成功,没有任何输出,但可以通过lsmod查看模块是否在列表中。 - 查看日志:使用
dmesg查看内核日志。由于printk输出到环形缓冲区,需要dmesg读取。建议使用dmesg | tail -n 20查看最近日志。 - 卸载模块:使用
sudo rmmod hello。注意这里使用模块名hello,而不是文件名hello.ko。卸载后再次dmesg查看退出日志。
一个典型的调试会话如下:
$ sudo insmod hello.ko
$ lsmod | grep hello
hello 16384 0
$ dmesg | tail -n 5
[ 1234.567890] Hello World from kernel module!
$ sudo rmmod hello
$ dmesg | tail -n 5
[ 1234.567890] Hello World from kernel module!
[ 1235.123456] Goodbye, kernel module unloaded.
如果 insmod 失败,常见错误及排查方法:
Operation not permitted:当前用户没有权限,需要使用sudo或切换到root用户。Invalid module format:模块编译时的内核版本与当前运行内核不一致,或者模块使用了不兼容的编译器选项。重新用当前内核头文件编译。Unknown symbol in module:模块引用了内核未导出的符号,或者依赖的其他模块未加载。使用nm hello.ko查看未定义符号,确认内核是否导出。
insmod hello.ko 得到 Invalid module format。首先执行 modinfo hello.ko 查看 vermagic 字段,例如 5.15.0-91-generic SMP mod_unload modversions。然后执行 uname -r 确认当前内核版本。如果两者不一致,说明 KDIR 指向了错误的内核头文件。检查 /lib/modules/ 目录,确认是否存在与当前内核版本完全匹配的子目录。如果存在,修改Makefile中的 KDIR 路径后重新 make clean 再 make。如果版本一致但仍有问题,检查 gcc 版本是否与编译内核时使用的编译器一致,必要时使用 make ARCH=x86_64 CROSS_COMPILE= 指定架构。
4.4 模块参数传递机制
内核模块支持在加载时通过 insmod hello.ko param=value 的方式传递参数。这需要使用 module_param() 宏来声明参数。参数类型支持 int、long、charp(字符指针)、bool 等。以下是一个带参数的模块示例:
#include <linux/init.h>
#include <linux/module.h>
#include <linux/moduleparam.h>
static int count = 1;
static char *name = "world";
static int arr[4] = {1, 2, 3, 4};
static int arr_argc = 0;
module_param(count, int, 0644);
module_param(name, charp, 0644);
module_param_array(arr, int, &arr_argc, 0644);
MODULE_PARM_DESC(count, "Number of times to print");
MODULE_PARM_DESC(name, "Name to greet");
MODULE_PARM_DESC(arr, "An array of integers");
static int __init hello_init(void)
{
int i;
for (i = 0; i < count; i++) {
printk(KERN_INFO "Hello %s!\n", name);
}
for (i = 0; i < arr_argc; i++) {
printk(KERN_INFO "arr[%d] = %d\n", i, arr[i]);
}
return 0;
}
static void __exit hello_exit(void)
{
printk(KERN_INFO "Module unloaded.\n");
}
module_init(hello_init);
module_exit(hello_exit);
MODULE_LICENSE("GPL");
module_param(name, type, perm) 的三个参数分别是:参数名、类型、sysfs权限。 perm 为 0644 表示在 /sys/module/hello/parameters/ 目录下创建对应的文件,允许所有用户读取,root用户写入。如果设为 0,则不创建sysfs条目,参数只能在加载时指定,运行时不可查看或修改。
加载带参数的模块:
$ sudo insmod hello.ko count=3 name="Linux" arr=10,20,30
$ dmesg | tail -n 10
[ 1234.567890] Hello Linux!
[ 1234.567890] Hello Linux!
[ 1234.567890] Hello Linux!
[ 1234.567890] arr[0] = 10
[ 1234.567890] arr[1] = 20
[ 1234.567890] arr[2] = 30
对于数组参数,module_param_array 的第三个参数是一个指向 int 的指针,用于接收实际传入的元素个数。如果加载时未指定数组参数,则使用默认值,且 arr_argc 会被设置为默认数组的长度。
perm 设置了写权限),但修改后不会自动触发任何回调函数。如果需要在参数变化时执行特定逻辑,需要使用 module_param_cb() 注册回调。另外,charp 类型的参数指向的字符串内存由内核分配,模块内不要尝试修改其内容,也不要直接将其传递给需要 const char * 以外的函数。
4.5 常见误区与总结复盘
初学者在编写第一个内核模块时,经常遇到以下几类问题,这里逐一分析并给出解决方案。
| 误区 | 现象 | 根因 | 解决方案 |
|---|---|---|---|
忘记包含 moduleparam.h |
编译报错 implicit declaration of module_param |
模块参数宏定义在独立的头文件中 | 添加 #include <linux/moduleparam.h> |
使用 printf 代替 printk |
编译报错 undefined reference to printf |
内核空间没有标准C库 | 使用 printk,并注意日志级别 |
Makefile中 KDIR 路径错误 |
make 报错 No rule to make target modules |
内核头文件未安装或路径不对 | 安装 linux-headers-$(uname -r),检查 /lib/modules/ 目录 |
加载后 dmesg 无输出 |
模块加载成功但看不到日志 | 日志级别过滤或控制台级别设置 | 使用 dmesg -n 8 或 printk(KERN_ALERT ...) |
卸载时提示 Module is in use |
rmmod 失败 |
有其他进程或模块引用该模块 | 使用 lsmod 查看引用计数,先释放引用 |
总结本章的核心要点:
- 内核模块的入口是
module_init和module_exit,而不是main函数。 - Makefile必须通过Kbuild系统编译,
obj-m指定模块目标,KDIR指向内核构建目录。 insmod加载模块,rmmod卸载模块,dmesg查看内核日志。- 模块参数通过
module_param声明,加载时以key=value形式传递,支持数组和字符串。 - 调试时优先检查
dmesg输出,结合modinfo、lsmod、nm等工具定位问题。