模块参数与导出符号:让内核模块真正“活”起来
说实话,刚接触内核模块开发那会儿,我总觉得模块就是个“黑盒子”——加载进去就干活,想改个参数还得重新编译。后来我才明白,模块参数和导出符号这两个机制,才是让模块真正灵活起来的关键。
这一节,我们就来聊聊怎么给模块“开个口子”,让用户能在加载时调整行为,以及怎么让模块之间互相调用。嗯,这些都是我实际项目中天天用的东西。
一、module_param:给模块开个“配置窗口”
你想想看,一个驱动要支持不同的硬件版本,总不能每次都改源码吧?module_param 就是干这个的。
1. 基本用法
先看个最简单的例子:
#include <linux/module.h>
#include <linux/moduleparam.h>
static int debug_level = 0;
module_param(debug_level, int, 0644);
MODULE_PARM_DESC(debug_level, "调试级别:0-关闭,1-基本信息,2-详细信息");
static char *device_name = "default_dev";
module_param(device_name, charp, 0);
MODULE_PARM_DESC(device_name, "设备名称");
static int __init my_init(void)
{
printk(KERN_INFO "模块加载,调试级别:%d,设备名:%s\n",
debug_level, device_name);
return 0;
}
加载模块时就能这样传参:
insmod my_module.ko debug_level=2 device_name="my_device"
我个人习惯把调试级别参数放在第一位,因为调试时改得最频繁。你可以在 /sys/module/ 下看到这些参数,运行时也能改(前提是权限允许)。
2. 参数类型与权限
内核支持的类型其实挺全的:
| 类型标识 | C语言类型 | 说明 |
|---|---|---|
| byte | unsigned char | 单字节,范围0-255 |
| short | short | 短整型,有符号 |
| ushort | unsigned short | 无符号短整型 |
| int | int | 标准整型,最常用 |
| uint | unsigned int | 无符号整型 |
| long | long | 长整型 |
| ulong | unsigned long | 无符号长整型 |
| charp | char * | 字符串指针,注意内存管理 |
| bool | bool | 布尔值,传0或1 |
| invbool | bool | 反转布尔值 |
权限参数是个八进制数,跟文件权限一个道理:
0:完全隐藏,连/sys里都看不到0444:所有人可读,但不可写(运行时只读)0644:所有者可读写,其他人只读0600:仅所有者可读写
0644,结果运维同学不小心改了值,导致驱动行为异常。所以,生产环境建议用 0 或 0444,调试阶段再放开写权限。
3. 数组参数
有时候你需要传一组值,比如多个I/O地址:
static int irq_array[4] = {0};
static int irq_count = 0;
module_param_array(irq_array, int, &irq_count, 0644);
MODULE_PARM_DESC(irq_array, "IRQ号列表,最多4个");
// 加载时:insmod my_module.ko irq_array=10,11,12
这里 irq_count 会自动记录实际传入的元素个数。嗯,这个特性在写多设备驱动时特别有用。
二、EXPORT_SYMBOL:让模块之间“互通有无”
模块参数解决了“用户怎么配置模块”的问题。那模块之间怎么互相调用呢?比如你写了个加密模块,别的模块想用你的函数——这就轮到 EXPORT_SYMBOL 出场了。
1. 导出符号的基本用法
假设我们有个“核心计算模块”:
// core_module.c
#include <linux/module.h>
int core_calculate(int a, int b)
{
return a * b + a + b; // 一个简单的运算
}
EXPORT_SYMBOL(core_calculate);
int core_init(void)
{
printk(KERN_INFO "核心模块已加载\n");
return 0;
}
另一个模块想用这个函数:
// user_module.c
extern int core_calculate(int a, int b); // 声明外部函数
static int __init user_init(void)
{
int result = core_calculate(3, 5);
printk(KERN_INFO "计算结果:%d\n", result);
return 0;
}
加载顺序很重要——必须先加载导出符号的模块:
insmod core_module.ko
insmod user_module.ko
核心要点:
- 导出模块用
EXPORT_SYMBOL()声明 - 使用模块用
extern声明 - 加载顺序:导出者先于使用者
- 可以用
lsmod查看模块依赖关系
2. EXPORT_SYMBOL_GPL:给代码加个“许可证锁”
如果你希望导出的符号只给 GPL 许可的模块使用:
EXPORT_SYMBOL_GPL(core_calculate); // 非GPL模块无法使用
为什么要有这个?说白了,这是内核社区保护知识产权的一种方式。我见过一些商业驱动,明明用了内核的 GPL 符号,却声称自己是专有软件——嗯,这其实是有法律风险的。
3. 查看已导出的符号
两个实用命令:
# 查看内核所有导出符号
cat /proc/kallsyms | grep " T "
# 查看某个模块导出的符号
cat /proc/kallsyms | grep "\[my_module\]"
或者用 nm 命令直接看模块文件:
nm my_module.ko | grep " T "
三、跨模块调用的实战经验
我在做嵌入式项目时,经常需要把驱动拆成多个模块。比如一个 I2C 总线驱动,一个传感器驱动,它们之间通过导出符号通信。
这里有几个坑,我踩过,你注意一下:
避坑指南:
- 符号冲突:不同模块导出同名符号,后加载的会覆盖前者。我建议加个模块名前缀,比如
i2c_core_register而不是register - 模块卸载顺序:如果模块A依赖模块B,必须先卸载A再卸载B。否则内核会 panic
- 符号版本控制:内核开启了
CONFIG_MODVERSIONS时,导出符号会带版本信息。接口变了记得重新编译所有依赖模块
四、知识体系总览
下面这张图,把这一节的核心逻辑串起来了:
说白了,模块参数是“对外接口”,让用户能调;导出符号是“对内接口”,让模块之间能协作。两者结合,你的模块才能真正融入内核生态。
我记得刚学内核开发那会儿,总觉得模块是孤立的。后来理解了这两个机制,才明白内核为什么能支撑那么复杂的驱动体系。嗯,你慢慢体会,后面实战章节我们会反复用到这些知识。
公众号:蓝海资料掘金营,微信deep3321