4. 内核模块编程

Linux 内核模块是驱动开发的最小可加载单元。它让驱动可以像“插件”一样在系统运行时动态插入或移除,而不必重新编译整个内核。掌握模块的入口/出口、参数传递、符号导出、版本依赖以及加载卸载机制,是驱动岗位从“会写代码”走向“能交付稳定驱动”的关键一步。

一、核心原理:模块的生命周期与入口出口

每个内核模块都必须声明两个关键函数:初始化函数和清理函数。它们通过 module_init() 和 module_exit() 宏注册到内核,内核在加载和卸载模块时分别调用。

#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("driver-team");
MODULE_DESCRIPTION("demo driver module");

其中 __init 和 __exit 是内核的段属性标记:初始化函数在加载完成后其内存会被释放,清理函数只在模块被编译进内核或卸载时才需要。若初始化返回非 0,内核会认为加载失败并自动回滚,因此初始化路径中的资源申请必须严格按“申请—失败—释放”的顺序处理。

模块加载成功的关键:init 返回 0;卸载成功的关键:exit 中释放所有在 init 中申请的资源,包括设备号、cdev、class、内存、中断等。

二、参数传递:让模块在加载时可配置

驱动常常需要根据硬件或场景调整行为,例如指定主设备号、缓冲区大小、调试开关。内核模块通过 module_param() 系列宏把变量暴露为加载参数。

static int major = 0;
static int buf_size = 1024;
static char *dev_name = "mydrv";
static bool debug = false;

module_param(major, int, 0644);
module_param(buf_size, int, 0644);
module_param(dev_name, charp, 0644);
module_param(debug, bool, 0644);

MODULE_PARM_DESC(major, "major device number, 0 = auto");
MODULE_PARM_DESC(buf_size, "buffer size in bytes");
MODULE_PARM_DESC(dev_name, "device name string");
MODULE_PARM_DESC(debug, "enable debug log");

加载时通过 insmod mydrv.ko major=240 buf_size=2048 debug=1 传入参数。权限位 0644 决定该参数是否在 /sys/module/mydrv/parameters/ 下可读写。若参数在模块运行中可修改,应使用 module_param_cb() 注册回调,在回调中做合法性校验,避免运行期非法值破坏驱动状态。

三、符号导出:模块之间的依赖与复用

当多个模块需要共享函数或变量时,导出方使用 EXPORT_SYMBOL() 或 EXPORT_SYMBOL_GPL(),使用方通过 extern 声明后直接调用。GPL 版本只允许 GPL 兼容模块使用,这是内核维护者控制许可证边界的重要手段。

/* 导出方 */
int mydrv_register_client(struct client *c)
{
    ...
}
EXPORT_SYMBOL_GPL(mydrv_register_client);

/* 使用方 */
extern int mydrv_register_client(struct client *c);

导出符号会进入内核符号表,可通过 cat /proc/kallsyms | grep mydrv 查看。若使用方加载时提示 Unknown symbol,通常说明导出方未加载、符号名拼写错误或未导出。

四、版本依赖与加载卸载流程

内核模块与内核版本强绑定。模块编译时会记录 vermagic,包含内核版本、SMP、抢占、模块版本等标志。若模块与当前内核不匹配,加载会报 version magic 错误。此外,内核可开启 CONFIG_MODVERSIONS,对导出符号做 CRC 校验,符号原型变化会导致加载失败。

环节关键命令/文件常见问题
编译Makefile + 内核源码树内核头文件路径错误
加载insmod / modprobeUnknown symbol、version magic
查看lsmod、/proc/modules依赖关系不清晰
卸载rmmod / modprobe -r模块正在使用、引用计数非 0
信息modinfo mydrv.ko参数、许可证、vermagic 缺失

加载与卸载的典型流程如下:insmod 直接把 .ko 插入内核,不处理依赖;modprobe 会读取 /lib/modules/$(uname -r)/modules.dep,自动加载依赖模块。卸载时若引用计数不为 0,rmmod 会失败,需先卸载使用者。

内核模块加载/卸载与依赖关系 用户空间 insmod / modprobe 内核模块加载器 解析 vermagic / 符号 内核符号表 /proc/kallsyms 模块 A(导出方) EXPORT_SYMBOL_GPL mydrv_register_client 模块 B(使用方) extern 声明 依赖模块 A 符号引用 卸载顺序 先 B 后 A 引用计数归零 加载:insmod → 校验 vermagic → 解析符号 → 调用 module_init → 返回 0 成功 卸载:rmmod → 引用计数为 0 → 调用 module_exit → 释放资源 → 从内核移除

五、案例与场景:一个可配置的字符设备模块

下面是一个综合示例:模块支持主设备号、设备名、调试开关三个参数,导出注册函数供其他模块使用,并在 exit 中完整释放资源。

static int major = 0;
static char *dev_name = "mydrv";
static bool debug = false;

module_param(major, int, 0644);
module_param(dev_name, charp, 0644);
module_param(debug, bool, 0644);

static dev_t devno;
static struct cdev my_cdev;
static struct class *my_class;

static int __init mydrv_init(void)
{
    int ret;
    ret = alloc_chrdev_region(&devno, 0, 1, dev_name);
    if (ret) return ret;
    cdev_init(&my_cdev, &my_fops);
    ret = cdev_add(&my_cdev, devno, 1);
    if (ret) { unregister_chrdev_region(devno, 1); return ret; }
    my_class = class_create(THIS_MODULE, dev_name);
    if (IS_ERR(my_class)) { cdev_del(&my_cdev); unregister_chrdev_region(devno, 1); return PTR_ERR(my_class); }
    device_create(my_class, NULL, devno, NULL, dev_name);
    pr_info("mydrv: loaded, major=%d\n", MAJOR(devno));
    return 0;
}

static void __exit mydrv_exit(void)
{
    device_destroy(my_class, devno);
    class_destroy(my_class);
    cdev_del(&my_cdev);
    unregister_chrdev_region(devno, 1);
    pr_info("mydrv: unloaded\n");
}

加载后可用 dmesg | tail 查看日志,用 lsmod | grep mydrv 确认模块存在,用 cat /sys/module/mydrv/parameters/debug 查看参数。卸载时若设备节点仍被打开,rmmod 会失败,这正是引用计数保护机制的体现。

六、误区与复盘

  • 误区一:init 失败不释放已申请资源。内核不会自动回收你申请的设备号、cdev、class,必须手动回滚。
  • 误区二:exit 中遗漏 class_destroy 或 device_destroy。会导致 /sys 和 /dev 下残留节点,影响下次加载。
  • 误区三:参数权限设置过大。0644 允许 root 写,若参数在运行期被修改却无校验,可能引发越界或空指针。
  • 误区四:忽略 vermagic 与 MODVERSIONS。开发机与目标板内核不一致时,模块无法加载,应使用目标板内核源码树编译。
  • 误区五:导出符号未加 GPL 限制。若使用 EXPORT_SYMBOL 而非 _GPL,可能让非 GPL 模块使用,带来许可证风险。
调试建议:加载失败先看 dmesg 最后几行;符号问题用 modinfo 和 /proc/kallsyms 交叉验证;卸载失败用 lsmod 查看引用计数,并检查是否有进程占用设备节点。

模块编程的核心可以概括为:入口申请、出口释放、参数可配、符号可控、版本匹配、加载有序。把这六点落实到每一行代码和每一次 insmod/rmmod 操作中,驱动才能在不同内核和不同硬件上稳定交付。