3. Linux内核模块基础

在Linux驱动开发中,内核模块(Loadable Kernel Module,LKM)是最基本的交付单元。它允许我们在不重启系统、不重新编译整个内核的前提下,把驱动代码动态插入内核空间运行,也能在不需要时安全卸载。理解模块的加载卸载机制、参数传递、符号导出、日志输出以及依赖与版本管理,是后续所有硬件联调工作的地基。本节围绕这五个核心点展开,帮助你把“写一个能跑起来的模块”升级为“写一个可维护、可调试、可复用的模块”。

一、模块的加载与卸载:从insmod到rmmod

内核模块本质是一个可重定位的ELF目标文件,扩展名为.ko。它不能像用户态程序那样直接执行,而是由内核的模块加载器把它映射进内核地址空间,完成符号解析和重定位后,调用模块的初始化函数。加载与卸载的入口由两个宏声明:

#include <linux/module.h>
#include <linux/init.h>

static int __init mydrv_init(void)
{
    pr_info("mydrv: init\n");
    return 0;          /* 返回0表示加载成功 */
}

static void __exit mydrv_exit(void)
{
    pr_info("mydrv: exit\n");
}

module_init(mydrv_init);
module_exit(mydrv_exit);

MODULE_LICENSE("GPL");
MODULE_AUTHOR("engineer");
MODULE_DESCRIPTION("demo driver module");

加载时使用insmod mydrv.ko,卸载时使用rmmod mydrv。更推荐用modprobe,因为它会自动处理依赖关系并从/lib/modules/$(uname -r)/下查找模块。加载成功后,模块会出现在/proc/modules和lsmod的输出中。初始化函数返回非0值会导致加载失败,此时内核会回滚已分配的资源,因此init函数中出错必须释放已申请资源再返回。

模块代码运行在内核态,没有用户态的内存保护。一次空指针解引用或越界写,轻则oops,重则整机panic。调试模块时建议在虚拟机或带串口日志的开发板上进行。

二、模块参数:让驱动行为可配置

硬编码的驱动难以适配不同硬件。模块参数允许在加载时通过命令行传入配置,使用module_param系列宏声明:

static int debug_level = 0;
static char *dev_name = "mydrv0";
static int buf_size = 4096;

module_param(debug_level, int, 0644);
module_param(dev_name, charp, 0644);
module_param(buf_size, int, 0444);

MODULE_PARM_DESC(debug_level, "debug verbosity 0-3");
MODULE_PARM_DESC(buf_size, "DMA buffer size in bytes");

加载时即可传参:insmod mydrv.ko debug_level=2 buf_size=8192。参数类型支持int、uint、long、charp、bool以及数组形式module_param_array。权限位0644表示该参数会在/sys/module/mydrv/parameters/下生成可读写节点,运行时也能查看甚至修改(若为可写)。这是驱动调试时非常实用的“运行时旋钮”。

三、符号导出:模块之间的接口

当多个模块需要协作时,一个模块可以把函数或变量导出给其他模块使用,使用EXPORT_SYMBOL或EXPORT_SYMBOL_GPL:

int mydrv_register_client(struct client_ops *ops)
{
    /* ... */
    return 0;
}
EXPORT_SYMBOL_GPL(mydrv_register_client);

导出后,其他模块在加载时就能解析到这个符号。EXPORT_SYMBOL_GPL只允许GPL兼容许可证的模块使用,这是内核维护者鼓励开源的一种机制。若模块未声明MODULE_LICENSE("GPL"),加载依赖GPL符号的模块会失败,并提示Unknown symbol或disagrees about version of symbol。因此,导出符号时要谨慎:它一旦发布,就构成模块间的稳定接口,随意修改签名会破坏依赖方。

四、printk日志:内核里的printf

内核不能用printf,取而代之的是printk及其封装宏pr_info、pr_err、dev_dbg等。日志带有级别,级别越低越紧急:

级别宏数值典型用途
KERN_EMERG0系统不可用
KERN_ALERT1必须立即处理
KERN_ERR3驱动错误
KERN_WARNING4警告信息
KERN_INFO6常规提示
KERN_DEBUG7调试细节

日志输出到内核环形缓冲区,可用dmesg查看,也会被rsyslog写入/var/log/kern.log。控制台输出级别由/proc/sys/kernel/printk控制,格式为“当前、默认、最小、最大”。调试时常用echo 8 > /proc/sys/kernel/printk临时打开全部级别。注意printk在中断上下文和原子上下文中也可安全调用,但高频打印会拖慢系统,生产驱动应使用pr_debug配合dynamic_debug按需开启。

五、模块依赖与版本管理

模块之间可以互相依赖。例如usb_storage依赖scsi_mod。依赖关系记录在/lib/modules/$(uname -r)/modules.dep中,由depmod生成。modprobe读取该文件自动先加载被依赖模块。若手动insmod一个依赖未满足的模块,会报Unknown symbol in module。

版本管理方面,内核通过vermagic字符串校验模块与内核是否匹配,内容包括内核版本、SMP、抢占、模块版本等。使用modinfo mydrv.ko可查看。若内核开启了CONFIG_MODVERSIONS,还会对导出符号做CRC校验,符号签名不一致时加载失败。跨内核版本移植模块时,必须用目标内核的头文件重新编译,不能直接拷贝.ko。

常见误区:把rmmod当成“停止驱动”。实际上若模块的引用计数不为0(例如设备节点仍被打开),rmmod会失败并提示Module is in use。此时应先关闭使用者,或检查是否有其他模块依赖它。

六、综合案例:一个可配置的调试模块

下面把上述知识点串起来,形成一个最小可用的调试模块,它导出符号、接收参数、打印日志:

static int dbg_level = 1;
module_param(dbg_level, int, 0644);

int dbg_emit(const char *tag, int val)
{
    if (dbg_level > 0)
        pr_info("dbg: %s=%d\n", tag, val);
    return 0;
}
EXPORT_SYMBOL_GPL(dbg_emit);

static int __init dbg_init(void)
{
    pr_info("dbg: loaded, level=%d\n", dbg_level);
    return 0;
}
static void __exit dbg_exit(void)
{
    pr_info("dbg: unloaded\n");
}
module_init(dbg_init);
module_exit(dbg_exit);
MODULE_LICENSE("GPL");

加载后,另一个模块即可调用dbg_emit;通过echo 0 > /sys/module/dbg/parameters/dbg_level可动态关闭日志。整个流程覆盖了模块生命周期、参数、符号、日志与依赖管理,是驱动联调中最常用的骨架。

Linux内核模块生命周期与关键机制 用户空间 insmod / modprobe rmmod dmesg / sysfs 内核空间 模块加载器 符号解析 / 重定位 mydrv.ko init / exit / 参数 / 导出符号 模块参数 module_param /sys/module/.../parameters 符号导出 EXPORT_SYMBOL_GPL 供其他模块调用 printk 日志 pr_info / pr_err 环形缓冲区 依赖与版本管理 modules.dep / vermagic modinfo / depmod dmesg 输出 · lsmod 列表 · /proc/modules · sysfs 参数节点