第2章 内核模块基础:模块结构、init/exit函数、printk日志级别、模块参数与依赖
Linux内核采用宏内核架构,但为了兼顾灵活性与可维护性,引入了可加载模块(Loadable Kernel Module, LKM)机制。模块允许开发者在不重新编译整个内核、不重启系统的前提下,动态扩展内核功能,例如添加新的设备驱动、文件系统或网络协议。本章将围绕模块的完整生命周期展开,深入剖析模块的代码结构、入口/出口函数的执行机制、printk日志级别的使用规范、模块参数的传递方式以及模块间的依赖关系,帮助读者建立内核模块开发的核心骨架。
上图展示了内核模块从源码编写、编译、加载、运行到卸载的完整生命周期,以及模块依赖和日志输出在其中的位置。下面我们将逐一拆解每个环节的核心原理与实战要点。
2.1 模块结构:从源码到 .ko 文件
一个标准的内核模块源码文件通常包含以下固定部分:模块头文件、模块许可证声明、模块参数定义、初始化函数、退出函数以及将函数注册为入口/出口的宏。虽然模块功能千差万别,但骨架高度一致。
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
static int __init my_module_init(void)
{
printk(KERN_INFO "My module loaded!\n");
return 0;
}
static void __exit my_module_exit(void)
{
printk(KERN_INFO "My module unloaded!\n");
}
module_init(my_module_init);
module_exit(my_module_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple example module");
MODULE_VERSION("1.0");
上述代码中,__init 和 __exit 是内核提供的属性标记。带有 __init 的函数在模块加载完成后,其占用的内存会被释放,以节省内核空间;__exit 标记则用于卸载路径,在编译进内核(非模块)时该函数会被丢弃。这种机制体现了内核模块对内存占用的极致追求。
EXPORT_SYMBOL() 或 EXPORT_SYMBOL_GPL() 的符号才能被其他模块引用。
2.2 init/exit 函数:模块的“构造函数”与“析构函数”
module_init() 指定的函数在模块加载时被调用,通常用于资源申请、设备注册、proc 文件创建等初始化操作。module_exit() 指定的函数在模块卸载时被调用,必须与 init 操作一一对应地释放资源,否则会导致内存泄漏或内核崩溃。
| 对比维度 | module_init() | module_exit() |
|---|---|---|
| 调用时机 | insmod 或 modprobe 加载时 | rmmod 卸载时 |
| 返回值 | 0 表示成功,负数表示失败(如 -ENOMEM) | void 类型,无返回值 |
| 失败处理 | 返回非零值,内核回滚加载操作 | 必须确保所有资源已释放 |
| 典型操作 | register_chrdev、kmalloc、proc_create | unregister_chrdev、kfree、proc_remove |
一个常见的错误是 init 函数中部分初始化成功、部分失败时,没有正确回滚已申请的资源。例如,先申请了中断号,再申请内存失败,此时必须释放中断号再返回错误码。
2.3 printk 日志级别:内核的“printf”与调试利器
printk 是内核空间的打印函数,与用户空间的 printf 类似,但增加了日志级别(loglevel)机制。日志级别决定了消息的严重程度,并影响消息是否立即显示在控制台上。
printk(KERN_EMERG "System is unusable\n"); // 0
printk(KERN_ALERT "Action must be taken\n"); // 1
printk(KERN_CRIT "Critical condition\n"); // 2
printk(KERN_ERR "Error condition\n"); // 3
printk(KERN_WARNING "Warning condition\n"); // 4
printk(KERN_NOTICE "Normal but significant\n"); // 5
printk(KERN_INFO "Informational\n"); // 6
printk(KERN_DEBUG "Debug-level message\n"); // 7
内核通过 /proc/sys/kernel/printk 文件控制日志输出策略,该文件包含 4 个数值:控制台日志级别、默认消息日志级别、最小控制台级别、默认控制台级别。例如,若控制台日志级别为 4,则只有级别 0-3 的消息会显示在控制台上,级别 4-7 的消息仅写入内核环形缓冲区(可通过 dmesg 查看)。
echo 8 > /proc/sys/kernel/printk),以便所有级别的消息都能实时显示。正式发布时,应使用 KERN_INFO 或更高级别,避免刷屏。
2.4 模块参数:向内核模块传递配置
模块参数允许用户在加载模块时通过 insmod 或 modprobe 传递配置值,无需重新编译。使用 module_param() 宏声明参数,并指定权限位。
static int count = 1;
static char *name = "mymod";
module_param(count, int, 0644);
module_param(name, charp, 0644);
MODULE_PARM_DESC(count, "Number of devices");
MODULE_PARM_DESC(name, "Device name prefix");
加载时传递参数:
sudo insmod mymod.ko count=3 name="testdev"
参数权限位 0644 表示该参数在 sysfs 中可见且可读,root 可写。如果权限为 0,则参数不会出现在 sysfs 中,只能在加载时设置。运行时修改参数(通过 sysfs)不会自动触发任何逻辑,需要驱动中实现相应的回调函数(如通过 param_set_* 自定义写操作)。
2.5 模块依赖:EXPORT_SYMBOL 与 modprobe 自动解析
当模块 A 需要使用模块 B 中导出的函数或变量时,就形成了依赖关系。内核通过 EXPORT_SYMBOL() 宏将符号导出到内核符号表,其他模块即可引用。
// 模块 B (libmod.c)
int add(int a, int b) { return a + b; }
EXPORT_SYMBOL(add);
// 模块 A (usermod.c)
extern int add(int a, int b);
static int __init init(void) { printk(KERN_INFO "%d\n", add(2,3)); return 0; }
编译模块 A 时,需要链接模块 B 的符号。使用 modprobe 加载模块 A 时,它会自动读取 /lib/modules/$(uname -r)/modules.dep 文件(由 depmod 生成),先加载依赖的模块 B,再加载模块 A。而 insmod 不会解析依赖,需要手动按顺序加载。
lsmod 查看模块引用计数和依赖列表;使用 modinfo mymod.ko 查看模块信息;使用 nm mymod.ko | grep U 查看未定义符号(即需要外部提供的符号)。
2.6 实战案例:一个带参数和依赖的完整模块
下面通过一个实际案例,将上述知识点串联起来。假设我们需要开发一个简单的“设备计数”模块,它依赖一个基础数学模块提供的阶乘函数,并支持通过参数配置设备数量。
场景与约束:目标平台为 x86_64 Linux 5.15 内核,模块需要动态加载/卸载,且要求参数可通过 sysfs 动态调整。初始现象是直接加载主模块时提示 Unknown symbol 错误。
关键输入数据:基础模块导出 int factorial(int n) 函数;主模块参数 dev_count 默认值为 2,权限 0644。
分析判断过程:错误 Unknown symbol factorial 表明主模块引用的符号未在内核符号表中找到。原因可能是基础模块未加载,或基础模块未使用 EXPORT_SYMBOL 导出该符号。
实际操作步骤:
- 编写基础模块 base.c,导出 factorial 函数,编译生成 base.ko。
- 编写主模块 main.c,声明
extern int factorial(int n),在 init 中调用并打印结果。 - 先加载 base.ko,再加载 main.ko,观察 dmesg 输出。
- 使用
echo 5 > /sys/module/main/parameters/dev_count动态修改参数。 - 卸载时先 rmmod main.ko,再 rmmod base.ko,验证顺序。
结果对比:正确加载后,dmesg 显示 factorial(5)=120,且 sysfs 参数可读可写。若使用 insmod 直接加载 main.ko 而不先加载 base.ko,则报错并加载失败。
风险与代价:模块卸载顺序错误会导致内核崩溃(因为主模块仍引用基础模块的代码段)。使用 modprobe 可以自动处理依赖,但需要确保 depmod 已正确生成依赖文件。
复盘经验:模块依赖管理是内核模块开发中最容易出问题的环节之一。建议在 Makefile 中使用 KBUILD_EXTRA_SYMBOLS 指定基础模块的符号文件,或者统一使用 modprobe 管理加载顺序。
2.7 常见误区与总结复盘
| 误区 | 后果 | 正确做法 |
|---|---|---|
| 忘记 MODULE_LICENSE("GPL") | 内核 taint 标记,部分 GPL 符号不可用 | 始终声明许可证,推荐 GPL |
| init 失败未回滚资源 | 内存泄漏、设备注册残留 | 使用 goto 语句统一错误处理 |
| printk 无日志级别 | 默认级别可能被控制台过滤 | 显式指定 KERN_* 级别 |
| 模块参数权限设为 0 | 无法在运行时查看/修改 | 调试期设为 0644 |
| 依赖模块未加载 | Unknown symbol 错误 | 使用 modprobe 或手动按序加载 |
内核模块开发是 Linux 驱动开发的基石。掌握模块结构、生命周期管理、日志输出、参数传递和依赖关系,是后续学习字符设备、平台驱动、中断处理等高级主题的前提。建议读者在虚拟机中动手实践本章案例,通过 dmesg 观察每一步的输出,加深对模块机制的理解。