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,内核会认为加载失败并自动回滚,因此初始化路径中的资源申请必须严格按“申请—失败—释放”的顺序处理。
二、参数传递:让模块在加载时可配置
驱动常常需要根据硬件或场景调整行为,例如指定主设备号、缓冲区大小、调试开关。内核模块通过 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 / modprobe | Unknown 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 会失败,需先卸载使用者。
五、案例与场景:一个可配置的字符设备模块
下面是一个综合示例:模块支持主设备号、设备名、调试开关三个参数,导出注册函数供其他模块使用,并在 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 操作中,驱动才能在不同内核和不同硬件上稳定交付。