3. 模块参数与导出符号:让内核模块真正“活”起来
前面我们写了一个最简单的“Hello World”模块。说实话,那个模块太死板了——加载就打印,卸载就消失,什么也控制不了。真正的内核模块,得能接收参数,得能把自己的功能暴露给其他模块用。这一章,我们就来解决这两个问题。
3.1 module_param:给模块开个“配置接口”
先问个问题:你写了个网络过滤模块,想控制它过滤哪些IP段。难道每次改需求都要重新编译?当然不。用 module_param 宏,加载时传个参数就行。
3.1.1 基本用法
看个最简单的例子:
#include <linux/module.h>
#include <linux/moduleparam.h>
#include <linux/kernel.h>
static int debug_level = 0;
module_param(debug_level, int, 0644);
MODULE_PARM_DESC(debug_level, "调试级别:0-关闭,1-基本信息,2-详细信息");
static int __init my_init(void)
{
printk(KERN_INFO "模块加载,调试级别:%d\n", debug_level);
return 0;
}
static void __exit my_exit(void)
{
printk(KERN_INFO "模块卸载\n");
}
module_init(my_init);
module_exit(my_exit);
MODULE_LICENSE("GPL");
编译后加载时,可以这样传参:
sudo insmod my_module.ko debug_level=2
module_param 三个参数的含义:
| 参数 | 含义 | 说明 |
|---|---|---|
| name | 变量名 | 必须是模块内已定义的全局变量 |
| type | 数据类型 | 支持 byte、short、ushort、int、uint、long、ulong、charp、bool |
| perm | 文件权限 | /sys/module/ 下对应文件的权限,0644 表示所有人可读 |
3.1.2 数组参数
有时候需要传多个值。比如我要过滤多个端口:
static int filter_ports[10] = {80, 443};
static int num_ports = 2;
module_param_array(filter_ports, int, &num_ports, 0644);
MODULE_PARM_DESC(filter_ports, "要过滤的端口列表");
// 加载时:
// insmod my_module.ko filter_ports=22,8080,3306
这里 num_ports 会被自动更新为实际传入的元素个数。我在项目中遇到过一个问题:数组大小声明为 10,结果用户传了 15 个值,直接越界了。后来我加了个上限检查:
if (num_ports > 10) {
printk(KERN_ERR "端口数量超过上限(10)\n");
return -EINVAL;
}
3.2 参数传递的三种方式
参数怎么传进去的?我总结了一下,主要有三种场景:
- insmod 加载时传参:最常用,
insmod xxx.ko key=val - modprobe 加载时传参:配合
/etc/modprobe.d/下的配置文件,适合系统启动时自动加载 - 运行时通过 sysfs 修改:如果 perm 不为 0,可以在
/sys/module/<模块名>/parameters/下看到对应文件,直接 echo 修改
举个例子,运行时修改:
echo 3 > /sys/module/my_module/parameters/debug_level
嗯,这里要注意:运行时修改不会触发任何通知。如果你的模块需要感知参数变化,得用 module_param_cb 注册回调函数。这个后面讲高级主题时再展开。
3.3 EXPORT_SYMBOL:把你的函数“借”给别人
模块参数解决了“配置”问题。但有时候,你需要让其他模块调用你的函数。比如你写了个加密模块,别的模块想用你的加密函数。这时候就需要导出符号。
3.3.1 导出单个函数
// 在 crypto_module.c 中
#include <linux/module.h>
int crypto_encrypt(char *data, int len)
{
// 加密逻辑
printk(KERN_INFO "加密 %d 字节数据\n", len);
return 0;
}
EXPORT_SYMBOL(crypto_encrypt);
int crypto_decrypt(char *data, int len)
{
// 解密逻辑
printk(KERN_INFO "解密 %d 字节数据\n", len);
return 0;
}
EXPORT_SYMBOL(crypto_decrypt);
MODULE_LICENSE("GPL");
另一个模块就可以这样调用:
// 在 user_module.c 中
extern int crypto_encrypt(char *data, int len);
extern int crypto_decrypt(char *data, int len);
static int __init user_init(void)
{
char data[] = "Hello Kernel";
crypto_encrypt(data, strlen(data));
return 0;
}
3.3.2 EXPORT_SYMBOL vs EXPORT_SYMBOL_GPL
这两个宏的区别,说白了就是“许可证洁癖”:
| 宏 | 谁可以用 | 典型场景 |
|---|---|---|
| EXPORT_SYMBOL | 所有模块,无论是否 GPL | 通用接口,比如内存分配、工具函数 |
| EXPORT_SYMBOL_GPL | 仅 GPL 许可证的模块 | 核心机制,不希望被闭源模块使用 |
我的建议:如果你写的是开源模块,一律用 EXPORT_SYMBOL_GPL。这不仅是法律问题,更是一种态度——你想想看,闭源模块用着你的代码,却不给你看它的代码,公平吗?
3.4 跨模块调用的完整流程
光说理论不够,我们走一遍完整流程。假设有两个模块:calc 提供加法函数,app 调用它。
3.4.1 导出模块(calc)
// calc.c
#include <linux/module.h>
int add(int a, int b)
{
return a + b;
}
EXPORT_SYMBOL_GPL(add);
static int __init calc_init(void)
{
printk(KERN_INFO "calc 模块加载\n");
return 0;
}
static void __exit calc_exit(void)
{
printk(KERN_INFO "calc 模块卸载\n");
}
module_init(calc_init);
module_exit(calc_exit);
MODULE_LICENSE("GPL");
3.4.2 调用模块(app)
// app.c
#include <linux/module.h>
extern int add(int a, int b);
static int __init app_init(void)
{
int result = add(3, 5);
printk(KERN_INFO "3 + 5 = %d\n", result);
return 0;
}
static void __exit app_exit(void)
{
printk(KERN_INFO "app 模块卸载\n");
}
module_init(app_init);
module_exit(app_exit);
MODULE_LICENSE("GPL");
3.4.3 加载顺序
这里有个坑:必须先加载 calc,再加载 app。否则 app 找不到 add 函数,加载会失败。
sudo insmod calc.ko
sudo insmod app.ko
# 查看导出的符号
cat /proc/kallsyms | grep add
卸载时顺序相反:先卸载 app,再卸载 calc。如果 calc 先被卸载,app 里还挂着对 add 的引用,内核直接 panic。
lsmod 查看模块依赖,确认没有引用再卸载。
3.5 知识体系图
下面这张图把本章的核心逻辑串起来了:
3.6 实战小技巧
最后分享几个我在项目中积累的经验:
- 参数命名规范:用下划线分隔,比如
debug_level、filter_ports。别用驼峰,内核社区不待见。 - 导出符号的版本控制:如果导出函数的接口变了,记得更新
MODULE_VERSION。否则其他模块编译时可能链接到旧版本,运行时出诡异问题。 - 调试技巧:用
modinfo查看模块参数和描述,用cat /proc/kallsyms | grep <模块名>确认符号是否导出成功。
我的习惯:每个导出函数都在注释里写明“谁在用、参数含义、返回值说明”。别嫌麻烦——三个月后你自己回来看代码,会感谢当初的注释。