第一个内核模块:编写Hello World模块、Makefile编写、insmod/rmmod/dmesg调试、模块参数传递

从零开始学习Linux驱动编程,第一个里程碑就是亲手编写并加载一个内核模块。内核模块(Kernel Module)是Linux内核支持动态扩展功能的核心机制,它允许我们在不重新编译整个内核的情况下,将设备驱动、文件系统、网络协议等代码片段动态加载到运行中的内核里。本章将围绕一个最简单的Hello World模块,完整讲解模块的代码结构、Makefile编写规范、加载与卸载命令、日志调试方法,以及如何通过模块参数向内核空间传递用户态数据。这是后续所有字符设备驱动、平台驱动、中断处理等复杂主题的基石。

内核模块开发与调试链路 用户态 insmod / rmmod dmesg / cat /proc 内核态 module_init / exit printk / module_param 硬件层 设备寄存器 中断控制器 系统调用 读写/中断 模块生命周期与调试路径 编写 .c 源文件 hello.c 编写 Makefile obj-m += hello.o make 编译 生成 hello.ko insmod 加载 进入内核空间 dmesg 查看日志 printk 输出到内核环形缓冲区 确认 init/exit 是否执行 rmmod 卸载 调用 module_exit 回调 释放资源,从内核移除 模块参数传递 insmod hello.ko count=5 module_param 声明接收

上图展示了内核模块从编写、编译到加载、调试、卸载的完整工程链路。用户态通过 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调试实战

模块编译成功后,就可以进行加载和卸载操作。以下是完整的操作流程:

  1. 加载模块:使用 sudo insmod hello.ko。如果加载成功,没有任何输出,但可以通过 lsmod 查看模块是否在列表中。
  2. 查看日志:使用 dmesg 查看内核日志。由于 printk 输出到环形缓冲区,需要 dmesg 读取。建议使用 dmesg | tail -n 20 查看最近日志。
  3. 卸载模块:使用 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 会被设置为默认数组的长度。

注意事项: 模块参数在运行时可以通过sysfs修改(如果 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 等工具定位问题。