2. 内核模块基础
Linux 内核模块(Loadable Kernel Module,LKM)是内核在运行时动态扩展功能的核心机制。它让驱动开发者无需重新编译整个内核,就能把设备驱动、文件系统或网络协议以 .ko 文件的形式插入内核或从内核移除。理解模块的加载卸载、参数传递、符号导出、依赖关系和日志输出,是编写可维护驱动的第一步。
一、模块的加载与卸载:生命周期入口
每个模块都必须提供两个入口函数:加载时调用的初始化函数和卸载时调用的清理函数。它们通过 module_init() 与 module_exit() 宏注册,内核在 insmod 或 rmmod 时自动找到对应函数。
#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-course");
MODULE_DESCRIPTION("module lifecycle demo");
加载流程可以概括为:用户执行 insmod demo.ko,内核读取 ELF 段、校验版本与依赖、解析符号,然后调用 demo_init。若返回非 0,内核会回滚已分配资源并报错。卸载时执行 rmmod demo,内核先检查引用计数,只有计数为 0 才调用 demo_exit。若模块正被使用,卸载会失败并提示 Module demo is in use。
__init 修饰,清理函数使用 __exit。前者在加载完成后释放内存,后者在模块被编译进内核时被丢弃,可减小镜像体积。二、模块参数:让驱动在加载时可配置
模块参数允许在加载时向模块传递配置值,避免为每个硬件变体重新编译。常用宏有 module_param()、module_param_named() 和 module_param_array(),配合 MODULE_PARM_DESC() 描述用途。
static int debug_level = 1;
static char *dev_name = "demo0";
static int ids[4];
static int id_count;
module_param(debug_level, int, 0644);
MODULE_PARM_DESC(debug_level, "debug verbosity 0-3");
module_param(dev_name, charp, 0644);
MODULE_PARM_DESC(dev_name, "device name string");
module_param_array(ids, int, &id_count, 0644);
MODULE_PARM_DESC(ids, "device id array");
加载时通过 insmod demo.ko debug_level=3 dev_name=eth0 ids=1,2,3 传入。权限位 0644 决定参数是否在 /sys/module/demo/parameters/ 下可读写。若参数非法,内核会拒绝加载并打印错误,因此参数校验应放在初始化函数中完成。
三、符号导出与模块依赖
模块之间需要共享函数或变量时,导出方使用 EXPORT_SYMBOL() 或 EXPORT_SYMBOL_GPL() 把符号加入内核符号表,使用方通过 extern 声明后直接调用。GPL 版本只允许 GPL 兼容模块使用,是内核社区推荐的授权方式。
/* provider.c */
int demo_add(int a, int b) { return a + b; }
EXPORT_SYMBOL_GPL(demo_add);
/* consumer.c */
extern int demo_add(int a, int b);
int use_it(void) { return demo_add(1, 2); }
依赖关系由内核在加载时自动解析。若 consumer 先加载而 provider 未加载,会出现 Unknown symbol demo_add。使用 modprobe 而非 insmod 可以自动处理依赖顺序,因为 modprobe 会读取 modules.dep 并递归加载所需模块。查看依赖可用 lsmod 的 Used by 列或 modinfo 的 depends 字段。
四、版本控制与 printk 日志级别
内核通过 vermagic 字符串校验模块与当前内核是否匹配,内容包括内核版本、SMP 与抢占配置、模块版本魔数等。若不一致,加载会报 version magic ... should be ...。开发阶段可临时使用 modprobe --force,但生产环境必须重新编译模块。
printk() 是内核打印的核心接口,格式为 printk(KERN_ERR "msg %d\n", val)。日志级别从 0 到 7,数值越小越紧急。常用封装 pr_info()、pr_err()、dev_err() 更简洁且能携带设备上下文。
| 级别 | 宏 | 含义 |
|---|---|---|
| 0 | KERN_EMERG | 系统不可用 |
| 1 | KERN_ALERT | 必须立即处理 |
| 2 | KERN_CRIT | 严重错误 |
| 3 | KERN_ERR | 错误条件 |
| 4 | KERN_WARNING | 警告 |
| 5 | KERN_NOTICE | 正常但重要 |
| 6 | KERN_INFO | 提示信息 |
| 7 | KERN_DEBUG | 调试信息 |
控制台只显示高于 console_loglevel 的消息,可通过 dmesg -n 8 或 /proc/sys/kernel/printk 调整。查看日志使用 dmesg 或 journalctl -k。驱动中应避免在中断上下文使用会睡眠的打印方式,并控制高频日志,防止日志风暴拖慢系统。
五、典型场景与常见误区
假设要开发一个虚拟字符设备驱动 vdev.ko,它依赖公共工具模块 vlib.ko 提供的 vlib_register()。正确做法是:在 vlib 中用 EXPORT_SYMBOL_GPL(vlib_register) 导出,在 vdev 中声明并调用;加载时先 modprobe vlib 再 modprobe vdev,或直接 modprobe vdev 让依赖自动解析。若把参数 buf_size 定义为 int 却传入字符串,加载会失败,应在 vdev_init 中检查范围并返回 -EINVAL。
MODULE_LICENSE("GPL"),导致内核标记为污染(tainted),部分 GPL-only 符号无法使用。误区二:在 __exit 中释放未初始化的资源,或在 __init 失败路径上遗漏清理,造成内存泄漏。误区三:把 printk 当作 printf 高频调用,导致日志淹没真实错误。误区四:直接修改内核源码树中的模块而不重新生成 modules.dep,使 modprobe 找不到依赖。复盘时建议按以下清单检查模块:入口返回值是否正确;参数是否校验;导出符号是否使用 GPL 版本;依赖是否写入 Makefile 并执行 depmod -a;日志级别是否合理;卸载路径是否与加载路径对称。掌握这些基础后,后续的设备模型、字符设备与平台驱动才能建立在稳固的模块机制之上。