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