内核模块基础:模块加载与卸载、模块参数传递、模块依赖与符号导出
说实话,很多刚接触驱动开发的朋友,第一个坎儿就是搞不懂内核模块到底是个什么东西。我当年也一样,觉得它跟普通应用程序差不多,结果一跑就崩,连个报错都看不懂。
其实说白了,内核模块就是一段可以在系统运行时动态加载到内核的代码。它不像应用程序那样有 main 函数,也不依赖 libc 库。它直接运行在内核空间,拥有最高权限——当然,搞砸了也是最高级别的崩溃。
核心概念:内核模块是 Linux 内核实现"按需加载"的机制。驱动、文件系统、网络协议都可以做成模块,需要时 insmod,不用时 rmmod,不用重新编译整个内核。
2.1 最简单的内核模块:Hello World
先看一个最基础的模块代码。我习惯把它当作驱动开发的"起点仪式"。
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
static int __init hello_init(void)
{
printk(KERN_INFO "Hello, Kernel Module!\n");
return 0;
}
static void __exit hello_exit(void)
{
printk(KERN_INFO "Goodbye, Kernel Module!\n");
}
module_init(hello_init);
module_exit(hello_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple Hello World module");
注意几个关键点:
- module_init 和 module_exit 是入口和出口宏,不是函数声明
- __init 和 __exit 是属性标记,告诉内核这些函数只在初始化/卸载时使用,之后可以释放内存
- printk 是内核空间的"printf",日志级别用 KERN_INFO 等宏控制
- MODULE_LICENSE 必须声明,否则内核会报"tainted"警告
我的习惯:每个模块我都会加上 MODULE_VERSION,方便后期排查固件版本问题。这个字段虽然不强制,但在生产环境中特别有用。
2.2 模块的加载与卸载
模块写好了,怎么跑起来?我常用的命令就这几个:
| 操作 | 命令 | 说明 |
|---|---|---|
| 加载模块 | insmod hello.ko |
直接插入,不处理依赖 |
| 加载模块(带依赖) | modprobe hello |
自动解决依赖关系 |
| 卸载模块 | rmmod hello |
移除模块 |
| 查看已加载模块 | lsmod |
列出所有模块及使用计数 |
| 查看模块信息 | modinfo hello.ko |
显示作者、描述、参数等 |
这里有个坑——我曾经在调试时用 insmod 加载了一个依赖其他模块的驱动,结果直接报 "Unknown symbol"。后来才意识到,insmod 不会帮你拉依赖,必须用 modprobe。
注意:rmmod 卸载模块时,如果模块被其他模块引用(lsmod 中 Used by 不为 0),卸载会失败。强制卸载可以用 rmmod -f,但我不建议在生产环境这么做,容易导致内核 panic。
2.3 模块参数传递
驱动写死了参数值,每次改都要重新编译?太傻了。内核模块支持参数传递,运行时通过 insmod 或 modprobe 传入。
#include <linux/moduleparam.h>
static int debug_level = 0;
static char *device_name = "default_dev";
module_param(debug_level, int, 0644);
module_param(device_name, charp, 0644);
MODULE_PARM_DESC(debug_level, "Debug level (0-3)");
MODULE_PARM_DESC(device_name, "Device name string");
加载时传参:
insmod mydriver.ko debug_level=2 device_name="my_uart"
参数类型支持:
| 类型 | 宏 | 示例 |
|---|---|---|
| 整型 | module_param(name, int, perm) | module_param(count, int, 0) |
| 长整型 | module_param(name, long, perm) | module_param(interval, long, 0) |
| 字符串 | module_param(name, charp, perm) | module_param(name, charp, 0) |
| 布尔型 | module_param(name, bool, perm) | module_param(enable, bool, 0) |
| 数组 | module_param_array(name, type, num, perm) | module_param_array(ports, int, &num, 0) |
perm 参数是文件权限,如果设为 0,模块参数不会出现在 /sys/module/ 下。我一般调试时设 0644,方便运行时通过 sysfs 修改。
避坑指南:我曾经在项目里用 charp 传了一个路径字符串,结果忘记检查长度,导致缓冲区溢出。建议对字符串参数做长度校验,或者用 module_param_string 指定最大长度。
2.4 模块依赖与符号导出
驱动之间经常有依赖关系。比如一个 I2C 控制器驱动,会被多个 I2C 设备驱动依赖。怎么实现?靠符号导出。
内核用 EXPORT_SYMBOL 和 EXPORT_SYMBOL_GPL 把函数或变量暴露给其他模块。
// 在控制器驱动中导出
#include <linux/export.h>
int i2c_controller_register(struct i2c_adapter *adap)
{
// 注册逻辑
}
EXPORT_SYMBOL(i2c_controller_register);
// 在设备驱动中引用
extern int i2c_controller_register(struct i2c_adapter *adap);
两个导出宏的区别:
| 宏 | 谁可以用 | 典型场景 |
|---|---|---|
| EXPORT_SYMBOL | 所有模块(含非GPL) | 通用接口,如内存分配 |
| EXPORT_SYMBOL_GPL | 仅GPL许可的模块 | 核心机制,如文件系统接口 |
模块依赖关系可以用 MODULE_SOFTDEP 声明:
MODULE_SOFTDEP("pre: i2c_core");
这样 modprobe 在加载当前模块前,会先加载 i2c_core。
注意:符号导出不是随便用的。过度导出会导致内核接口混乱,维护困难。我见过一个项目,所有驱动函数都 EXPORT_SYMBOL,最后没人敢改任何接口。建议只导出必要的、稳定的接口。
2.5 知识结构总览
下面这张图把本章的核心逻辑串起来了,你可以对照着理解模块的完整生命周期。
2.6 实战要点总结
最后,我把本章最实用的几个点列出来,你写驱动时可以直接对照:
- 模块入口/出口必须配对——只写 init 不写 exit,卸载时资源泄漏
- 参数权限设 0 还是 0644——调试阶段用 0644,方便 sysfs 调参;发布前改成 0
- 符号导出要克制——只导出稳定接口,内部函数用 static 隐藏
- 依赖声明用 MODULE_SOFTDEP——比在 init 里手动检查更优雅
- printk 日志级别别乱用——KERN_DEBUG 调试用,KERN_ERR 报错用,KERN_INFO 正常信息
我的经验:刚开始写模块时,我总喜欢把所有函数都 EXPORT_SYMBOL,觉得"万一别人要用呢"。结果三个月后自己都分不清哪些是公开接口。后来我定了个规矩:只有被其他 .ko 直接调用的函数才导出,其他一律 static。这个习惯一直用到现在。