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_EMERG0系统不可用
KERN_ALERT1必须立即处理
KERN_ERR3驱动错误、硬件异常
KERN_WARNING4可疑但可恢复的情况
KERN_INFO6常规信息、加载卸载记录
KERN_DEBUG7调试细节

日志的完整链路是: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 记录到环形缓冲区。

内核模块生命周期与日志链路 用户空间 insmod / modprobe 参数注入 buf_size=4096 rmmod 引用计数为 0 才可卸载 demo_init() __init 段,返回 0 成功 运行期 sysfs 参数 / 导出符号调用 demo_exit() __exit 段,释放资源 printk 环形缓冲区 固定大小,旧日志被覆盖 dmesg / journalctl -k 实时查看与落盘 /var/log/ 长期留存与检索 装载 传参 卸载 pr_info pr_debug pr_info

六、常见误区与复盘要点

  • 忘记 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 卸载并再次核对日志。这条闭环跑通后,再进入字符设备驱动的编写会顺畅很多。