内核模块基础:模块加载/卸载、模块参数、模块依赖与符号导出
大家好,欢迎来到第二章。今天我们来聊聊内核模块的基础操作。说实话,我刚入行那会儿,觉得写个"Hello World"模块能跑起来就挺了不起的。后来才发现,模块的加载卸载、参数传递、依赖管理这些基本功,才是真正决定你调试效率的关键。
你想想看,一个驱动写好了,怎么装进内核?怎么卸掉?怎么传参数?怎么让别的模块用你的函数?这些看似简单的问题,坑其实不少。我当年就在模块卸载顺序上栽过跟头——一个模块没等依赖它的模块先卸,直接rmmod,结果内核直接panic。嗯,从那以后我再也不敢乱来了。
2.1 模块的加载与卸载
先看最基础的操作。内核模块本质上是一个".ko"文件,通过insmod和rmmod来管理生命周期。
2.1.1 模块入口与出口
每个模块必须有两个宏:module_init()和module_exit()。前者指定加载时执行的函数,后者指定卸载时执行的函数。
#include <linux/module.h>
#include <linux/kernel.h>
static int __init my_driver_init(void)
{
printk(KERN_INFO "My driver loaded!\n");
return 0;
}
static void __exit my_driver_exit(void)
{
printk(KERN_INFO "My driver unloaded!\n");
}
module_init(my_driver_init);
module_exit(my_driver_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple example module");
这里有个细节:__init和__exit这两个标记。加了__init的函数,在模块加载完成后,占用的内存会被释放。说白了就是"用完就扔",省内存。我在项目中见过有人没加这个标记,结果模块占着几KB内存不放——虽然不多,但嵌入式设备上能省一点是一点。
2.1.2 加载与卸载命令
| 命令 | 作用 | 说明 |
|---|---|---|
insmod my_module.ko |
加载模块 | 需要指定完整路径,不自动解决依赖 |
rmmod my_module |
卸载模块 | 按模块名卸载,不按文件名 |
modprobe my_module |
智能加载 | 自动处理依赖,推荐使用 |
lsmod |
列出模块 | 显示模块大小、被谁使用等信息 |
rmmod卸载一个正在被其他模块使用的模块。我曾经在一个项目中,因为模块A依赖模块B,我先卸了B,结果A直接崩溃。正确的做法是先卸A,再卸B。或者用modprobe -r,它会自动处理依赖顺序。
2.2 模块参数
模块参数是个好东西。它让你不用重新编译,就能在加载时调整驱动行为。比如调试开关、缓冲区大小、设备数量等。
2.2.1 声明参数
用module_param()宏来声明。支持的类型有:int、long、charp(字符串指针)、bool等。
static int debug_level = 0;
static char *device_name = "default_dev";
static int buffer_size = 1024;
module_param(debug_level, int, 0644);
module_param(device_name, charp, 0644);
module_param(buffer_size, int, 0644);
MODULE_PARM_DESC(debug_level, "Debug level: 0=off, 1=info, 2=verbose");
MODULE_PARM_DESC(device_name, "Device name string");
MODULE_PARM_DESC(buffer_size, "Buffer size in bytes");
第三个参数是文件权限,0644表示用户可读写,其他人只读。设为0表示不在sysfs中暴露。
2.2.2 加载时传参
# 命令行传参
insmod my_driver.ko debug_level=2 device_name="mydev" buffer_size=4096
# 或者用modprobe
modprobe my_driver debug_level=2 device_name="mydev"
debug_level参数暴露出来,上线前再改成固定值。这样既方便调试,又不会影响最终性能。另外,字符串参数记得用引号括起来,否则可能被shell解析掉。
2.3 模块依赖与符号导出
模块之间可以互相调用函数。比如一个I2C控制器驱动,可以被多个I2C设备驱动使用。这就需要符号导出。
2.3.1 导出符号
用EXPORT_SYMBOL()或EXPORT_SYMBOL_GPL()来导出函数或变量。区别在于:EXPORT_SYMBOL_GPL()只允许GPL许可的模块使用。
// 在模块A中
int my_i2c_read(struct i2c_client *client, u8 reg)
{
// 实现I2C读取
return 0;
}
EXPORT_SYMBOL_GPL(my_i2c_read);
// 在模块B中,可以直接调用
int val = my_i2c_read(client, 0x10);
2.3.2 依赖关系
当模块B使用了模块A导出的符号,B就依赖于A。加载时必须先加载A,再加载B。卸载时顺序相反。
# 查看依赖
lsmod | grep my_driver
# 输出示例
my_driver_b 16384 0
my_driver_a 20480 1 my_driver_b
最后一列的数字表示被多少个模块使用。如果数字不为0,rmmod会失败。
- 导出符号用
EXPORT_SYMBOL或EXPORT_SYMBOL_GPL - 依赖关系由内核自动维护,
modprobe会自动处理 - 卸载时先卸依赖者,再卸被依赖者
- 用
lsmod查看模块使用计数
2.4 知识结构图
下面这张图总结了本章的核心逻辑:模块从加载到卸载的完整生命周期,以及参数、依赖、符号导出的关系。
2.5 实战避坑指南
最后分享几个我踩过的坑:
- 符号冲突:两个模块导出同名符号,加载第二个时会失败。我建议给导出的函数加模块名前缀,比如
my_i2c_read而不是i2c_read。 - 参数类型不匹配:传参时类型不对,内核不会报错,但值会乱掉。比如
int类型传了字符串,结果就是0。加载后用cat /sys/module/模块名/parameters/参数名检查一下。 - 卸载时资源未释放:在
module_exit()里忘了释放内存、注销设备、关闭文件等,会导致资源泄漏。我习惯在卸载函数里加打印,确认每一步都执行了。 - 依赖循环:模块A依赖B,B又依赖A,这种循环依赖内核无法处理。设计模块架构时就要避免。
printk打印关键信息。加载时打印"init ok",卸载时打印"exit ok"。虽然土,但排查问题特别有效。等模块稳定了再删掉这些调试信息。