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

说实话,刚接触内核模块开发那会儿,我总觉得模块就是个“黑盒子”——加载进去就干活,想改个参数还得重新编译。后来我才明白,模块参数导出符号这两个机制,才是让模块真正灵活起来的关键。

这一节,我们就来聊聊怎么给模块“开个口子”,让用户能在加载时调整行为,以及怎么让模块之间互相调用。嗯,这些都是我实际项目中天天用的东西。

一、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,结果运维同学不小心改了值,导致驱动行为异常。所以,生产环境建议用 00444,调试阶段再放开写权限。

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 时,导出符号会带版本信息。接口变了记得重新编译所有依赖模块

四、知识体系总览

下面这张图,把这一节的核心逻辑串起来了:

模块参数与导出符号知识体系 模块参数 (module_param) 核心作用:用户态配置模块行为 支持类型:int, charp, bool, array... 权限控制:0(隐藏), 0444(只读), 0644(读写) 使用场景: • 调试级别控制 • 设备地址/IRQ配置 • 运行时行为切换 • 数组参数(多设备支持) 加载示例:insmod xxx.ko debug=2 name="dev" 导出符号 (EXPORT_SYMBOL) 核心作用:模块间函数/变量共享 两种形式:EXPORT_SYMBOL / EXPORT_SYMBOL_GPL 查看方式:/proc/kallsyms, nm命令 使用场景: • 驱动分层(总线/设备分离) • 核心算法库共享 • 硬件抽象层实现 • 多模块协作框架 注意:加载顺序——导出者先于使用者 共同目标:让内核模块更灵活、可配置、可复用

说白了,模块参数是“对外接口”,让用户能调;导出符号是“对内接口”,让模块之间能协作。两者结合,你的模块才能真正融入内核生态。

我记得刚学内核开发那会儿,总觉得模块是孤立的。后来理解了这两个机制,才明白内核为什么能支撑那么复杂的驱动体系。嗯,你慢慢体会,后面实战章节我们会反复用到这些知识。


公众号:蓝海资料掘金营,微信deep3321