2. 内核模块基础:Linux内核模块的Hello World、模块加载与卸载、Makefile编写
在深入Netfilter钩子编程之前,必须掌握Linux内核模块(Loadable Kernel Module, LKM)的基本开发流程。内核模块是运行在内核空间的动态可加载代码片段,Netfilter钩子函数正是以内核模块的形式注册到网络协议栈中的。本章通过一个经典的“Hello World”模块,系统讲解模块的编写、编译、加载与卸载全流程。
2.1 内核模块与用户态程序的本质区别
内核模块运行在内核空间,拥有最高特权级(Ring 0),可以直接访问硬件和内核数据结构。与用户态程序相比,存在以下关键差异:
| 对比维度 | 用户态程序 | 内核模块 |
|---|---|---|
| 运行空间 | 用户空间(Ring 3) | 内核空间(Ring 0) |
| 入口函数 | main() |
module_init() / module_exit() |
| 标准库支持 | 完整glibc | 无,仅使用内核API |
| 内存管理 | 虚拟地址空间,缺页异常 | 直接物理内存,需谨慎 |
| 错误处理 | 可崩溃,进程隔离 | 崩溃即内核panic |
| 调试方式 | GDB断点 | printk / ftrace / kprobe |
关键认知:内核模块的一个bug可能导致整个系统崩溃。在开发Netfilter钩子时,必须时刻注意指针安全、锁竞争和内存泄漏问题。
2.2 Hello World内核模块源码解析
一个最小化的内核模块包含两个核心函数:初始化函数(模块加载时调用)和清理函数(模块卸载时调用)。下面是一个完整的示例:
/* hello.c - 最小化内核模块示例 */
#include <linux/module.h> /* 所有模块都需要 */
#include <linux/kernel.h> /* printk() 宏 */
#include <linux/init.h> /* __init / __exit 宏 */
static int __init hello_init(void)
{
printk(KERN_INFO "Hello, Netfilter World! Module loaded.\n");
printk(KERN_DEBUG "Current PID: %d\n", current->pid);
return 0; /* 返回0表示加载成功 */
}
static void __exit hello_exit(void)
{
printk(KERN_INFO "Goodbye! Module unloaded.\n");
}
module_init(hello_init);
module_exit(hello_exit);
MODULE_LICENSE("GPL");
MODULE_AUTHOR("Netfilter Course");
MODULE_DESCRIPTION("A simple Hello World kernel module for Netfilter hook practice");
MODULE_VERSION("1.0");
代码要点:
__init和__exit宏将函数放入特殊内存段,模块加载/卸载后可释放,节省内存。printk()是内核版的printf,通过KERN_INFO等宏指定日志级别。MODULE_LICENSE("GPL")是必须的,否则内核会标记模块为“污染”(tainted)。current宏指向当前进程的task_struct,在内核模块中可直接访问。
2.3 模块加载与卸载的生命周期
内核模块从编译完成到最终卸载,经历以下状态转换:
实际操作中,使用以下命令管理模块:
| 命令 | 功能 | 内核对应函数 |
|---|---|---|
insmod hello.ko |
加载模块,调用 module_init |
sys_init_module() |
rmmod hello |
卸载模块,调用 module_exit |
sys_delete_module() |
modprobe hello |
智能加载(自动处理依赖) | 同上,但解析依赖 |
lsmod |
列出当前已加载模块 | 读取 /proc/modules |
modinfo hello.ko |
查看模块元信息 | 解析 .ko 文件 |
常见陷阱:
- 使用
rmmod时不要带.ko后缀,只写模块名。 - 如果模块被其他模块依赖,或内核正在使用模块导出的符号,卸载会失败。
- Netfilter钩子注册后,必须确保在卸载前注销钩子,否则内核会访问已释放的内存。
2.4 Makefile编写:内核模块的编译系统
内核模块不能使用普通的gcc编译,必须借助内核的Kbuild构建系统。Makefile的核心是调用内核源码树的Makefile来编译外部模块。
# Makefile - 编译Hello World内核模块
# 目标模块名
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
# 安装模块到标准位置(可选)
install:
$(MAKE) -C $(KDIR) M=$(PWD) modules_install
Makefile关键点解析:
obj-m := hello.o:告诉Kbuild将hello.c编译成内核模块hello.ko。KDIR:指向当前运行内核的构建树,包含头文件和编译规则。-C $(KDIR):切换到内核源码目录执行make。M=$(PWD):指定外部模块源码路径。- 如果模块由多个源文件组成,使用
模块名-objs := file1.o file2.o语法。
2.5 多文件模块与模块参数
实际Netfilter钩子模块通常包含多个源文件。下面展示多文件模块的Makefile写法,以及如何向模块传递参数:
/* params.c - 演示模块参数传递 */
#include <linux/module.h>
#include <linux/moduleparam.h>
#include <linux/kernel.h>
/* 模块参数:可在insmod时指定 */
static int hook_priority = 0;
static char *iface_name = "eth0";
module_param(hook_priority, int, 0644);
module_param(iface_name, charp, 0644);
MODULE_PARM_DESC(hook_priority, "Netfilter hook priority (default: 0)");
MODULE_PARM_DESC(iface_name, "Network interface to monitor (default: eth0)");
static int __init params_init(void)
{
printk(KERN_INFO "Module loaded with priority=%d, iface=%s\n",
hook_priority, iface_name);
return 0;
}
static void __exit params_exit(void)
{
printk(KERN_INFO "Module unloaded.\n");
}
module_init(params_init);
module_exit(params_exit);
MODULE_LICENSE("GPL");
# Makefile - 多文件模块示例
obj-m := netfilter_demo.o
netfilter_demo-objs := main.o hook.o utils.o params.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 netfilter_demo.ko hook_priority=10 iface_name="wlan0"
调试技巧:使用
dmesg | tail 查看 printk 输出。如果日志级别高于控制台日志级别(通常为KERN_WARNING),需要运行 dmesg 查看完整日志。
2.6 编译与测试流程总结
完成一个内核模块从编写到验证的完整步骤:
- 编写源码:创建
hello.c和Makefile。 - 编译:在源码目录执行
make,生成hello.ko。 - 加载:
sudo insmod hello.ko,观察dmesg输出。 - 验证:
lsmod | grep hello确认模块已加载。 - 卸载:
sudo rmmod hello,再次检查dmesg。 - 清理:
make clean删除编译产物。