1. 课程导论:内核模块生命周期与 init/exit 函数的核心作用

Linux 内核模块(Loadable Kernel Module, LKM)是动态加载到内核地址空间的独立代码段。理解模块的完整生命周期,以及 init_module() 与 cleanup_module()(或通过 module_init()/module_exit() 宏注册的等价函数)在其中的核心作用,是进行模块加载调试的基石。本导论将系统梳理模块从加载到卸载的完整流程,并聚焦于 init/exit 函数作为故障高发区的根本原因。

1.1 内核模块的生命周期全景

一个典型的内核模块会经历以下五个阶段。每个阶段都可能因资源分配失败、符号依赖缺失或硬件状态异常而中断,这正是调试工作的切入点。

关键认知: 模块的生命周期并非简单的“加载-卸载”二元状态,而是包含“注册-初始化-运行-退出-注销”的精细状态机。调试时需明确当前模块处于哪个阶段。
内核模块生命周期状态机 加载 (insmod) init 执行 运行中 exit 执行 卸载完成 init 失败回滚 调试要点:init 失败时模块不会进入“运行中”状态,内核自动释放已分配资源;exit 失败则可能导致资源泄漏。

1.2 init 函数:模块的“构造函数”

每个内核模块必须且只能定义一个初始化入口函数。该函数通过 module_init() 宏注册,在内核模块加载时由内核自动调用。其核心职责包括:

  • 资源分配: 申请内存(kmalloc)、注册设备号(register_chrdev)、创建 proc 文件、注册中断处理程序等。
  • 数据结构初始化: 设置链表、哈希表、工作队列、定时器等内核对象。
  • 依赖检查: 验证硬件是否存在、依赖的符号是否已导出、依赖的模块是否已加载。
  • 状态上报: 返回 0 表示成功,返回负的错误码(如 -ENOMEM、-EIO)表示失败。
常见陷阱: init 函数中如果调用 printk 输出调试信息,但控制台日志级别设置不当,可能导致信息被过滤,给调试带来误导。建议在 init 入口和出口处均添加 printk(KERN_INFO ...) 作为“生命信号”。

1.3 exit 函数:模块的“析构函数”

exit 函数通过 module_exit() 宏注册,在模块卸载时由内核调用。其核心职责是逆序释放 init 函数中分配的所有资源:

资源类型 分配函数(init 中) 释放函数(exit 中) 常见遗漏后果
内存 kmalloc() kfree() 内存泄漏,系统可用内存持续减少
字符设备 register_chrdev() unregister_chrdev() 设备号被占用,后续模块无法注册
proc 文件 proc_create() remove_proc_entry() 访问已卸载模块的 proc 文件导致内核崩溃
中断处理 request_irq() free_irq() 中断号被占用,或卸载后中断触发导致系统挂死
工作队列 schedule_work() cancel_work_sync() 工作项在卸载后执行,访问已释放的内存

1.4 init/exit 函数故障的典型模式

根据内核社区 Bugzilla 和 LKML 的统计,模块加载/卸载故障中,约 70% 可归因于 init/exit 函数的实现缺陷。以下是三种最常见的故障模式:

  1. init 部分成功导致资源泄漏: init 函数在分配了资源 A 后,分配资源 B 时失败。如果代码没有正确回滚已分配的资源 A,则模块虽然加载失败,但资源 A 永久泄漏。
  2. exit 函数未完全释放: 开发者遗漏了某个资源的释放,或释放顺序与分配顺序不一致,导致卸载后仍有内核对象引用已释放的内存。
  3. 并发安全问题: init 函数中注册了中断处理或工作队列,但在注册完成前中断/工作项已被触发,而此时数据结构尚未完全初始化。
调试建议: 在 init 函数中,建议使用“goto 错误处理”模式(内核社区惯用风格),确保每个失败路径都能正确回滚。在 exit 函数中,建议按 init 分配顺序的严格逆序释放资源。

1.5 模块加载/卸载的完整调用链

以下代码展示了标准的内核模块框架,以及 init/exit 函数在其中的位置:

#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/fs.h>

static int __init my_module_init(void)
{
    int ret;

    printk(KERN_INFO "my_module: init entered\n");

    /* 步骤1: 分配内存 */
    /* 步骤2: 注册设备 */
    ret = register_chrdev(0, "my_device", &fops);
    if (ret < 0) {
        printk(KERN_ERR "my_module: device registration failed: %d\n", ret);
        return ret;  /* 直接返回,无需回滚 */
    }

    /* 步骤3: 创建 proc 文件 */
    /* 步骤4: 注册中断 */

    printk(KERN_INFO "my_module: init succeeded\n");
    return 0;  /* 返回0表示成功 */
}

static void __exit my_module_exit(void)
{
    printk(KERN_INFO "my_module: exit entered\n");

    /* 逆序释放: 先释放中断,再移除 proc,最后注销设备 */
    /* free_irq(); */
    /* remove_proc_entry(); */
    unregister_chrdev(major, "my_device");

    printk(KERN_INFO "my_module: exit completed\n");
}

module_init(my_module_init);
module_exit(my_module_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Kernel Debug Course");
MODULE_DESCRIPTION("Example module for init/exit debugging");

1.6 调试切入点:从 dmesg 到系统状态

当模块加载或卸载失败时,第一手信息通常来自内核日志缓冲区。以下表格总结了不同故障场景下的典型日志特征:

故障场景 dmesg 典型输出 可能原因
init 返回负值 my_module: init entered
my_module: init failed: -12
内存不足(-ENOMEM),或设备号冲突
符号未定义 Unknown symbol my_function (err -2) 依赖的模块未加载,或符号未导出
exit 未执行 rmmod: ERROR: Module my_module is in use 有其他进程持有模块引用计数
内核崩溃 BUG: unable to handle kernel NULL pointer dereference init 中访问了未初始化的指针,或 exit 中释放了仍在使用的内存