1. 内核模块基础概念
1.1 什么是内核模块
内核模块(Kernel Module)是一段可以在 Linux 内核运行时动态加载和卸载的代码,用于扩展内核功能而不需要重新编译整个内核或重启系统。模块通常用于实现设备驱动、文件系统、网络协议、系统调用等内核级功能。
核心特征: 内核模块是可动态插拔的内核扩展单元,通过 insmod / modprobe 加载,通过 rmmod 卸载。
1.2 内核模块与用户态程序的区别
内核模块与普通用户态程序在运行环境、权限、开发方式等方面存在本质差异。理解这些区别是正确编写和调试模块的前提。
| 对比维度 | 内核模块 | 用户态程序 |
|---|---|---|
| 运行空间 | 内核空间(Ring 0) | 用户空间(Ring 3) |
| 入口函数 | module_init() / module_exit() |
main() |
| 内存访问 | 直接访问物理内存、内核地址空间 | 只能访问虚拟地址空间,受 MMU 保护 |
| 系统调用 | 不能直接调用系统调用,使用内核 API | 通过系统调用接口请求内核服务 |
| 标准库 | 不能链接 glibc,使用内核提供的 printk、kmalloc 等 |
可链接标准 C 库,使用 printf、malloc 等 |
| 调试方式 | 通过 dmesg、kgdb、ftrace 等内核调试工具 |
通过 gdb、strace、valgrind 等用户态调试工具 |
| 崩溃影响 | 模块崩溃可能导致整个系统 panic 或死机 | 进程崩溃通常只影响自身,系统继续运行 |
| 加载方式 | insmod / modprobe 动态加载 |
通过 shell 或 exec 启动进程 |
| 许可证要求 | 通常需要 GPL 兼容许可证(MODULE_LICENSE("GPL")) |
无特殊许可证限制 |
⚠️ 关键风险: 内核模块运行在最高特权级,一个空指针解引用或缓冲区溢出就可能导致整个系统崩溃。开发内核模块时必须格外谨慎,做好边界检查和错误处理。
1.3 模块生命周期
内核模块从编写完成到最终卸载,经历一系列明确的阶段。理解生命周期有助于定位加载失败、运行异常等问题。
生命周期阶段总览
| 阶段 | 触发动作 | 关键函数 / 事件 | 典型状态 |
|---|---|---|---|
| 1. 编译 | make |
Kbuild 系统编译生成 .ko 文件 |
ELF 目标文件 |
| 2. 加载 | insmod / modprobe |
module_init() 被调用 |
Live(活动) |
| 3. 初始化 | 内核模块加载器 | 执行 init 函数,注册设备/文件系统等 | Live(活动) |
| 4. 运行 | 系统正常运行 | 模块提供服务(驱动读写、协议处理等) | Live(活动) |
| 5. 卸载准备 | rmmod |
检查模块引用计数是否为 0 | Going-away(正在移除) |
| 6. 卸载 | 内核模块卸载器 | module_exit() 被调用,释放资源 |
Deleted(已删除) |
模块加载与卸载的详细流程
当执行 insmod mymodule.ko 时,内核模块加载器执行以下步骤:
- 文件读取: 将
.koELF 文件读入内核内存。 - 符号解析: 解析模块中引用的外部符号(函数、变量),在内核符号表中查找匹配项。
- 版本校验: 检查模块的
vermagic字符串是否与当前内核版本一致。 - 内存分配: 为模块代码段、数据段分配内核内存空间。
- 重定位: 根据加载地址修正模块内的绝对地址引用。
- 调用 init 函数: 执行
module_init()指定的初始化函数。 - 注册模块: 将模块加入内核模块链表,更新引用计数。
💡 实用技巧: 使用 lsmod 查看当前已加载的模块列表;使用 modinfo mymodule.ko 查看模块的详细信息(作者、描述、许可证、依赖等)。
一个完整的内核模块示例
// hello.c — 最简单的内核模块
#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; // 返回 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("Linux Kernel Expert");
MODULE_DESCRIPTION("A simple Hello World kernel module");
MODULE_VERSION("1.0");
编译该模块的 Makefile:
obj-m += hello.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
关键点总结:
__init标记初始化函数,加载完成后该函数占用的内存会被释放。__exit标记卸载函数,仅在模块编译为内置(built-in)时有效。MODULE_LICENSE("GPL")是必需的,否则内核会标记模块为“污染”(tainted)。printk是内核空间的“printf”,输出可通过dmesg查看。
模块引用计数机制
内核使用引用计数(reference count)来跟踪模块是否正在被使用。当引用计数大于 0 时,rmmod 会失败并返回 EBUSY。
| 操作 | 引用计数变化 | 说明 |
|---|---|---|
insmod |
0 → 1 | 模块加载成功,初始引用计数为 1 |
| 打开模块设备文件 | +1 | 用户态程序打开模块对应的设备节点 |
| 关闭模块设备文件 | -1 | 用户态程序关闭设备节点 |
| 其他模块依赖本模块 | +1 | 模块 B 使用了模块 A 导出的符号 |
rmmod |
1 → 0 | 引用计数为 0 时才能卸载 |
⚠️ 常见故障: 模块卸载失败最常见的原因是引用计数不为 0。使用 lsmod 查看 Used by 列,确认哪些进程或模块还在使用当前模块。使用 fuser -v /dev/your_device 查找占用设备文件的进程。
模块状态查看命令
| 命令 | 功能 | 示例输出 |
|---|---|---|
lsmod |
列出所有已加载模块 | hello 16384 0 - Live 0x0000000000000000 |
modinfo <module> |
显示模块详细信息 | 显示作者、描述、许可证、依赖等 |
dmesg |
查看内核日志(含 printk 输出) | [12345.678] Hello, Kernel Module loaded! |
cat /proc/modules |
查看模块内存占用和引用计数 | hello 16384 0 - Live 0xffffffffc0000000 |
cat /proc/kallsyms |
查看内核符号表(含模块导出符号) | ffffffffc0000000 t hello_init |
💡 调试建议: 在模块开发阶段,建议在 init 和 exit 函数中增加 printk 日志输出,配合 dmesg -w 实时查看加载/卸载过程中的内核消息,快速定位问题。