3、内核模块基础:模块结构(init/exit)、模块加载与卸载、模块参数、模块依赖及模块信息

Linux内核采用宏内核架构,但为了兼顾功能扩展性与运行时灵活性,引入了可动态加载的模块(Loadable Kernel Module, LKM)机制。模块机制允许系统在运行时向内核动态添加或移除功能,例如设备驱动、文件系统、网络协议等,而无需重新编译内核或重启系统。理解模块的结构、生命周期、参数传递与依赖关系,是编写高质量驱动程序的基石。本章将围绕模块的代码骨架、加载/卸载流程、参数接口、符号依赖以及元信息声明展开,并结合实际案例与常见误区进行深度剖析。

内核模块生命周期与依赖关系总览 hello.ko ELF 目标文件 包含 .text/.data/.modinfo insmod / modprobe 用户态工具 触发 init_module() 系统调用 内核空间 module_init() 执行初始化函数 register_*() 注册设备/驱动/文件系统 模块参数:insmod hello.ko count=5 name="test" 模块依赖与符号解析 EXPORT_SYMBOL() 导出符号 modprobe 自动加载依赖 depmod 生成 modules.dep 模块元信息 MODULE_LICENSE / AUTHOR MODULE_DESCRIPTION / VERSION modinfo 命令查看 模块卸载流程 rmmod → delete_module() 系统调用 执行 module_exit() → 释放资源 → 从内核链表移除 常见失败场景 资源泄漏 / 符号未解析 / 参数格式错误 / 依赖未加载 编译 → insmod/modprobe → 内核加载 → 初始化 → 运行 → rmmod → 清理退出

3.1 模块的代码结构与生命周期

3.1.1 核心原理:init/exit 函数与模块骨架

每个内核模块本质上是一个独立的ELF目标文件(通常为 .ko),它并不像用户态程序那样拥有 main() 入口,而是通过两个关键宏向内核声明入口和出口:module_init() 和 module_exit()。当模块被加载时,内核会调用 module_init 指定的函数完成初始化;当模块被卸载时,内核调用 module_exit 指定的函数进行清理。这两个宏在 <linux/module.h> 中定义,其本质是将函数指针放入特定的ELF段(如 .init.text 和 .exit.text),内核在加载和卸载时从这些段中解析入口地址。

一个最简模块的代码结构如下:

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

static int __init hello_init(void)
{
    printk(KERN_INFO "Hello, kernel module loaded!\n");
    return 0;  // 返回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 是内核提供的属性标记,它们告诉内核:初始化函数在加载完成后可以释放其占用的内存(因为不会再被调用),退出函数仅在卸载时使用。这种机制有效节省了内核宝贵的内存资源。

3.1.2 判断方法:加载与卸载的操作流程

模块的加载与卸载涉及用户态工具与内核态的交互。常用的命令包括 insmod、rmmod、modprobe 和 modinfo。它们的区别与使用场景如下表所示:

命令功能适用场景依赖处理
insmod直接加载指定 .ko 文件调试单个模块,不解析依赖不自动处理依赖
rmmod卸载指定模块按模块名卸载不检查依赖
modprobe智能加载/卸载模块生产环境,自动处理依赖根据 modules.dep 自动加载依赖模块
modinfo查看模块元信息检查模块参数、许可、描述不涉及依赖

加载模块时,内核会执行一系列检查:验证模块文件格式、检查符号引用是否满足、调用 module_init 函数。如果初始化函数返回非零值,内核会回滚已分配的资源并卸载模块。卸载时,内核会检查模块的引用计数(refcnt),若仍有其他模块或进程引用该模块,则拒绝卸载并返回 EBUSY。

3.1.3 案例拆解:一个带资源分配与释放的模块

实际驱动中,初始化函数往往需要分配内存、注册设备号、建立proc条目等。以下是一个模拟字符设备注册的模块片段:

static int __init mydrv_init(void)
{
    int ret;
    ret = alloc_chrdev_region(&dev_num, 0, 1, "mydrv");
    if (ret < 0) {
        printk(KERN_ERR "Failed to alloc dev region\n");
        return ret;
    }
    cdev_init(&my_cdev, &my_fops);
    ret = cdev_add(&my_cdev, dev_num, 1);
    if (ret < 0) {
        unregister_chrdev_region(dev_num, 1);
        return ret;
    }
    printk(KERN_INFO "mydrv initialized, major=%d\n", MAJOR(dev_num));
    return 0;
}

static void __exit mydrv_exit(void)
{
    cdev_del(&my_cdev);
    unregister_chrdev_region(dev_num, 1);
    printk(KERN_INFO "mydrv unloaded\n");
}

这个案例展示了典型的“分配-注册-失败回滚”模式。在 mydrv_init 中,如果 cdev_add 失败,必须手动释放之前已分配的设备号区域,否则会造成资源泄漏。在 mydrv_exit 中,释放顺序与分配顺序相反,这是内核编程中必须遵守的纪律。

3.1.4 常见误区与复盘

风险提示:初学者常犯的错误包括:初始化函数返回正数(应返回0或负错误码);忘记在退出函数中释放资源;在 __exit 函数中访问已被释放的内存;使用 printk 时未指定日志级别导致调试信息被淹没。此外,module_init 和 module_exit 宏必须出现在模块文件的最外层作用域,不能放在函数内部。

复盘经验:编写模块时,建议在初始化函数中为每个资源分配步骤添加标签(goto error_xxx),统一在失败时跳转到错误处理路径,确保所有已分配资源都能被释放。这种“集中式错误处理”模式是内核代码的常见风格。

3.2 模块参数:向内核传递配置

3.2.1 核心原理:module_param 宏

模块参数允许用户在加载模块时向模块内部传递配置值,而无需修改代码重新编译。内核通过 module_param(name, type, perm) 宏声明参数,其中 name 是变量名,type 是参数类型(如 int, charp, bool, intp 等),perm 是sysfs权限位(0表示不创建sysfs条目,0444 表示只读,0644 表示可读写)。参数变量必须是全局或静态变量。

static int count = 1;
static char *name = "default";
module_param(count, int, 0644);
module_param(name, charp, 0444);
MODULE_PARM_DESC(count, "Number of items to process");
MODULE_PARM_DESC(name, "Device name string");

加载时可通过 insmod hello.ko count=5 name="mydev" 传递参数。对于数组参数,可使用 module_param_array(name, type, &num, perm),其中 num 用于接收实际传入的元素个数。

3.2.2 操作流程:参数校验与默认值

内核在加载模块时会解析参数字符串,并将其转换为对应类型。如果类型转换失败,模块加载将失败并返回错误。建议在初始化函数中对参数进行合法性校验,例如检查范围、长度等。参数校验失败时,应返回 -EINVAL 并打印错误信息。

经验提醒:对于字符串参数,内核会复制传入的字符串到模块分配的内存中,模块负责释放该内存(使用 kfree)。对于 charp 类型,如果参数在运行时可能被修改,建议使用 kstrdup 复制一份,避免直接操作原始指针。

3.2.3 案例拆解:带参数校验的模块

static int buf_size = 1024;
module_param(buf_size, int, 0644);
MODULE_PARM_DESC(buf_size, "Buffer size in bytes (64-65536)");

static int __init param_demo_init(void)
{
    if (buf_size < 64 || buf_size > 65536) {
        printk(KERN_ERR "Invalid buf_size: %d\n", buf_size);
        return -EINVAL;
    }
    buffer = kmalloc(buf_size, GFP_KERNEL);
    if (!buffer)
        return -ENOMEM;
    printk(KERN_INFO "Buffer allocated with size %d\n", buf_size);
    return 0;
}

该案例中,模块在初始化时对参数进行范围检查,避免因用户传入不合理值导致内核异常。这种防御性编程在驱动开发中至关重要。

3.2.4 常见误区

风险提示:不要将 module_param 用于局部变量或堆内存;不要使用 module_param 声明 const 变量(因为内核需要写入);对于 charp 参数,如果模块卸载时未释放内存,会造成泄漏。另外,perm 权限位如果设置为可写,用户可以通过sysfs在运行时修改参数值,此时必须确保模块代码对并发修改有保护机制(如使用互斥锁)。

3.3 模块依赖与符号导出

3.3.1 核心原理:EXPORT_SYMBOL 与模块间调用

模块之间可以相互调用函数或访问变量,但被调用的符号必须显式导出。内核使用 EXPORT_SYMBOL() 和 EXPORT_SYMBOL_GPL() 宏将符号暴露给其他模块。前者对所有模块可见,后者仅对声明为GPL许可的模块可见。当模块A引用了模块B导出的符号时,A就依赖于B。加载A之前必须先加载B,否则内核会报“Unknown symbol”错误。

// 模块B (exporter)
int my_add(int a, int b) { return a + b; }
EXPORT_SYMBOL(my_add);

// 模块A (importer)
extern int my_add(int a, int b);
static int __init importer_init(void)
{
    printk(KERN_INFO "3+4=%d\n", my_add(3, 4));
    return 0;
}

依赖关系由 depmod 工具扫描所有模块的符号表生成 modules.dep 文件,modprobe 根据该文件自动加载依赖模块。

3.3.2 判断方法:查看模块依赖与符号

使用 modinfo 可以查看模块的依赖列表(depends 字段)。使用 nm 或 objdump 可以查看模块的未定义符号(U 标记)和导出符号(T 或 D 标记)。内核运行时,可通过 /proc/modules 查看已加载模块及其引用计数,通过 /proc/kallsyms 查看内核符号表。

工具/文件用途示例
modinfo查看模块元信息与依赖modinfo hello.ko
nm查看模块符号表nm hello.ko | grep ' U '
/proc/modules查看已加载模块列表cat /proc/modules
/proc/kallsyms查看内核符号地址grep my_add /proc/kallsyms

3.3.3 案例拆解:依赖加载失败排障

假设模块 importer.ko 引用了 exporter.ko 中的 my_add 符号。直接使用 insmod importer.ko 会失败,错误信息为:

insmod: ERROR: could not insert module importer.ko: Unknown symbol in module

此时应使用 modprobe importer.ko,它会自动加载 exporter.ko。如果 modprobe 也失败,检查 modules.dep 是否已更新(运行 depmod -a),或者检查 exporter.ko 是否已安装到标准模块目录(如 /lib/modules/$(uname -r)/extra/)。

排障要点:当遇到“Unknown symbol”时,首先用 nm importer.ko | grep ' U ' 查看未定义符号,然后用 grep symbol /proc/kallsyms 确认该符号是否已被导出。如果符号存在但模块仍无法加载,检查符号是否因 EXPORT_SYMBOL_GPL 而受许可限制。

3.3.4 常见误区

风险提示:不要在内核模块中使用用户态库函数(如 printf、malloc),应使用 printk 和 kmalloc。导出符号时,应确保符号名不与其他模块冲突,建议使用具有模块前缀的命名方式。另外,模块卸载时,必须确保没有其他模块仍引用其导出的符号,否则会导致悬空指针。

3.4 模块信息与元数据声明

3.4.1 核心原理:MODULE_* 宏

模块元信息用于描述模块的许可、作者、描述、版本、别名等,这些信息被编译进 .modinfo 段,可通过 modinfo 命令查看。常用的宏包括:

  • MODULE_LICENSE("GPL"):声明模块许可,影响可访问的导出符号范围。
  • MODULE_AUTHOR("name"):作者信息。
  • MODULE_DESCRIPTION("text"):模块功能描述。
  • MODULE_VERSION("x.y"):版本号。
  • MODULE_ALIAS("alias"):模块别名,用于自动加载匹配。
  • MODULE_DEVICE_TABLE(type, table):设备ID表,用于热插拔自动加载。

这些宏不产生代码,仅生成元数据字符串。内核在加载模块时会检查 MODULE_LICENSE,如果声明为专有许可(如 "Proprietary"),则无法使用 EXPORT_SYMBOL_GPL 导出的符号,且内核会标记该模块为“污染”(tainted)。

3.4.2 操作流程:编写规范的模块信息

在模块文件末尾集中声明元信息,保持代码整洁。对于设备驱动,应使用 MODULE_DEVICE_TABLE 声明支持的设备ID,以便udev在设备插入时自动加载对应驱动。例如:

static const struct usb_device_id my_usb_table[] = {
    { USB_DEVICE(0x1234, 0x5678) },
    { }
};
MODULE_DEVICE_TABLE(usb, my_usb_table);

3.4.3 案例拆解:modinfo 输出解读

$ modinfo hello.ko
filename:       /home/user/hello.ko
license:        GPL
author:         Your Name
description:    A simple hello world module
version:        1.0
depends:        
vermagic:       5.15.0-91-generic SMP mod_unload

其中 vermagic 字段记录了模块编译时的内核版本和配置(如SMP、抢占模式),如果与当前运行内核不匹配,加载会失败并提示版本不兼容。这是内核模块与用户态程序的重要区别之一。

3.4.4 常见误区

风险提示:不要省略 MODULE_LICENSE,否则内核会警告并污染内核。不要随意声明 MODULE_LICENSE("GPL") 而实际代码包含非GPL兼容代码,这可能导致法律风险。另外,MODULE_VERSION 应遵循语义化版本规范,便于用户识别驱动更新。

3.5 综合实战:一个完整的带参数、依赖与信息的模块

以下综合案例展示了一个包含参数、依赖、完整元信息的模块,并演示了加载、参数传递、依赖解析和卸载的完整流程。

// exporter.c
#include <linux/module.h>
#include <linux/kernel.h>

int exported_add(int a, int b)
{
    return a + b;
}
EXPORT_SYMBOL(exported_add);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Alice");
MODULE_DESCRIPTION("Exporter module");
MODULE_VERSION("1.0");

// importer.c
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/slab.h>

extern int exported_add(int a, int b);

static int times = 2;
module_param(times, int, 0644);
MODULE_PARM_DESC(times, "Number of times to call exported_add");

static int __init importer_init(void)
{
    int i, sum = 0;
    for (i = 0; i < times; i++)
        sum += exported_add(i, i+1);
    printk(KERN_INFO "Sum = %d\n", sum);
    return 0;
}

static void __exit importer_exit(void)
{
    printk(KERN_INFO "Importer unloaded\n");
}

module_init(importer_init);
module_exit(importer_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Bob");
MODULE_DESCRIPTION("Importer module using exported_add");
MODULE_VERSION("1.0");

编译后,执行 modprobe importer.ko times=3,系统会自动加载 exporter.ko,然后加载 importer.ko。通过 dmesg 可看到输出:Sum = 9。卸载时,先执行 rmmod importer,再执行 rmmod exporter,顺序不能颠倒。

复盘总结:模块开发的核心在于理解内核生命周期管理。初始化函数必须处理所有可能的失败路径,退出函数必须释放所有资源。参数传递提供了灵活性,但必须校验。依赖管理要求模块间接口清晰,符号导出谨慎。元信息声明不仅是规范,更是内核模块生态的基础设施。掌握这些基础,才能进一步编写复杂的设备驱动。