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 知识体系总览

下面这张图把本章的核心知识点串起来了。你可以看到模块从编写、编译到加载、卸载的完整流程,以及参数和依赖在其中的位置。

Linux 内核模块开发知识体系 模块结构 module_init / module_exit MODULE_LICENSE / __init 加载与卸载 insmod / rmmod / lsmod modprobe 自动处理依赖 模块参数 module_param / charp / int sysfs 运行时配置 模块依赖 EXPORT_SYMBOL Used By 依赖链 关键流程 1. 编写模块代码(入口 + 出口 + 许可证) 2. 编译生成 .ko 文件 3. insmod 加载(可传参数)→ 内核调用 module_init 4. rmmod 卸载 → 内核调用 module_exit

3.6 避坑指南

做内核模块开发,有几个坑我踩过,分享给你:

  • 忘记加 MODULE_LICENSE:内核会报 "kernel tainted" 警告。虽然能加载,但很多内核功能会受限。我建议一律写 "GPL"。
  • 出口函数不释放资源:加载时申请了内存、注册了设备,卸载时一定要释放。否则内存泄漏,系统越跑越慢。
  • 参数类型不匹配:传字符串时用 charp,传数字用 int。类型错了模块加载直接失败。
  • 依赖顺序搞反:用 modprobe 代替 insmod,它能自动解析依赖关系。

总结一下:内核模块开发的核心就是写好入口和出口,用好参数和符号导出。记住这四点——结构、加载卸载、参数、依赖——你就能写出健壮的模块。剩下的就是多练,踩坑多了自然就熟了。