内核模块基础:模块加载/卸载、模块参数、模块依赖与符号导出

大家好,欢迎来到第二章。今天我们来聊聊内核模块的基础操作。说实话,我刚入行那会儿,觉得写个"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 知识结构图

下面这张图总结了本章的核心逻辑:模块从加载到卸载的完整生命周期,以及参数、依赖、符号导出的关系。

内核模块生命周期与依赖关系 insmod / modprobe module_init() 执行 模块运行中 rmmod / modprobe -r module_exit() 执行 模块卸载完成 模块参数 module_param() 声明 符号导出 EXPORT_SYMBOL_GPL() 依赖管理 modprobe 自动处理 关键规则 ① 先加载被依赖模块,后加载依赖模块 ② 卸载顺序与加载相反

2.5 实战避坑指南

最后分享几个我踩过的坑:

  • 符号冲突:两个模块导出同名符号,加载第二个时会失败。我建议给导出的函数加模块名前缀,比如my_i2c_read而不是i2c_read。
  • 参数类型不匹配:传参时类型不对,内核不会报错,但值会乱掉。比如int类型传了字符串,结果就是0。加载后用cat /sys/module/模块名/parameters/参数名检查一下。
  • 卸载时资源未释放:在module_exit()里忘了释放内存、注销设备、关闭文件等,会导致资源泄漏。我习惯在卸载函数里加打印,确认每一步都执行了。
  • 依赖循环:模块A依赖B,B又依赖A,这种循环依赖内核无法处理。设计模块架构时就要避免。
我的建议:刚开始写模块时,多用printk打印关键信息。加载时打印"init ok",卸载时打印"exit ok"。虽然土,但排查问题特别有效。等模块稳定了再删掉这些调试信息。