模块参数与导出符号:让内核模块“活”起来

模块写好了,怎么让它更灵活?

你想想看,一个驱动程序,如果每次改个参数都要重新编译,那得多麻烦。我个人习惯,能用参数解决的问题,绝不动代码。这一章,我们就来聊聊怎么让模块在加载时接受参数,以及如何让模块之间互相调用函数。

module_param:加载时传参,省去编译之苦

说白了,module_param 就是一个宏,它把模块内部的变量暴露给加载器。你在 insmod 的时候,可以像给函数传参一样,给模块变量赋值。

基本用法很简单:

#include <linux/module.h>
#include <linux/moduleparam.h>
#include <linux/init.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, "调试级别:0-关闭,1-基本信息,2-详细信息");
MODULE_PARM_DESC(device_name, "设备名称");

static int __init my_init(void)
{
    printk(KERN_INFO "模块加载,调试级别:%d,设备:%s\n",
           debug_level, device_name);
    return 0;
}

module_init(my_init);
MODULE_LICENSE("GPL");

加载时这样用:

sudo insmod my_module.ko debug_level=2 device_name="my_usb_dev"

这里有个细节,0644 是 sysfs 中的文件权限。如果你设成 0,参数就不会出现在 /sys/module/ 下,运行时也就没法改了。

我的习惯:调试阶段权限给 0644,方便运行时调整。发布版本给 0 或 0444,防止用户误操作。

参数类型与权限:不只是 int 和 charp

支持的参数类型比你想的要多。除了 int、charp,还有 long、uint、bool、invbool(反向布尔值)等。

数组参数怎么传?用 module_param_array:

static int irq_array[4];
static int irq_count;

module_param_array(irq_array, int, &irq_count, 0644);

// 加载时:insmod my_module.ko irq_array=10,20,30,40

嗯,这里要注意:数组长度由 irq_count 自动记录,你不需要手动指定个数。

参数类型 宏 示例值 说明
int module_param(name, int, perm) debug_level=2 标准整型
charp module_param(name, charp, perm) name="mydev" 字符串指针
bool module_param(name, bool, perm) enable=1 布尔值,接受 0/1 或 y/n
int 数组 module_param_array(name, int, &count, perm) arr=1,2,3 逗号分隔
我曾经踩过的坑:字符串参数 charp 传的值如果带空格,必须用引号括起来。比如 name="my device",否则 insmod 会把它当成两个参数。

EXPORT_SYMBOL:把函数“借”给别的模块

模块之间怎么调用函数?你不能直接 #include 对方的 .c 文件。内核提供了一套符号导出机制——EXPORT_SYMBOL。

假设你写了一个计算模块 calc.c:

#include <linux/module.h>
#include <linux/init.h>

int add(int a, int b)
{
    return a + b;
}
EXPORT_SYMBOL(add);

int sub(int a, int b)
{
    return a - b;
}
EXPORT_SYMBOL(sub);

static int __init calc_init(void)
{
    printk(KERN_INFO "计算模块加载\n");
    return 0;
}

module_init(calc_init);
MODULE_LICENSE("GPL");

另一个模块 user.c 就可以这样用:

extern int add(int, int);
extern int sub(int, int);

static int __init user_init(void)
{
    int result = add(10, 5);
    printk(KERN_INFO "10 + 5 = %d\n", result);
    return 0;
}

module_init(user_init);
MODULE_LICENSE("GPL");

加载顺序很重要:先加载 calc.ko,再加载 user.ko。反过来会报错,说符号找不到。

符号导出与模块依赖

内核怎么知道模块之间有依赖?它通过 Module.symvers 文件来追踪。当你编译 calc.c 时,会生成这个文件,里面记录了导出的符号和对应的 crc 校验值。

编译 user.c 时,需要把 calc 的 Module.symvers 复制过来,或者用 KBUILD_EXTRA_SYMBOLS 指定路径:

make -C /lib/modules/$(uname -r)/build M=$(PWD) \
    KBUILD_EXTRA_SYMBOLS=/path/to/calc/Module.symvers

加载时,insmod 会自动处理依赖。你可以用 lsmod 查看模块间的引用计数:

$ lsmod
Module                  Size  Used by
user                   16384  0
calc                   16384  1 user

看到 Used by 那一列了吗?calc 被 user 引用了一次,所以计数是 1。这时候你没法 rmmod calc,必须先卸载 user。

核心要点:
  • EXPORT_SYMBOL 导出的符号对所有模块可见
  • EXPORT_SYMBOL_GPL 只对 GPL 许可的模块可见
  • 模块加载顺序由依赖关系决定,insmod 会自动处理
  • 卸载时先卸引用者,再卸被引用者

GPL 与符号可见性

为什么要有 EXPORT_SYMBOL_GPL?这是内核社区的一个“君子协定”。如果你的模块声明了 MODULE_LICENSE("GPL"),才能调用 EXPORT_SYMBOL_GPL 导出的符号。非 GPL 模块调用这些符号,内核会打印警告,甚至拒绝加载。

我个人建议:如果你写的是开源驱动,一律用 EXPORT_SYMBOL_GPL。这既是对社区的尊重,也能避免一些法律风险。

知识结构图:模块参数与符号调用全景

模块参数与符号调用关系图 模块参数与符号调用全景 模块参数 (module_param) • 加载时传参:insmod xxx.ko param=val • 支持类型:int, charp, bool, 数组 • 权限控制:0644 运行时可见 • MODULE_PARM_DESC 添加说明 符号导出 (EXPORT_SYMBOL) • EXPORT_SYMBOL:所有模块可见 • EXPORT_SYMBOL_GPL:仅 GPL 模块 • Module.symvers 记录 CRC 校验 • 加载顺序:先导出后引用 无直接关系 模块间符号调用 1. 模块 A 导出函数:EXPORT_SYMBOL(func) 2. 模块 B 声明外部函数:extern int func() 3. 加载顺序:insmod A.ko → insmod B.ko 4. 卸载顺序:rmmod B → rmmod A 提示:模块参数用于配置,符号导出用于功能复用,两者配合可构建灵活的内核模块体系

实战避坑指南

我在项目中遇到过几次因为符号导出顺序搞错,导致系统启动时模块加载失败的情况。这里总结几个常见问题:

  • 符号找不到:检查 Module.symvers 是否包含目标符号,CRC 是否匹配。内核版本不同,CRC 会变。
  • 加载顺序错误:用 modprobe 代替 insmod,它会自动处理依赖。
  • 参数不生效:确认参数名和变量名一致,权限不为 0。
  • GPL 冲突:非 GPL 模块调用 EXPORT_SYMBOL_GPL 符号,内核会 taint(污染)标记。
我曾经犯过的错:有一次我写了一个闭源驱动,调用了 EXPORT_SYMBOL_GPL 导出的内核函数。加载时内核直接报 Unknown symbol,查了半天才发现是 GPL 限制。后来老老实实改成了 EXPORT_SYMBOL 或者自己重新实现。

嗯,这一章的内容就到这里。模块参数和符号导出,是让内核模块从“死代码”变成“活组件”的关键。参数让模块可配置,符号导出让模块可组合。你想想看,一个驱动如果能通过参数调整缓冲区大小,又能导出接口给上层协议栈调用,那它的复用性就大大提升了。