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 表示所有人可读
我的习惯:调试阶段 perm 给 0644,方便运行时查看。产品发布前改成 0,避免用户空间随意修改。

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 参数传递的三种方式

参数怎么传进去的?我总结了一下,主要有三种场景:

  1. insmod 加载时传参:最常用,insmod xxx.ko key=val
  2. modprobe 加载时传参:配合 /etc/modprobe.d/ 下的配置文件,适合系统启动时自动加载
  3. 运行时通过 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 知识体系图

下面这张图把本章的核心逻辑串起来了:

模块参数与导出符号知识体系 模块参数 (module_param) • 基本类型:int, charp, bool 等 • 数组参数:module_param_array • 权限控制:perm 参数 (0644/0) • 描述信息:MODULE_PARM_DESC • 运行时修改:/sys/module/.../parameters/ 导出符号 (EXPORT_SYMBOL) • 导出函数:EXPORT_SYMBOL(func) • GPL 限制:EXPORT_SYMBOL_GPL • 跨模块调用:extern 声明 • 查看符号:/proc/kallsyms • 依赖管理:lsmod 查看引用计数 共同构成 跨模块调用核心流程 模块A 定义函数 → EXPORT_SYMBOL → 模块B extern 声明 → 调用 ⚠️ 避坑指南 • 加载顺序:先导出模块,后调用模块 • 卸载顺序:先调用模块,后导出模块(否则内核 panic) • 数组参数:手动检查越界,内核不会帮你保护

3.6 实战小技巧

最后分享几个我在项目中积累的经验:

  • 参数命名规范:用下划线分隔,比如 debug_level、filter_ports。别用驼峰,内核社区不待见。
  • 导出符号的版本控制:如果导出函数的接口变了,记得更新 MODULE_VERSION。否则其他模块编译时可能链接到旧版本,运行时出诡异问题。
  • 调试技巧:用 modinfo 查看模块参数和描述,用 cat /proc/kallsyms | grep <模块名> 确认符号是否导出成功。

我的习惯:每个导出函数都在注释里写明“谁在用、参数含义、返回值说明”。别嫌麻烦——三个月后你自己回来看代码,会感谢当初的注释。