3. Linux内核模块基础
Linux内核模块(Loadable Kernel Module,LKM)是驱动开发的最小交付单元。它让驱动代码可以独立编译成 .ko 文件,在系统运行时动态插入内核或从内核移除,而不必重新编译整个内核。理解模块的加载卸载机制、参数传递、符号导出以及内核日志系统,是后续编写字符设备、平台设备、I2C/SPI 等真实驱动的基础。本节围绕这四条主线展开,并给出可直接验证的实验方法。
一、模块的加载与卸载机制
模块的本质是一段被内核链接器动态装载的 ELF 目标文件。用户空间通过 insmod、modprobe、rmmod 等命令触发系统调用,内核侧的 init_module() 与 delete_module() 完成实际装载与卸载。每个模块必须至少提供两个入口:加载时执行的初始化函数和卸载时执行的清理函数。
#include <linux/module.h>
#include <linux/init.h>
static int __init demo_init(void)
{
pr_info("demo module loaded\n");
return 0; /* 返回 0 表示加载成功 */
}
static void __exit demo_exit(void)
{
pr_info("demo module unloaded\n");
}
module_init(demo_init);
module_exit(demo_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("driver-team");
MODULE_DESCRIPTION("kernel module basic demo");
其中 __init 与 __exit 是段属性标记:初始化函数在加载完成后其内存会被释放,清理函数在模块被编译进内核时会被丢弃。MODULE_LICENSE("GPL") 非常关键,它决定模块能否使用内核导出的 GPL-only 符号,缺失或写成非 GPL 会导致大量符号无法解析。
insmod 只返回一句笼统的错误,真正的失败原因通常记录在内核日志中。因此排障第一步永远是 dmesg | tail,而不是反复重试 insmod。二、模块参数:让同一份驱动适配不同硬件
模块参数允许在加载时向模块传入配置值,避免为每个板级差异重新编译。内核提供 module_param() 系列宏,把变量暴露到 /sys/module/<模块名>/parameters/ 下,同时支持命令行传参。
static int buf_size = 1024;
static char *dev_name = "demo0";
static bool debug_en;
module_param(buf_size, int, 0644);
module_param(dev_name, charp, 0644);
module_param(debug_en, bool, 0644);
MODULE_PARM_DESC(buf_size, "buffer size in bytes");
MODULE_PARM_DESC(dev_name, "device node name");
加载时传参方式为 insmod demo.ko buf_size=4096 dev_name=mydev。权限位 0644 表示该参数在 sysfs 中可读可写,若设为 0 则不出现在 sysfs 中。需要特别注意的是,参数在模块初始化函数执行前就已被赋值,因此初始化函数中可以直接使用;但运行期通过 sysfs 修改参数不会自动触发任何回调,需要驱动自行设计通知机制。
三、符号导出:模块之间的依赖关系
当驱动被拆分为多个模块时,模块 A 需要调用模块 B 中的函数,就必须由 B 通过 EXPORT_SYMBOL() 或 EXPORT_SYMBOL_GPL() 导出符号。导出后,符号进入内核符号表,其他模块在加载时由内核完成重定位。
/* 模块 B:提供能力 */
int demo_register_client(const char *name)
{
pr_info("register client: %s\n", name);
return 0;
}
EXPORT_SYMBOL_GPL(demo_register_client);
/* 模块 A:使用能力 */
extern int demo_register_client(const char *name);
依赖关系会体现在模块的引用计数上:A 加载时 B 的引用计数加一,A 卸载后减一。因此只要 A 还在内核中,rmmod B 就会失败并提示 Module B is in use。使用 lsmod 可以查看 Used by 字段确认依赖链,使用 modinfo 可以查看模块的 depends 信息。
四、printk 与内核日志系统
内核没有 printf,取而代之的是 printk() 及其封装宏 pr_info()、pr_err()、dev_info() 等。printk 的第一个特征是日志级别,级别数值越小优先级越高,只有低于控制台级别的消息才会直接打印到终端。
| 级别宏 | 数值 | 典型用途 |
|---|---|---|
| KERN_EMERG | 0 | 系统不可用 |
| KERN_ALERT | 1 | 必须立即处理 |
| KERN_ERR | 3 | 驱动错误、硬件异常 |
| KERN_WARNING | 4 | 可疑但可恢复的情况 |
| KERN_INFO | 6 | 常规信息、加载卸载记录 |
| KERN_DEBUG | 7 | 调试细节 |
日志的完整链路是:printk 写入内核环形缓冲区(ring buffer),由 klogd/rsyslog 或 systemd-journald 读取并落盘到 /var/log/,同时 dmesg 直接读取环形缓冲区。环形缓冲区大小固定,旧日志会被覆盖,因此长时间运行后早期日志可能丢失。
dmesg -T | tail -20 # 带时间戳查看最近日志
dmesg -w # 实时跟踪
cat /proc/sys/kernel/printk # 查看当前控制台级别
echo 8 > /proc/sys/kernel/printk # 临时放开所有级别到控制台
journalctl -k -f # systemd 环境下跟踪内核日志
pr_info 会显著拖慢系统,甚至因日志量过大掩盖真正的错误。生产驱动应使用 pr_debug/dev_dbg,并通过 dynamic_debug 按需开启。五、模块生命周期与日志的对应关系
把上述四条主线串起来,可以看到一个模块从加载到卸载的完整状态流转,以及每一步在日志中留下的痕迹。下图展示了这一过程:参数在装载阶段注入,初始化函数执行并可能调用其他模块导出的符号,运行期通过 sysfs 调整参数,卸载时清理函数释放资源,全程由 printk 记录到环形缓冲区。
六、常见误区与复盘要点
- 忘记 MODULE_LICENSE:模块能加载但大量 GPL-only 符号报
Unknown symbol,或内核直接标记为污染(tainted)。 - init 返回非 0 却未清理:初始化中途失败时若已申请内存、注册设备,必须回滚,否则模块加载失败但资源泄漏。
- 把参数当运行期配置:sysfs 修改参数不会触发回调,需要
module_param_cb()自定义 set 函数才能感知变化。 - 导出符号命名冲突:全局符号名在内核中唯一,导出前应加模块前缀,避免与其他驱动重名导致加载失败。
- 依赖 printk 定位问题:日志级别被控制台级别过滤时看不到输出,应先确认
/proc/sys/kernel/printk的四个数值。
make -C /lib/modules/$(uname -r)/build M=$PWD modules 编译 → insmod 加载并传参 → lsmod 与 ls /sys/module/demo/parameters/ 确认状态 → dmesg -T | tail 核对日志 → rmmod 卸载并再次核对日志。这条闭环跑通后,再进入字符设备驱动的编写会顺畅很多。