内核模块编程:模块加载与卸载、module_init/module_exit机制、模块参数传递、模块依赖与符号导出
说实话,很多刚接触内核开发的朋友,第一个坎儿就是搞不懂内核模块到底怎么跑起来的。你写了个hello world,insmod进去,dmesg一看,打印出来了。然后rmmod,又打印了。嗯,看起来很简单对吧?但这里面的门道,比你想象的要深得多。
我当年第一次写内核模块,就踩了个大坑。模块加载成功了,但卸载的时候系统直接panic。后来查了半天,原来是exit函数里释放了一个不该释放的资源。从那以后,我对module_init和module_exit这对兄弟,就格外敬畏。
模块加载与卸载:内核的“插件”机制
内核模块,说白了就是Linux内核的“插件”。你想想看,如果每次要加个驱动都得重新编译整个内核,那得多痛苦?模块机制就是为了解决这个问题的。
加载一个模块,用insmod或者modprobe。卸载用rmmod。但这两个命令背后,内核做了哪些事?
- insmod:把模块的二进制文件读到内核空间,然后调用
init_module()系统调用。内核会做符号解析、重定位,最后调用你的module_init函数。 - rmmod:检查模块的引用计数,如果为0,就调用module_exit函数,然后释放模块占用的内存。
- modprobe:比insmod更智能。它会自动处理模块依赖,把依赖的模块先加载进来。
核心要点:模块加载后,它的代码和数据都在内核空间。卸载时,必须确保没有其他模块或进程在使用它。否则,轻则Oops,重则系统崩溃。
module_init / module_exit:模块的“构造函数”和“析构函数”
每个内核模块都必须有这两个函数。它们定义了模块的生命周期。
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
static int __init my_module_init(void)
{
printk(KERN_INFO "Hello, kernel! Module loaded.\n");
// 在这里做初始化工作:注册设备、分配内存、创建文件等
return 0; // 返回0表示成功,负数表示失败
}
static void __exit my_module_exit(void)
{
printk(KERN_INFO "Goodbye, kernel! Module unloaded.\n");
// 在这里做清理工作:释放资源、注销设备等
}
module_init(my_module_init);
module_exit(my_module_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple kernel module example");
这里有几个细节,我建议你注意一下:
__init和__exit宏:它们告诉内核,这些函数只在特定阶段使用。__init函数在模块加载完成后,占用的内存可以被回收。__exit函数在模块编译进内核时会被忽略。- 返回值:init函数返回0表示成功。返回负数,比如-ENOMEM或-EINVAL,模块加载就会失败。我曾经见过有人返回正数,结果模块加载成功了但行为诡异——因为内核只检查返回值是否为0。
- exit函数:它是void类型,没有返回值。但你要确保它能把init里分配的所有资源都释放干净。
我的习惯:在init函数里,我会用一个goto式的错误处理结构。每一步初始化失败,就跳转到对应的错误标签去清理已经分配的资源。这样代码清晰,也不容易漏掉释放。
模块参数传递:让模块更灵活
你写了一个驱动,但不同的硬件可能有不同的IO地址、中断号。难道每次都要改代码重新编译?当然不用。模块参数就是干这个的。
#include <linux/moduleparam.h>
static int irq = 10;
static char *device_name = "my_device";
module_param(irq, int, 0644);
module_param(device_name, charp, 0644);
MODULE_PARM_DESC(irq, "Interrupt number for the device");
MODULE_PARM_DESC(device_name, "Device name string");
static int __init my_module_init(void)
{
printk(KERN_INFO "Device: %s, IRQ: %d\n", device_name, irq);
return 0;
}
加载时传参:
insmod my_module.ko irq=15 device_name="my_device"
参数类型支持:byte、short、ushort、int、uint、long、ulong、charp(字符串指针)、bool、invbool。
第三个参数是文件权限。如果设为0,参数在sysfs中不可见。设为0644,用户可以通过/sys/module/模块名/parameters/来查看和修改。
我曾经踩过的坑:用charp传递字符串时,传进去的指针指向的是用户空间。如果模块里直接访问这个指针,会引发严重错误。内核模块必须用strncpy_from_user之类的函数来拷贝。不过好消息是,内核框架已经帮我们处理好了——你只管用,它内部已经做了拷贝。
模块依赖与符号导出:模块间的“合作”
一个模块可能需要调用另一个模块提供的函数。比如,一个字符设备驱动可能需要调用USB核心模块的函数。这就涉及到了符号导出。
内核用EXPORT_SYMBOL和EXPORT_SYMBOL_GPL来导出符号(函数或变量)。
// 在模块A中
#include <linux/module.h>
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 module_b_init(void)
{
int result = my_add(3, 4);
printk(KERN_INFO "Result: %d\n", result);
return 0;
}
加载顺序:必须先加载模块A,再加载模块B。或者用modprobe,它会自动处理。
查看导出符号:
cat /proc/kallsyms | grep my_add
或者查看模块的符号表:
nm my_module.ko | grep T
关键区别:EXPORT_SYMBOL_GPL导出的符号,只有声明了MODULE_LICENSE("GPL")的模块才能使用。这是内核社区用来保护GPL代码的一种方式。我个人建议,如果你的代码是开源的,就用GPL导出;如果是商业闭源模块,那就只能用非GPL导出的符号了。
知识体系总览
下面这张图,是我梳理的本章核心逻辑。你看一遍,应该就能把整个模块编程的脉络串起来了。
避坑指南
做内核模块开发,有些坑是新人必踩的。我把自己这些年摔过的跟头总结一下:
- 资源泄漏:init函数里申请了内存、注册了设备,但中途出错了,直接返回错误码。结果exit函数没被调用,资源就泄漏了。我的做法:每个资源分配后,用一个标志位记录,exit里检查所有标志位。
- 引用计数:模块卸载时,如果还有进程在使用它,rmmod会失败。但如果你强制卸载(rmmod -f),系统可能直接崩溃。我曾经在调试时这么干过,然后花了半天重编内核。
- 符号冲突:两个模块导出了同名符号,加载第二个时会失败。所以导出符号时,我习惯加个模块名前缀,比如
my_module_my_function。 - 参数权限:如果参数权限设为0644,用户可以通过sysfs修改。但修改时机不对,可能导致模块状态不一致。我建议,如果参数只在加载时使用,权限设为0。
一个小技巧:调试模块时,多用printk打印关键信息。但注意,生产环境里别打太多,否则dmesg会被刷爆。可以用pr_debug,它在非调试模式下会被编译掉。
嗯,关于内核模块编程的基础,差不多就这些了。你把这些搞明白,写一个能正常加载卸载的模块,应该不成问题。剩下的,就是在实际项目中慢慢积累了。