内核模块基础:模块的加载与卸载、模块参数传递、模块依赖与符号导出

说到内核模块,我刚开始接触Linux驱动时,总觉得它挺神秘的。说白了,模块就是一段可以在运行时动态加载到内核的代码。你想想看,不用重新编译整个内核,不用重启机器,就能给系统添加一个新功能——这设计确实漂亮。

模块的加载与卸载:insmod与rmmod的幕后故事

先看一个最简单的模块代码。我习惯把它叫做"Hello World"的驱动版:

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

static int __init hello_init(void)
{
    printk(KERN_INFO "Hello, module loaded!\n");
    return 0;
}

static void __exit hello_exit(void)
{
    printk(KERN_INFO "Goodbye, module unloaded!\n");
}

module_init(hello_init);
module_exit(hello_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple example module");

这里有两个关键宏:module_initmodule_exit。它们告诉内核:加载时执行哪个函数,卸载时执行哪个函数。嗯,这里要注意——__init__exit 这两个标记不是摆设。加了 __init 的函数,初始化完成后内核会把它占用的内存释放掉。我在项目中遇到过有人没加这个标记,结果模块加载后白白浪费了几KB内存,虽然不多,但积少成多啊。

加载模块用 insmod,卸载用 rmmod。但你知道吗?insmod 背后其实调用了 init_module 系统调用,而 rmmod 对应 delete_module。内核会检查模块的依赖关系、许可证、版本信息等,全部通过后才真正执行你的初始化函数。

避坑指南: 我曾经在调试一个USB驱动时,反复 insmod/rmmod 后发现系统越来越慢。后来排查才发现,模块的退出函数里忘了释放申请的中断号和DMA缓冲区。记住:rmmod 只负责卸载,你申请的资源必须自己在退出函数里释放。

模块参数传递:让驱动更灵活

写驱动时,你肯定不希望把所有的配置都硬编码在代码里。比如调试开关、缓冲区大小、设备地址这些,最好能在加载时动态指定。这就是模块参数的作用。

看个例子:

#include <linux/moduleparam.h>

static int debug = 0;
static char *device_name = "default_dev";

module_param(debug, int, 0644);
module_param(device_name, charp, 0644);

MODULE_PARM_DESC(debug, "Enable debug output (0=off, 1=on)");
MODULE_PARM_DESC(device_name, "Device name string");

static int __init my_init(void)
{
    printk("Debug mode: %d\n", debug);
    printk("Device name: %s\n", device_name);
    return 0;
}

加载时这样传参:

insmod my_module.ko debug=1 device_name="my_usb_dev"

module_param 的三个参数分别是:变量名、类型、权限。权限这里我多说一句——0644 表示这个参数在 /sys/module/ 下会生成一个文件,root用户可以运行时修改。我个人习惯把调试参数设成0644,这样不用卸载模块就能动态开关调试输出,排查问题特别方便。

小技巧: 如果你不想让用户运行时修改参数,把权限设成0就行。比如 module_param(debug, int, 0); 这样参数只能在加载时指定,加载后就变成只读了。

支持的参数类型有:boolintlongcharp(字符串指针)、int数组 等。数组参数用 module_param_array 宏,用法类似。

模块依赖与符号导出:模块间的"社交关系"

模块不是孤岛。一个模块可能需要调用另一个模块提供的函数。比如你写了一个I2C控制器驱动,另一个I2C设备驱动需要调用你的传输函数。这时候就需要符号导出。

导出符号用这两个宏:

EXPORT_SYMBOL(func_name);
EXPORT_SYMBOL_GPL(func_name);

区别在于:EXPORT_SYMBOL_GPL 只允许GPL许可证的模块使用。我个人建议,除非有特殊商业需求,否则一律用 EXPORT_SYMBOL_GPL。为什么?因为很多内核核心函数都要求GPL,你导出的符号如果被非GPL模块用了,可能会引发许可证合规问题。

看个实际例子:

// 模块A:导出函数
#include <linux/module.h>

void my_custom_function(void)
{
    printk("This is an exported function\n");
}
EXPORT_SYMBOL_GPL(my_custom_function);

MODULE_LICENSE("GPL");

// 模块B:使用导出的函数
extern void my_custom_function(void);

static int __init b_init(void)
{
    my_custom_function();
    return 0;
}
module_init(b_init);
MODULE_LICENSE("GPL");

加载时,必须先加载模块A,再加载模块B。如果顺序反了,insmod 会报 "Unknown symbol" 错误。内核的模块加载器会检查所有未解析的符号,然后去已加载的模块里找。找不到就拒绝加载。

核心要点: 模块依赖是自动管理的。你用 modprobe 加载模块B时,它会自动先加载模块A。但用 insmod 不会——它只做你让它做的事。所以生产环境建议用 modprobe,它会处理依赖关系。

查看模块的符号表可以用:

nm my_module.ko | grep EXPORT_SYMBOL

或者加载后用:

cat /proc/kallsyms | grep my_custom_function

我记得有一次调试一个音频驱动,发现某个符号总是解析失败。折腾了半天,最后发现是模块B里声明了 extern,但模块A导出时用了 EXPORT_SYMBOL(不带GPL),而模块B的许可证是 PROPRIETARY。内核默认不允许非GPL模块调用非GPL导出的符号?其实不是——EXPORT_SYMBOL 对所有模块开放,但 EXPORT_SYMBOL_GPL 只对GPL模块开放。那次的问题其实是模块B忘了声明 MODULE_LICENSE("GPL")

最后,用表格总结一下关键点:

操作 命令/宏 说明
加载模块 insmod / modprobe modprobe自动处理依赖
卸载模块 rmmod / modprobe -r modprobe -r 递归卸载依赖
声明初始化函数 module_init() 加载时调用
声明退出函数 module_exit() 卸载时调用
模块参数 module_param() 支持int/charp/bool等类型
导出符号 EXPORT_SYMBOL[_GPL]() GPL版本限制许可证
查看已加载模块 lsmod 显示模块列表和依赖
查看模块信息 modinfo 显示作者、描述、参数等

嗯,模块的基础知识就这些。说白了就是三件事:怎么加载卸载、怎么传参数、怎么让模块之间互相调用。把这些搞清楚了,后面写驱动时就不会在基础设施上栽跟头。