3. 内核模块基础:模块的加载与卸载、module_init/module_exit、LICENSE声明、内核打印printk
内核模块是Linux内核在运行时动态扩展功能的机制。在系统调用表Hook技术中,所有拦截逻辑都必须以内核模块的形式加载到内核空间。理解模块的加载与卸载生命周期、入口/出口函数声明、许可证合规性以及内核调试输出,是编写稳定、可维护的Hook模块的前提。
3.1 内核模块的生命周期
一个内核模块从编译完成到从内核中移除,经历以下四个阶段:
- 加载(Loading):通过
insmod或modprobe将 .ko 文件插入内核地址空间。 - 初始化(Initialization):内核自动调用模块中声明的
module_init()函数,执行资源分配、注册、Hook安装等操作。 - 运行(Running):模块驻留内核,提供功能(如拦截系统调用、注册文件系统、驱动设备)。
- 卸载(Unloading):通过
rmmod触发module_exit()函数,执行资源释放、Hook恢复等清理工作。
关键原则: 模块的初始化与卸载函数必须成对出现。如果在 init 中申请了资源(内存、注册回调、修改系统调用表),则 exit 中必须逐一释放和恢复,否则会导致内核内存泄漏或系统崩溃。
3.2 module_init 与 module_exit 宏
Linux内核通过两个宏来标记模块的入口和出口函数。这两个宏展开后会生成特定的 .init.text 和 .exit.text 段,内核加载器根据这些段来定位调用地址。
| 宏 | 作用 | 函数原型 | 返回值 |
|---|---|---|---|
module_init(x) |
声明模块加载时调用的初始化函数 | int __init init_function(void) |
0 表示成功,负数表示失败(模块加载被中止) |
module_exit(x) |
声明模块卸载时调用的清理函数 | void __exit exit_function(void) |
无返回值 |
以下是一个标准的内核模块骨架代码:
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/init.h>
static int __init my_hook_init(void)
{
printk(KERN_INFO "my_hook: module loaded, preparing syscall table...\n");
/* 在此处获取系统调用表地址、保存原始调用、写入Hook */
return 0; /* 返回0表示加载成功 */
}
static void __exit my_hook_exit(void)
{
printk(KERN_INFO "my_hook: module unloaded, syscall table restored.\n");
/* 在此处恢复原始系统调用、释放资源 */
}
module_init(my_hook_init);
module_exit(my_hook_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Kernel Hacker");
MODULE_DESCRIPTION("A simple syscall table hook example");
注意:
__init 和 __exit 标记不仅用于语义提示,还会告诉内核在初始化完成后释放该函数占用的内存(如果模块被编译进内核而非动态加载)。对于动态加载的模块,__exit 标记的函数在模块加载后会被丢弃,因此卸载时无法调用——但动态模块的卸载路径由内核模块加载器特殊处理,实际仍可正常调用。
3.3 LICENCE 声明:GPL 与内核符号导出
每个内核模块必须包含 MODULE_LICENSE() 声明。这不仅是一个法律声明,还直接影响模块能否访问内核导出的符号(函数和变量)。
| 许可证字符串 | 含义 | 对符号访问的影响 |
|---|---|---|
"GPL" |
GNU General Public License v2 或更高版本 | 可访问所有 EXPORT_SYMBOL_GPL() 导出的符号 |
"GPL v2" |
仅 GPL v2 | 同上 |
"Dual BSD/GPL" |
BSD 或 GPL 双许可 | 同上 |
"Proprietary" |
专有许可证 | 只能访问 EXPORT_SYMBOL() 导出的符号,无法使用 EXPORT_SYMBOL_GPL() 符号 |
对Hook技术的影响: 系统调用表地址通常通过
kallsyms_lookup_name 或直接内存搜索获取,这些函数本身是 EXPORT_SYMBOL_GPL 导出的。如果模块声明为非GPL许可证,将无法调用 kallsyms_lookup_name,必须使用其他方法(如 /proc/kallsyms 用户态解析)来定位系统调用表。因此,绝大多数公开的Hook示例模块都声明为 "GPL"。
3.4 内核打印 printk:调试与日志
printk 是内核空间的“printf”,用于向内核日志缓冲区输出消息。通过 dmesg 命令可以查看这些输出。在开发Hook模块时,printk 是最直接的调试手段。
printk 的日志级别通过宏指定,每个级别对应一个数字,数字越小优先级越高:
| 宏 | 数字 | 含义 | 典型使用场景 |
|---|---|---|---|
KERN_EMERG |
0 | 紧急事件,系统不可用 | 内核崩溃前 |
KERN_ALERT |
1 | 必须立即处理 | 严重硬件故障 |
KERN_CRIT |
2 | 临界条件 | 严重软件错误 |
KERN_ERR |
3 | 错误条件 | Hook安装失败、内存分配失败 |
KERN_WARNING |
4 | 警告 | 不推荐的操作、兼容性提示 |
KERN_NOTICE |
5 | 正常但重要的消息 | 模块加载/卸载通知 |
KERN_INFO |
6 | 信息性消息 | Hook触发记录、调试信息 |
KERN_DEBUG |
7 | 调试消息 | 详细跟踪、变量值输出 |
使用示例:
printk(KERN_INFO "my_hook: syscall nr %ld called by pid %d\n", nr, current->pid);
printk(KERN_ERR "my_hook: failed to allocate memory for hook structure\n");
printk(KERN_DEBUG "my_hook: original syscall address = %px\n", (void *)orig_syscall);
调试技巧: 在开发阶段,建议使用
KERN_INFO 或 KERN_DEBUG 级别输出关键变量的值。使用 %px 打印指针地址(而非 %p,后者可能被内核混淆)。通过 dmesg -w 实时监控日志,或使用 tail -f /var/log/kern.log 查看持久化日志。
3.5 模块加载与卸载的完整流程(SVG架构图)
下图展示了从用户态执行 insmod 到模块初始化完成,再到 rmmod 触发清理的完整流程:
3.6 模块编译与加载实践
编写一个最简单的内核模块并测试其加载与卸载:
- 创建 Makefile:利用内核构建系统自动编译。
- 编译:执行
make生成.ko文件。 - 加载:
sudo insmod my_hook.ko,检查dmesg | tail查看 init 输出。 - 查看已加载模块:
lsmod | grep my_hook确认模块存在。 - 卸载:
sudo rmmod my_hook,再次检查dmesg查看 exit 输出。
# Makefile 示例
obj-m += my_hook.o
all:
make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules
clean:
make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean
常见问题排查: 如果
insmod 失败返回 Operation not permitted,检查是否以 root 权限执行。如果返回 Unknown symbol,说明模块引用了未导出的内核符号,需要检查 MODULE_LICENSE 声明或使用 kallsyms_lookup_name 动态查找。
3.7 本章小结
- 内核模块通过
module_init和module_exit宏定义生命周期函数,init 返回 0 表示成功。 MODULE_LICENSE("GPL")是访问EXPORT_SYMBOL_GPL符号的必要条件,对Hook技术至关重要。printk配合不同日志级别(KERN_INFO / KERN_ERR / KERN_DEBUG)是内核模块开发的核心调试手段。- 模块的加载与卸载必须对称:init 中获取的资源,exit 中必须释放;init 中修改的内核状态,exit 中必须恢复。