3、内核模块基础:模块结构(init/exit函数)、module_init/module_exit宏、许可证与作者信息声明
Linux内核模块(Loadable Kernel Module,LKM)是Linux系统动态扩展内核能力的主要机制。与直接编译进内核的代码不同,模块可以在系统运行期间按需加载和卸载,从而在不重启系统的情况下增加设备驱动、文件系统、网络协议或安全钩子等功能。理解模块的基本结构是编写可靠驱动程序的基石。本章将深入剖析模块的入口与出口函数、加载/卸载宏的底层机制、许可证与作者信息的声明规范,并通过实际案例与常见误区分析,帮助读者建立扎实的模块编写基础。
上图展示了内核模块从源文件编译、加载到内核、运行以及最终卸载的完整生命周期。模块的入口函数(init)和出口函数(exit)是内核与模块交互的两个核心接口,而module_init和module_exit宏则负责将这些函数注册到内核的模块管理机制中。许可证声明不仅是一个法律文本,更直接影响模块可以调用的内核符号范围。下面我们将逐一深入探讨这些关键要素。
3.1 模块的入口与出口函数:init/exit的设计哲学
核心原理
每个内核模块都必须包含且仅包含一个入口函数(init function)和一个出口函数(exit function)。入口函数在模块被加载时执行,负责初始化模块所需的资源,例如注册字符设备、分配内存、建立proc条目、注册中断处理程序等。出口函数在模块被卸载时执行,负责释放入口函数分配的所有资源,确保内核状态恢复到模块加载之前。
从内核源码的角度看,入口函数和出口函数的原型定义如下:
static int __init hello_init(void)
{
/* 初始化代码 */
return 0; /* 返回0表示成功,返回负数表示失败 */
}
static void __exit hello_exit(void)
{
/* 清理代码 */
}
关键点在于:入口函数必须返回int类型,返回0表示初始化成功,模块可以继续运行;返回负的错误码(如-ENOMEM、-EIO)表示初始化失败,内核将中止模块加载过程并自动调用清理逻辑(如果部分资源已分配)。出口函数返回void,因为卸载过程不允许失败——如果清理不彻底,内核将处于不一致状态。
__init和__exit是内核提供的属性标记。带有__init的函数在模块加载完成后,其占用的内存会被释放(如果模块编译进内核,则在内核启动完成后释放)。__exit标记在模块编译进内核时会被忽略,因为内核本身不需要卸载。这些标记有助于优化内存占用。
判断方法或操作流程
编写正确的init/exit函数需要遵循以下操作流程:
- 资源分配顺序规划:在init函数中,按照依赖关系从底层到高层分配资源。例如,先注册设备号,再创建cdev,最后创建设备节点。
- 错误处理回滚:每一步资源分配后都要检查返回值,如果失败,必须回滚之前所有已成功的分配步骤。这是最常见的错误来源。
- exit函数逆序释放:exit函数必须严格按照init函数分配资源的逆序释放资源,避免悬空引用。
- 使用goto错误处理标签:内核社区惯例是使用goto语句实现统一的错误处理出口,而不是多层嵌套的if-else。
static int __init my_init(void)
{
int ret;
ret = register_chrdev_region(...);
if (ret < 0)
goto fail_region;
cdev_init(&my_cdev, &my_fops);
ret = cdev_add(&my_cdev, devno, 1);
if (ret < 0)
goto fail_cdev;
return 0;
fail_cdev:
unregister_chrdev_region(devno, 1);
fail_region:
return ret;
}
案例拆解
假设我们要编写一个简单的字符设备驱动模块,管理一个虚拟硬件设备。初始现象是:模块加载后,/dev/mydev节点不存在,应用程序无法访问。目标是通过模块初始化创建该设备节点。
关键输入参数:主设备号(动态分配)、次设备号(0)、设备名("mydev")。分析判断过程:我们需要在init中完成设备号注册、cdev初始化和添加、设备节点创建。实际操作步骤:
- 使用
alloc_chrdev_region动态获取主设备号。 - 初始化cdev结构体并关联file_operations。
- 调用
cdev_add将设备添加到内核。 - 使用
class_create和device_create在/dev下创建设备节点。
结果对比:加载模块后,/dev/mydev出现,应用程序可以正常open/read/write。风险与代价:如果第3步失败,必须回滚第1步;如果第4步失败,必须回滚第3步和第1步。复盘经验:设备节点的创建依赖udev或mdev,在嵌入式系统中可能需要手动mknod,因此init函数中应检查device_create的返回值。
常见误区
误区1:init函数中不检查每一步的返回值。这会导致部分资源分配失败后,模块仍然报告加载成功,但后续访问时崩溃。
误区2:exit函数中释放了未分配的资源。例如,在init失败路径中已经释放了内存,但exit函数再次释放,导致双重释放。
误区3:使用return;而不是return 0;。入口函数必须返回int,如果返回void,编译器会报错。
3.2 module_init/module_exit宏:背后的链接魔法
核心原理
module_init和module_exit宏的作用是将init和exit函数指针放置到内核模块的特定ELF段中。对于编译为模块(.ko文件)的情况,这些宏将函数指针放入.init.text和.exit.text段。内核在加载模块时,通过查找这些段中的指针来调用对应的函数。
宏定义(以Linux 6.x为例)如下:
#define module_init(x) __initcall(x);
#define module_exit(x) __exitcall(x);
进一步展开,__initcall会将函数指针放入特定的initcall段。对于模块,这个段是.init.text。内核的do_init_module函数会从该段取出函数指针并调用。对于编译进内核的代码,module_init会被转换为device_initcall,在系统启动时按优先级执行。
理解这一机制的关键在于:module_init宏不仅是一个函数注册,它还定义了模块的初始化优先级。内核定义了多个initcall级别(pure_initcall、core_initcall、postcore_initcall、arch_initcall、subsys_initcall、fs_initcall、device_initcall、late_initcall),模块默认使用device_initcall级别。
判断方法或操作流程
要确认模块的init/exit函数是否正确注册,可以使用以下方法:
- 查看模块的符号表:使用
nm mymodule.ko | grep init,可以看到init_module或自定义的init函数地址。 - 使用modinfo:
modinfo mymodule.ko可以显示模块的元信息,但不包含init/exit地址。 - 动态查看已加载模块:
cat /proc/modules显示模块的内存占用和状态,lsmod显示依赖关系。 - 使用systemtap或bpftrace跟踪
do_init_module调用。
操作流程:编写模块时,在源文件末尾调用module_init(hello_init)和module_exit(hello_exit)。注意,宏调用必须出现在函数定义之后,否则编译器无法找到函数符号。
案例拆解
考虑一个场景:模块A和模块B,B依赖A导出的符号。初始现象:加载B时提示Unknown symbol。目标:确保A先加载。分析判断:B的init函数引用了A中的函数,但A尚未加载。实际操作:使用insmod A.ko后再insmod B.ko,或者使用modprobe自动处理依赖。结果对比:B加载成功。风险:如果A被卸载而B仍在运行,B将崩溃。复盘经验:模块依赖关系需要在代码中通过EXPORT_SYMBOL声明,并在B中使用request_module或依赖modprobe的自动加载。
常见误区
经验提醒:不要在一个模块中定义多个module_init。虽然编译器不会报错,但只有最后一个会生效,且会造成资源泄漏。同样,不要省略module_exit,否则模块无法卸载,只能重启系统。
3.3 许可证与作者信息声明:法律与协作的基石
核心原理
Linux内核采用GPLv2许可证。内核模块作为衍生作品,其许可证声明直接影响模块与内核的交互方式。具体来说,内核导出符号分为两类:GPL符号和非GPL符号。如果模块声明为GPL兼容许可证(如"GPL"、"GPL v2"),则可以调用所有内核符号;如果声明为专有许可证(如"Proprietary"),则只能调用非GPL符号,且内核会打印警告信息。
常用的许可证宏包括:
MODULE_LICENSE("GPL"); /* GPL v2 */
MODULE_LICENSE("GPL v2");
MODULE_LICENSE("Dual MIT/GPL");
MODULE_LICENSE("Dual BSD/GPL");
MODULE_LICENSE("Proprietary"); /* 专有,不推荐 */
作者信息声明使用MODULE_AUTHOR宏,描述模块作者和联系方式。此外还有MODULE_DESCRIPTION(模块功能描述)、MODULE_VERSION(版本号)、MODULE_ALIAS(别名,用于自动加载)等。
判断方法或操作流程
如何判断一个模块的许可证是否合规?操作流程如下:
- 使用
modinfo module.ko查看license字段。 - 检查模块源码中的
MODULE_LICENSE宏。 - 如果模块使用了
EXPORT_SYMBOL_GPL导出的符号,则模块必须声明GPL许可证。 - 查看内核日志:
dmesg | tail,如果出现module license 'Proprietary' taints kernel,说明模块使用了专有许可证。
作者信息声明的最佳实践:
MODULE_AUTHOR("Alice Zhang <alice@example.com>");
MODULE_DESCRIPTION("Sample character device driver");
MODULE_VERSION("1.0");
MODULE_ALIAS("char-major-240-0");
案例拆解
某公司开发了一个硬件加速器驱动,初始现象:驱动加载后,内核日志出现taint警告,且某些内核功能(如kprobes)不可用。目标:消除taint状态。分析判断:驱动使用了EXPORT_SYMBOL_GPL导出的函数,但声明为"Proprietary"。实际操作:将许可证改为"GPL",并确保代码不包含专有部分。结果对比:taint警告消失,kprobes可用。风险:公司需要开源驱动代码。复盘经验:如果驱动包含专有算法,应将其拆分为用户态库或使用Dual BSD/GPL许可证。
常见误区
风险提示:不要随意使用"GPL"声明,除非你确实以GPL许可证发布代码。虚假声明可能导致法律纠纷。另外,MODULE_LICENSE必须放在源文件的全局作用域,不能放在函数内部。
3.4 综合实战:一个完整的最小模块
下面是一个完整的最小模块示例,整合了本章所有知识点:
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
static int __init hello_init(void)
{
printk(KERN_INFO "Hello, kernel module loaded!\n");
return 0;
}
static void __exit hello_exit(void)
{
printk(KERN_INFO "Goodbye, kernel module unloaded!\n");
}
module_init(hello_init);
module_exit(hello_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Bob <bob@example.com>");
MODULE_DESCRIPTION("A simple hello world module");
MODULE_VERSION("0.1");
编译该模块的Makefile:
obj-m := hello.o
KDIR := /lib/modules/$(shell uname -r)/build
PWD := $(shell pwd)
all:
$(MAKE) -C $(KDIR) M=$(PWD) modules
clean:
$(MAKE) -C $(KDIR) M=$(PWD) clean
加载和卸载命令:
sudo insmod hello.ko
sudo rmmod hello
dmesg | tail
3.5 知识结构总结
| 组件 | 作用 | 关键要求 | 常见错误 |
|---|---|---|---|
| init函数 | 初始化资源 | 返回int,0成功,负值失败 | 忘记检查返回值 |
| exit函数 | 释放资源 | 返回void,逆序释放 | 双重释放 |
| module_init宏 | 注册入口 | 只能调用一次 | 重复定义 |
| module_exit宏 | 注册出口 | 不能省略 | 模块无法卸载 |
| MODULE_LICENSE | 声明许可证 | 影响符号访问 | 专有声明导致taint |
| MODULE_AUTHOR | 作者信息 | 便于维护 | 缺失导致维护困难 |
总结复盘:编写内核模块时,应始终遵循"先规划后编码,先分配后检查,先注册后释放"的原则。init函数中的每一步都要有对应的错误处理路径,exit函数必须与init严格对称。许可证声明不是可有可无的元数据,它决定了模块与内核的交互边界。作者信息虽然不影响功能,但良好的文档习惯是专业驱动开发者的标志。