4、内核模块基础:模块概念、模块加载/卸载机制、模块参数、模块依赖与符号导出
Linux内核采用宏内核架构,但为了兼顾功能扩展性与运行时灵活性,引入了内核模块(Loadable Kernel Module, LKM)机制。模块机制允许开发者将设备驱动、文件系统、网络协议等内核功能编译为独立的.ko文件,在系统运行期间动态加载或卸载,而无需重新编译整个内核或重启系统。本章将深入剖析模块的二进制结构、加载/卸载的完整内核链路、参数传递机制、符号导出与模块依赖关系,帮助读者建立从用户态insmod命令到内核态init_module系统调用的完整认知。
4.1 内核模块的核心概念与二进制结构
4.1.1 模块的本质:可重定位的ELF目标文件
内核模块本质上是一个ELF Relocatable(ET_REL)格式的目标文件,而非可执行文件。与用户态程序不同,模块没有固定的加载地址,其代码段、数据段中的符号引用需要在加载时由内核完成重定位(Relocation)。模块文件通常以.ko(Kernel Object)为后缀,存放在/lib/modules/$(uname -r)/目录下。一个典型的模块文件包含以下关键节区:
- .text:模块的代码段,包含函数实现。
- .data / .bss:已初始化与未初始化的全局数据。
- .modinfo:模块元信息,包括作者、描述、许可证、依赖别名等,可通过
modinfo命令查看。 - .symtab / .strtab:符号表与字符串表,记录模块导出和引用的符号。
- __versions:模块编译时内核版本相关的CRC校验值,用于加载时版本匹配检查。
- __kcrctab:导出符号的CRC校验表,用于符号版本控制。
模块的入口点并非传统的main(),而是通过module_init()和module_exit()宏指定的初始化与清理函数。这两个宏在编译阶段会将函数指针放入特定的ELF节区(.init.text和.exit.text),内核在加载和卸载时从这些节区中提取函数地址并调用。
4.1.2 模块与内核的版本匹配机制
内核在加载模块时会进行严格的版本检查。如果模块编译时使用的内核头文件版本与当前运行内核不一致,且内核开启了CONFIG_MODVERSIONS,则模块中的__versions节区会记录每个引用的内核符号的CRC值。加载时,内核会比较模块记录的CRC值与当前内核符号表中符号的CRC值,不一致则拒绝加载,并提示version magic mismatch错误。这一机制有效防止了二进制不兼容的模块被强行加载导致系统崩溃。
linux-headers-$(uname -r))配合make -C /lib/modules/$(uname -r)/build M=$PWD modules命令构建,确保版本匹配。
4.2 模块加载与卸载机制深度解析
4.2.1 加载路径:从insmod到init_module系统调用
用户态执行insmod ./hello.ko时,glibc会封装finit_module()或init_module()系统调用,将模块文件内容传入内核。内核的load_module()函数执行以下关键步骤:
- ELF解析与校验:检查文件头魔数、架构(ELF class)、字节序,确认是合法的ET_REL文件。
- 分配内存与复制节区:在
vmalloc区域分配模块内存,将各节区内容拷贝到最终位置。 - 符号解析与重定位:遍历模块的未定义符号,在内核符号表(
kallsyms)和已加载模块的导出符号表中查找匹配项;若找到则修正模块内对应引用地址,否则加载失败并报Unknown symbol错误。 - 版本CRC检查:对每个引用的内核符号,比对CRC值。
- 调用module_init:执行模块的初始化函数。若初始化失败,内核会回滚所有已分配的资源,并将模块状态置为
MODULE_STATE_GOING后卸载。 - 创建sysfs接口:在
/sys/module/<name>/下创建属性文件,暴露参数、状态等信息。
4.2.2 卸载路径:引用计数与安全回收
卸载模块使用rmmod命令,对应delete_module()系统调用。内核在卸载前会检查模块的引用计数(refcnt)。如果模块被其他模块依赖,或模块创建的设备节点正被进程打开,引用计数不为0,内核将拒绝卸载并返回EBUSY。模块自身可以通过try_module_get()和module_put()管理内部引用。卸载流程如下:
- 将模块状态置为
MODULE_STATE_GOING,阻止新的引用获取。 - 调用
module_exit指定的清理函数,释放中断、定时器、设备号、内存等资源。 - 从全局模块链表和sysfs中移除模块节点。
- 释放模块占用的内存区域。
module_exit中必须完整释放所有在module_init中获取的资源。若存在内存泄漏或未注销的工作队列,卸载后可能出现内核崩溃或僵尸资源。建议使用rmmod -f强制卸载前,先通过lsmod确认无依赖,通过dmesg确认无异常日志。
4.3 模块参数:运行时配置的灵活通道
4.3.1 参数定义与类型支持
模块参数允许用户在加载时向模块传递配置值,无需修改代码重新编译。使用module_param(name, type, perm)宏声明参数,其中type支持byte、short、ushort、int、uint、long、ulong、charp(字符指针)、bool、invbool,以及通过module_param_array声明的数组类型。perm表示sysfs中参数文件的访问权限,0表示不创建sysfs条目,0444表示只读,0644表示可读写。
// 示例:带参数的模块
#include <linux/module.h>
#include <linux/moduleparam.h>
static int count = 1;
static char *name = "default";
module_param(count, int, 0644);
module_param(name, charp, 0444);
MODULE_PARM_DESC(count, "Number of devices");
MODULE_PARM_DESC(name, "Device name prefix");
static int __init param_init(void) {
pr_info("count=%d, name=%s\n", count, name);
return 0;
}
module_init(param_init);
4.3.2 参数传递与运行时修改
加载时传递参数:insmod mymod.ko count=5 name="eth0"。对于数组参数,使用逗号分隔:insmod mymod.ko arr=1,2,3,4。若perm设置了写权限,模块运行期间可通过/sys/module/mymod/parameters/count文件修改参数值,内核会调用param_set_*回调函数进行类型转换与校验。这一特性对调试和调优非常有用,例如动态调整日志级别或缓冲区大小。
module_param(threshold, int, 0644),运维人员无需重新加载模块,直接执行echo 256 > /sys/module/ixgbe/parameters/threshold即可动态生效,极大提升了运维效率。
4.4 模块依赖与符号导出
4.4.1 EXPORT_SYMBOL与符号可见性
内核模块之间通过符号导出实现代码复用。使用EXPORT_SYMBOL(func)或EXPORT_SYMBOL_GPL(func)宏,可以将模块内的全局函数或变量导出到内核符号表,供其他模块引用。两者的区别在于:EXPORT_SYMBOL_GPL仅允许声明为GPL许可证的模块使用,否则在加载引用模块时会报Unknown symbol错误。导出的符号会出现在/proc/kallsyms中,并带有[module]标记。
| 宏 | 许可证限制 | 适用场景 |
|---|---|---|
| EXPORT_SYMBOL | 无限制 | 通用工具函数、核心API |
| EXPORT_SYMBOL_GPL | 仅GPL模块可用 | 涉及内核内部实现细节的函数 |
4.4.2 依赖解析与modprobe机制
当模块A引用了模块B导出的符号时,A依赖于B。加载A之前必须先加载B。手动使用insmod时,内核不会自动加载依赖模块,而是直接报Unknown symbol错误。而modprobe命令会读取/lib/modules/$(uname -r)/modules.dep文件(由depmod命令生成),自动解析依赖关系并按照拓扑顺序加载所有需要的模块。依赖关系文件的内容格式如下:
# modules.dep 片段
kernel/drivers/net/phy/realtek.ko:
kernel/drivers/net/ethernet/intel/e1000e.ko: kernel/drivers/net/phy/realtek.ko
上述内容表示e1000e.ko依赖realtek.ko。执行modprobe e1000e时,系统会先加载realtek.ko,再加载e1000e.ko。
4.4.3 模块依赖的循环与处理策略
内核不允许模块之间存在循环依赖。若A依赖B,B又依赖A,加载时内核会检测到循环并拒绝加载。解决循环依赖的常用方法包括:将公共代码抽取到第三个模块中;使用symbol_get()/symbol_put()在运行时动态获取符号;或者通过函数指针回调机制解耦。
EXPORT_SYMBOL导出的符号可以在模块卸载后继续使用。实际上,当导出符号的模块被卸载后,内核符号表中对应的符号条目会被移除,任何仍持有该符号引用的模块将触发悬空指针,导致系统崩溃。因此,卸载模块前必须确保所有依赖模块已先行卸载。
4.5 综合案例:构建一个带参数和依赖的完整模块
下面通过一个实际案例,将本章知识点串联起来。假设我们需要开发一个虚拟传感器驱动,它依赖一个底层的I2C核心辅助模块,并且支持通过模块参数配置传感器地址和采样频率。
4.5.1 案例背景与约束
目标平台为ARM64嵌入式Linux 5.10内核。I2C辅助模块(i2c_helper.ko)导出一个函数i2c_helper_read_reg(),用于读取I2C设备寄存器。传感器驱动(sensor_drv.ko)调用该函数获取温度数据。约束条件:传感器I2C地址在0x48~0x4F之间,采样频率需在1~100Hz之间可调。
4.5.2 初始现象与目标
直接使用insmod sensor_drv.ko加载时,系统报错:Unknown symbol i2c_helper_read_reg (err 0)。目标是通过modprobe自动加载依赖,并正确传递参数,使驱动正常工作。
4.5.3 分析与操作步骤
- 编写i2c_helper模块,导出核心函数:
// i2c_helper.c #include <linux/module.h> int i2c_helper_read_reg(unsigned char addr, unsigned char reg) { // 实际I2C读取逻辑 return 0x25; // 模拟返回温度 } EXPORT_SYMBOL(i2c_helper_read_reg); MODULE_LICENSE("GPL"); - 编写sensor_drv模块,声明参数并引用导出符号:
// sensor_drv.c #include <linux/module.h> #include <linux/moduleparam.h> extern int i2c_helper_read_reg(unsigned char addr, unsigned char reg); static int i2c_addr = 0x48; static int sample_hz = 10; module_param(i2c_addr, int, 0644); module_param(sample_hz, int, 0644); static int __init sensor_init(void) { int temp = i2c_helper_read_reg(i2c_addr, 0x00); pr_info("Sensor temp: %d, sample_hz: %d\n", temp, sample_hz); return 0; } module_init(sensor_init); MODULE_LICENSE("GPL"); - 编译并生成依赖:在源码目录执行
make生成两个.ko文件,拷贝到/lib/modules/$(uname -r)/extra/,然后执行depmod -a更新modules.dep。 - 使用modprobe加载:
系统自动先加载modprobe sensor_drv i2c_addr=0x4A sample_hz=50i2c_helper.ko,再加载sensor_drv.ko,dmesg输出显示温度读取成功。 - 运行时调整参数:
采样频率动态更新为100Hz。echo 100 > /sys/module/sensor_drv/parameters/sample_hz
4.5.4 结果对比与复盘
| 加载方式 | 结果 | 原因分析 |
|---|---|---|
| insmod sensor_drv.ko | 失败,Unknown symbol | 依赖模块未加载,符号未解析 |
| modprobe sensor_drv | 成功,依赖自动加载 | depmod生成的依赖关系被正确解析 |
复盘要点:符号导出是模块间协作的基础,但必须配合depmod和modprobe才能实现自动化依赖管理。参数机制提供了灵活的运行时配置能力,但需注意参数类型与权限的合理设置,避免暴露敏感接口。
EXPORT_SYMBOL和modprobe机制阐明了模块依赖的解析原理。掌握这些基础后,开发者可以高效地组织多模块驱动架构,实现代码复用与动态部署。在实际项目中,建议遵循“一个模块一个功能”的原则,合理划分导出符号的粒度,并始终使用modprobe而非insmod来管理模块加载,以充分利用内核的依赖解析能力。