3. Linux 内核模块开发:模块结构、加载与卸载、模块参数、模块依赖
内核模块开发,说白了就是给 Linux 内核打补丁——不用重新编译整个内核,就能动态加功能。我刚开始接触时也觉得挺玄乎,后来发现其实套路很固定。今天咱们就把模块的结构、加载卸载、参数传递和依赖关系,一个一个说清楚。
核心要点:内核模块 = 入口函数 + 出口函数 + 许可证声明。缺一不可。
3.1 模块的基本结构
每个内核模块都有两个关键函数:module_init() 和 module_exit()。前者在模块加载时执行,后者在卸载时执行。嗯,这里要注意——这两个函数的名字可以随便起,但必须用宏注册一下。
我习惯把入口函数叫 hello_init,出口叫 hello_exit,简单明了。来看一个最简模块:
#include <linux/module.h>
#include <linux/kernel.h>
static int __init hello_init(void)
{
printk(KERN_INFO "Hello, Kernel!\n");
return 0;
}
static void __exit hello_exit(void)
{
printk(KERN_INFO "Goodbye, Kernel!\n");
}
module_init(hello_init);
module_exit(hello_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple example module");
这段代码里,__init 和 __exit 是内核的标记宏。加了 __init 的函数,加载完后内核会把它的内存释放掉——省空间。我在项目中遇到过有人忘了加 __init,结果模块占着内存不撒手,被同事吐槽了好久。
小提示:printk 的日志级别很重要。用 KERN_INFO 能看到消息,用 KERN_DEBUG 可能被系统过滤掉。调试时建议先用 KERN_ALERT。
3.2 模块的加载与卸载
加载模块用 insmod,卸载用 rmmod。这两个命令背后调用了 init_module() 和 delete_module() 系统调用。你想想看,内核其实就是在执行你写的入口和出口函数。
常用操作:
insmod hello.ko— 加载模块rmmod hello— 卸载模块(不用加 .ko)lsmod— 查看已加载的模块列表modinfo hello.ko— 查看模块信息(作者、描述、参数等)
我曾经在调试一个网卡驱动时,反复加载卸载模块上百次。有一次 rmmod 报错说模块正忙,查了半天发现是另一个驱动引用了它。这就引出了模块依赖的问题,咱们后面细说。
警告:不要在模块的出口函数里做耗时操作。内核期望出口函数尽快返回,否则可能导致系统卡死。我见过有人把 msleep(5000) 写在 module_exit 里,结果卸载时系统直接挂起。
3.3 模块参数
模块参数允许你在加载时传递配置值,不用改代码重新编译。用 module_param() 宏声明即可。
举个例子:
static int count = 1;
static char *name = "default";
module_param(count, int, 0644);
module_param(name, charp, 0644);
MODULE_PARM_DESC(count, "Number of times to print");
MODULE_PARM_DESC(name, "Name to print");
加载时这样传参:
insmod hello.ko count=5 name="mydev"
参数类型支持 int、long、charp(字符串指针)、bool 等。第三个参数是文件权限——如果设为非零值,会在 /sys/module/<模块名>/parameters/ 下创建对应的 sysfs 文件,运行时也能改。
我个人习惯把关键配置都做成模块参数,这样调试时不用反复编译。比如之前做 I2C 驱动,把设备地址和总线号都设成参数,换硬件平台时只需改启动脚本,不用动代码。
| 参数类型 | 宏 | 说明 |
|---|---|---|
| int | module_param(name, int, perm) |
整型参数 |
| charp | module_param(name, charp, perm) |
字符串指针 |
| bool | module_param(name, bool, perm) |
布尔值(y/n) |
| 数组 | module_param_array(name, type, num, perm) |
数组参数 |
小提示:参数权限 perm 设为 0 表示只读,加载后不可改。设为 0644 表示 root 可读写,其他人只读。生产环境建议用 0,避免运行时误改。
3.4 模块依赖
模块之间可以互相调用。比如模块 A 导出一个函数,模块 B 调用它。这就形成了依赖关系——加载 B 之前必须先加载 A。
导出符号用 EXPORT_SYMBOL():
// 模块 A
int my_add(int a, int b)
{
return a + b;
}
EXPORT_SYMBOL(my_add);
// 模块 B
extern int my_add(int a, int b);
static int __init b_init(void)
{
printk("3 + 5 = %d\n", my_add(3, 5));
return 0;
}
用 lsmod 查看时,会显示模块的依赖链:
Module Used By
module_b 0
module_a 1 module_b
这里 Used By 列显示 module_a 被 module_b 使用。卸载时必须先卸 module_b,再卸 module_a。否则内核会报错。
我曾经在调试一个音频驱动时,依赖链搞错了顺序,结果系统启动时模块加载失败,声卡不工作。后来用 modprobe 代替 insmod 解决了——modprobe 会自动处理依赖,先加载被依赖的模块。
警告:循环依赖是内核模块的大忌。模块 A 依赖 B,B 又依赖 A,加载时直接死锁。设计模块架构时一定要避免环形依赖。如果实在绕不开,考虑把公共代码抽成第三个模块。
3.5 知识体系总览
下面这张图把本章的核心知识点串起来了。你可以看到模块从编写、编译到加载、卸载的完整流程,以及参数和依赖在其中的位置。
3.6 避坑指南
做内核模块开发,有几个坑我踩过,分享给你:
- 忘记加 MODULE_LICENSE:内核会报 "kernel tainted" 警告。虽然能加载,但很多内核功能会受限。我建议一律写 "GPL"。
- 出口函数不释放资源:加载时申请了内存、注册了设备,卸载时一定要释放。否则内存泄漏,系统越跑越慢。
- 参数类型不匹配:传字符串时用
charp,传数字用int。类型错了模块加载直接失败。 - 依赖顺序搞反:用
modprobe代替insmod,它能自动解析依赖关系。
总结一下:内核模块开发的核心就是写好入口和出口,用好参数和符号导出。记住这四点——结构、加载卸载、参数、依赖——你就能写出健壮的模块。剩下的就是多练,踩坑多了自然就熟了。