内核模块基础:模块的加载与卸载、模块参数传递、模块依赖与符号导出、printk调试技巧
说实话,很多刚接触驱动开发的朋友,第一个坎儿就是搞不懂内核模块到底是个什么东西。我当年也一样,总觉得它跟普通的应用程序差不多,结果一跑就崩,连个报错都看不懂。
内核模块,说白了就是一段可以在系统运行时动态加载到内核的代码。它不像应用程序那样有自己的进程空间,而是直接跑在内核态,跟内核共享地址空间。这意味着——你写错了,系统就挂了,连个招呼都不打。
核心概念:内核模块是Linux内核实现"按需加载"的机制。驱动、文件系统、网络协议等都可以编译成模块,需要时插入,不用时卸载,避免内核镜像过于臃肿。
2.1 模块的加载与卸载
先看一个最简单的模块代码。我习惯把这个模板叫做"Hello World的进阶版"——它虽然简单,但五脏俱全。
#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;
}
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");
这里有两个关键点:module_init 和 module_exit 宏。它们告诉内核,加载模块时调用哪个函数,卸载时调用哪个函数。嗯,这里要注意——__init 和 __exit 这两个标记不是摆设。加了 __init 的函数,在模块加载完成后,内核会把它的代码从内存中释放掉,省空间。
编译成 .ko 文件后,操作就简单了:
# 加载模块
insmod my_module.ko
# 查看已加载的模块
lsmod
# 卸载模块
rmmod my_module
insmod 和 modprobe 有什么区别?我刚开始也搞混过。insmod 是硬加载,你给它什么文件它就加载什么,不处理依赖。modprobe 则会自动解析依赖关系,把需要的其他模块一起加载进来。所以实际项目中,我基本只用 modprobe。
避坑指南:我曾经在调试一个USB驱动时,用 insmod 加载模块,结果系统直接卡死。后来发现是因为模块依赖的另一个核心模块没加载。从那以后,但凡涉及多个模块的场景,我都用 modprobe。
2.2 模块参数传递
驱动写死了参数值,每次改都要重新编译?太蠢了。内核模块支持在加载时传递参数,这个功能我几乎每个项目都会用。
#include <linux/moduleparam.h>
static int debug_level = 0;
static char *device_name = "default_dev";
module_param(debug_level, int, 0644);
module_param(device_name, charp, 0644);
MODULE_PARM_DESC(debug_level, "Debug level (0-3)");
MODULE_PARM_DESC(device_name, "Device name string");
static int __init my_module_init(void)
{
printk(KERN_INFO "Debug level: %d\n", debug_level);
printk(KERN_INFO "Device name: %s\n", device_name);
return 0;
}
加载时这样传参:
insmod my_module.ko debug_level=2 device_name="my_usb_dev"
module_param 宏的三个参数:变量名、类型、权限。权限这里我多说一句——0644 表示在 /sys/module/ 下创建对应的参数文件,允许用户查看,只有 root 能写。如果你设成 0,那就只能在加载时传参,运行时改不了。
| 类型 | 描述 | 示例 |
|---|---|---|
| byte / short / int / long | 基本整数类型 | module_param(count, int, 0) |
| uint / ulong | 无符号整数 | module_param(flags, uint, 0644) |
| charp | 字符串指针 | module_param(name, charp, 0) |
| bool / invbool | 布尔值(支持0/1, y/n) | module_param(enable, bool, 0644) |
| 数组 | 用 module_param_array |
module_param_array(arr, int, &num, 0) |
我的习惯:调试阶段,我会把参数权限设成 0644,这样可以在运行时通过 echo 3 > /sys/module/my_module/parameters/debug_level 动态调整日志级别,不用反复加载卸载模块。
2.3 模块依赖与符号导出
一个驱动往往不是孤立的。比如你的字符设备驱动可能需要依赖一个底层的GPIO控制器模块。这时候就需要符号导出机制。
内核用 EXPORT_SYMBOL 和 EXPORT_SYMBOL_GPL 来导出符号(函数或变量),供其他模块使用。
// 在 gpio_ctrl.c 中
#include <linux/module.h>
int gpio_set_value(int pin, int value)
{
// 实际的 GPIO 操作
return 0;
}
EXPORT_SYMBOL(gpio_set_value);
// 在 my_driver.c 中
extern int gpio_set_value(int pin, int value);
static int __init my_driver_init(void)
{
gpio_set_value(17, 1); // 调用导出的函数
return 0;
}
加载顺序很重要。必须先加载 gpio_ctrl.ko,再加载 my_driver.ko。用 modprobe 的话,它会自动处理这个顺序。
查看模块导出了哪些符号:
# 查看所有导出的符号
cat /proc/kallsyms | grep gpio_set_value
# 查看某个模块导出的符号
cat /proc/kallsyms | grep gpio_ctrl
EXPORT_SYMBOL_GPL 和 EXPORT_SYMBOL 的区别?前者只允许 GPL 协议的模块使用。我个人建议,如果你的代码是开源项目,用 EXPORT_SYMBOL_GPL 更合适,能避免一些许可证纠纷。
我曾经踩过的坑:有一次我写了一个驱动,依赖另一个模块导出的函数。结果那个模块更新后,导出的函数签名变了,我的模块加载时直接报 Unknown symbol 错误。从那以后,我养成了一个习惯——在模块的 MODULE_DESCRIPTION 里明确写明依赖的模块和版本,方便排查。
2.4 printk 调试技巧
驱动开发调试,说白了大部分时间就是靠 printk。没有 GDB,没有断点,系统一崩就是 panic,你只能靠日志来还原现场。
printk 跟 printf 很像,但多了个日志级别:
printk(KERN_EMERG "System is unusable\n"); // 0
printk(KERN_ALERT "Action must be taken\n"); // 1
printk(KERN_CRIT "Critical condition\n"); // 2
printk(KERN_ERR "Error condition\n"); // 3
printk(KERN_WARNING "Warning condition\n"); // 4
printk(KERN_NOTICE "Normal but significant\n"); // 5
printk(KERN_INFO "Informational\n"); // 6
printk(KERN_DEBUG "Debug-level message\n"); // 7
日志级别从 0(最紧急)到 7(最不紧急)。内核会根据 /proc/sys/kernel/printk 中的配置决定哪些级别的消息能输出到控制台。
# 查看当前 printk 配置
cat /proc/sys/kernel/printk
# 输出示例:7 4 1 7
# 分别对应:控制台级别、默认级别、最低级别、最大级别
第一个数字 7 表示只有级别小于 7 的消息才会输出到控制台。也就是说,KERN_DEBUG(级别 7)默认是看不到的。想看调试信息?调低这个值:
echo 8 > /proc/sys/kernel/printk
或者直接用 dmesg 查看所有日志,不管级别:
# 查看所有内核日志
dmesg
# 实时跟踪
dmesg -w
# 清空日志
dmesg -c
我的调试三板斧:
- 开发阶段:把所有
printk都写成KERN_INFO或KERN_DEBUG,用dmesg -w实时看 - 定位问题:在关键路径上加
KERN_ERR,即使控制台级别高也能看到 - 性能敏感路径:用
printk_ratelimited或pr_debug(编译时通过DEBUG宏控制开关),避免刷屏
说到 pr_debug,这是内核提供的一个便捷宏。它在编译时如果定义了 DEBUG 才会生效,否则就是个空操作。我习惯在模块开头加一行:
#define DEBUG
#include <linux/module.h>
这样调试信息只在调试版本中输出,发布时去掉 #define DEBUG 就行,不用改代码。
重要提醒:生产环境中,printk 是有性能开销的。我曾经在一个中断处理函数里加了 printk,结果系统响应变得极慢。原因很简单——printk 在中断上下文中可能会引起调度延迟。所以,调试完记得删掉或者用条件编译关掉。
这张图把模块从加载到卸载的完整流程串起来了。你可以看到,调试手段贯穿整个生命周期——加载时看 printk 输出,运行时调参数、查符号,卸载时确认清理函数是否正常执行。
嗯,这一章的内容就这些。模块加载卸载、参数传递、符号导出、printk 调试,这四个点是你写驱动的基本功。我当年花了整整一周才把这些搞清楚,现在你有了这份总结,应该能快很多。