4、内核模块开发基础:模块编程框架与调试手段
内核模块开发,说白了就是给Linux内核“打补丁”或者“加插件”。你不需要重新编译整个内核,就能动态加载一段代码进去。我刚开始接触时也觉得挺神奇——一个.ko文件,insmod一下,内核就多了个新功能。这背后的核心就是module_init和module_exit这两个宏。
4.1 模块编程框架:入口与出口
每个内核模块都必须有两个基本函数:一个初始化函数,一个退出函数。它们通过宏注册到内核框架中。
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
static int __init my_driver_init(void)
{
printk(KERN_INFO "Hello, kernel module loaded!\n");
return 0;
}
static void __exit my_driver_exit(void)
{
printk(KERN_INFO "Goodbye, kernel module unloaded!\n");
}
module_init(my_driver_init);
module_exit(my_driver_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Your Name");
MODULE_DESCRIPTION("A simple example module");
这里有几个关键点:
- __init和__exit是内核的节区标记。初始化函数在运行完后,占用的内存会被释放。我遇到过一个问题:调试时想反复加载模块,结果发现__init标记的函数第二次就不见了——嗯,它已经被内核回收了。
- module_init告诉内核:加载模块时调用这个函数。module_exit则告诉内核:卸载模块时调用这个函数。
- 返回值必须小心。初始化成功返回0,失败返回负数错误码。我曾经犯过一个低级错误:初始化函数返回了1,结果insmod显示“Operation not permitted”,查了半天才发现是返回值不对。
核心原则:初始化函数必须保证原子性——要么全部成功,要么全部回滚。如果中途失败,之前申请的资源必须全部释放。
4.2 模块参数传递
你写了一个驱动,但不同硬件平台的寄存器地址不一样。难道每次都要改源码重新编译?不用。内核模块支持参数传递,加载时通过命令行指定。
#include <linux/moduleparam.h>
static int irq_num = 10;
static char *device_name = "my_device";
module_param(irq_num, int, 0644);
module_param(device_name, charp, 0644);
MODULE_PARM_DESC(irq_num, "IRQ number for the device");
MODULE_PARM_DESC(device_name, "Device name string");
加载时这样用:
insmod my_driver.ko irq_num=33 device_name="uart0"
参数类型支持int、long、charp(字符串指针)、数组等。第三个参数是文件权限——如果为0,表示参数在sysfs中不可见;如果设为0644,你可以在/sys/module/你的模块名/parameters/下看到并修改它。
我的习惯:调试阶段把参数权限设为0644,这样可以在运行时通过echo命令动态调整参数值,不用反复卸载加载模块。但产品发布前,我会改成0,避免用户误操作。
4.3 模块依赖与符号导出
模块之间可以互相调用。比如你写了一个核心算法模块,另一个模块想用它的函数。这就涉及到符号导出。
// 在模块A中
int calculate_crc(const char *data, int len)
{
// CRC计算逻辑
return result;
}
EXPORT_SYMBOL(calculate_crc);
模块B只需要声明这个函数,链接时就能找到它。但要注意:
- 模块B加载前,模块A必须已经加载。否则内核会报“Unknown symbol”错误。
- 使用EXPORT_SYMBOL_GPL可以限制只有GPL许可的模块才能调用。
查看模块依赖关系,用lsmod命令:
lsmod | grep my_module
它会显示模块的引用计数和被哪些模块使用。我曾经在调试一个网络驱动时,发现模块卸载不掉,引用计数一直是1。查了半天,原来是另一个模块持有了它的符号引用,却没有释放。
避坑指南:模块卸载时,一定要确保没有其他模块在使用你导出的符号。否则内核会panic。我遇到过生产环境因为这个问题导致系统重启——从那以后,我每次卸载模块前都会用lsmod确认引用计数为0。
4.4 内核打印(printk)与日志级别
printk是内核模块的printf。但它比printf复杂——它有日志级别,用来控制消息的紧急程度和显示位置。
| 宏定义 | 数值 | 含义 | 典型场景 |
|---|---|---|---|
| KERN_EMERG | 0 | 紧急事件 | 系统崩溃前 |
| KERN_ALERT | 1 | 必须立即处理 | 严重硬件故障 |
| KERN_CRIT | 2 | 临界条件 | 驱动初始化失败 |
| KERN_ERR | 3 | 错误 | I/O操作失败 |
| KERN_WARNING | 4 | 警告 | 参数超出范围 |
| KERN_NOTICE | 5 | 正常但重要 | 模块加载成功 |
| KERN_INFO | 6 | 信息 | 调试信息 |
| KERN_DEBUG | 7 | 调试 | 详细日志 |
日志级别决定了消息是否显示在控制台。内核有一个console_loglevel变量,只有级别低于它的消息才会打印到控制台。查看当前控制台级别:
cat /proc/sys/kernel/printk
# 输出示例:4 4 1 7
# 分别表示:控制台级别、默认级别、最低级别、启动时级别
如果你想看到所有调试信息,可以临时调高级别:
echo 8 > /proc/sys/kernel/printk
我的调试习惯:开发阶段把printk级别设为KERN_DEBUG,同时把控制台级别调到8。这样所有日志都能看到。但产品发布时,我会把调试信息全部去掉或用pr_debug替代——pr_debug在非调试内核中会被编译掉,不影响性能。
printk的输出可以通过dmesg命令查看。它会把内核日志环缓冲区的内容打印出来。注意:环缓冲区大小有限,如果日志太多,早期的消息会被覆盖。
dmesg | tail -20 # 查看最近20条内核日志
dmesg -c # 查看并清空日志缓冲区
4.5 知识结构总览
下面这张图把本章的核心知识点串了起来。你可以把它当作模块开发的“地图”——从编写代码到加载运行,每一步都有对应的机制。
4.6 避坑总结
最后,我把这些年踩过的坑整理成一张表,你写模块时对照着检查,能省不少时间。
| 常见问题 | 现象 | 根因 | 解决方案 |
|---|---|---|---|
| 模块加载失败 | insmod返回错误 | 初始化函数返回非0值 | 检查返回值,确保成功返回0 |
| 模块卸载不掉 | rmmod提示“Module in use” | 引用计数不为0 | 用lsmod查看谁在使用,先卸载依赖模块 |
| Unknown symbol | 加载时报符号未定义 | 依赖模块未加载或符号未导出 | 先加载依赖模块,或检查EXPORT_SYMBOL |
| printk不显示 | 控制台看不到日志 | 日志级别高于console_loglevel | 调高console_loglevel或用dmesg查看 |
| 模块参数无效 | insmod传参后值不对 | 参数类型不匹配或权限为0 | 检查module_param类型和权限设置 |
最后提醒一句:内核模块运行在特权级,一个空指针就能让整个系统挂掉。我建议你在开发机上先测试,确认无误后再部署到目标设备。调试时多用printk,但上线前记得清理——不然生产环境的内核日志会被你的调试信息刷爆。