Linux设备驱动开发与内核模块动态部署 - 第2节
2.1 内核模块概念
Linux 内核模块(Kernel Module)是一段可以在运行时动态加载到内核或从内核卸载的代码片段。它允许在不重新编译整个内核、不重启系统的情况下扩展内核功能。模块机制是 Linux 内核可扩展性的核心支柱之一,也是设备驱动最常用的部署形式。
核心定义: 内核模块本质上是目标文件(.ko 文件),包含初始化/退出函数、符号导出表以及依赖信息。它运行在内核空间,拥有最高特权级(Ring 0)。
2.1.1 模块的物理形态
一个编译完成的内核模块通常以 .ko(Kernel Object)为后缀,存放在 /lib/modules/$(uname -r)/ 目录下。使用 file 命令可以查看其 ELF 格式信息:
$ file hello.ko
hello.ko: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), BuildID[sha1]=..., not stripped
模块文件包含以下关键段:
- .init.text — 模块初始化函数代码段,加载时执行一次后释放。
- .exit.text — 模块退出函数代码段,卸载时执行。
- .modinfo — 模块信息段,包含作者、描述、许可证、依赖等元数据。
- .symtab / .strtab — 符号表和字符串表,用于符号导出与解析。
- .modversions — 版本校验信息,确保模块与当前内核 ABI 兼容。
2.1.2 模块的依赖关系
模块之间可以存在依赖。例如,USB 存储驱动依赖于 SCSI 核心模块。依赖信息通过 depmod 工具生成,存储在 modules.dep 文件中:
$ depmod -a
$ cat /lib/modules/$(uname -r)/modules.dep | grep usb-storage
kernel/drivers/usb/storage/usb-storage.ko: kernel/drivers/scsi/scsi_mod.ko kernel/drivers/usb/core/usbcore.ko
提示: 使用
modinfo -F depends hello.ko 可以快速查看模块的直接依赖列表,无需手动解析 modules.dep。
2.2 模块与应用程序的区别
内核模块虽然也是 C 语言编写的二进制程序,但与用户空间的应用程序存在本质差异。理解这些差异是编写正确驱动的前提。
| 对比维度 | 内核模块 | 用户空间应用程序 |
|---|---|---|
| 运行空间 | 内核空间(Ring 0) | 用户空间(Ring 3) |
| 入口函数 | module_init() / module_exit() |
main() |
| 内存访问 | 直接访问物理内存、I/O 端口 | 虚拟地址空间,受 MMU 保护 |
| 标准库 | 不可使用 libc(如 printf、malloc) | 可链接任意用户态库 |
| 调试方式 | printk / ftrace / kdump / kgdb | gdb / strace / valgrind |
| 错误影响 | 内核 panic / 系统崩溃 | 进程 segfault / 可恢复 |
| 浮点运算 | 默认禁用,需手动保存 FPU 状态 | 正常使用 |
| 链接方式 | 动态链接到内核符号表 | 静态/动态链接到用户态库 |
| 生命周期 | 由 insmod/rmmod 或内核自动管理 | 由 shell 或进程管理器管理 |
关键警告: 模块运行在内核空间,一个空指针解引用就会导致整个系统崩溃。必须使用内核专用 API(如
kmalloc、printk),绝不能调用 malloc、printf 等用户态函数。
2.2.1 内核专用 API 示例
以下对比展示模块与应用程序在内存分配和输出上的差异:
/* 用户空间应用程序 */
#include <stdio.h>
#include <stdlib.h>
int main(void) {
char *buf = malloc(1024);
printf("buf = %p\n", buf);
free(buf);
return 0;
}
/* 内核模块 */
#include <linux/module.h>
#include <linux/slab.h>
static int __init my_init(void) {
char *buf = kmalloc(1024, GFP_KERNEL);
printk(KERN_INFO "buf = %p\n", buf);
kfree(buf);
return 0;
}
module_init(my_init);
2.3 模块生命周期
内核模块从加载到卸载经历四个明确的阶段。每个阶段都有对应的内核回调函数和状态转换。理解生命周期是正确编写初始化/退出逻辑的基础。
2.3.1 生命周期四阶段
- 加载阶段(Loading) — 通过
insmod或modprobe将 .ko 文件读入内核,解析依赖,分配内存,执行模块初始化函数。 - 运行阶段(Running) — 模块初始化成功后进入运行态,提供设备操作接口(如 file_operations),响应系统调用或硬件中断。
- 卸载阶段(Unloading) — 通过
rmmod触发模块退出函数,释放资源,注销设备,从内核符号表移除。 - 释放阶段(Cleanup) — 内核回收模块占用的所有内存和系统资源,模块完全消失。
2.3.2 关键回调函数
| 回调函数 | 触发时机 | 典型职责 | 返回值 |
|---|---|---|---|
module_init() |
模块加载时自动调用 | 注册设备、分配资源、初始化硬件 | 0 成功 / 负值失败 |
module_exit() |
模块卸载时自动调用 | 注销设备、释放内存、关闭硬件 | void |
__init 标记 |
初始化函数执行后释放代码段 | 标记初始化专用函数,节省内存 | — |
__exit 标记 |
仅模块卸载时编译进内核 | 标记退出函数,非卸载时丢弃 | — |
最佳实践: 使用
__init 和 __exit 宏标记初始化/退出函数。当模块被静态编译进内核时,__exit 标记的函数会被完全丢弃,减少内核映像体积。
2.3.3 生命周期状态机
2.3.4 引用计数机制
内核使用引用计数(reference count)防止模块在正被使用时被卸载。每个模块维护一个 refcnt 字段:
- 当有进程打开模块管理的设备文件时,引用计数 +1。
- 当进程关闭设备文件时,引用计数 -1。
- 只有引用计数为 0 时,
rmmod才能成功卸载模块。
# 查看模块引用计数
$ lsmod | grep hello
hello 16384 0 # 第三列 0 表示引用计数为 0
# 打开设备后引用计数变为 1
$ cat /dev/hello &
$ lsmod | grep hello
hello 16384 1 # 引用计数为 1,无法卸载
# 卸载尝试会失败
$ rmmod hello
rmmod: ERROR: Module hello is in use
内核实现: 现代内核(5.x+)使用
try_module_get() 和 module_put() 原子操作管理引用计数。驱动开发者通常不需要手动调用,VFS 层会在 open()/release() 时自动处理。
2.3.5 模块加载/卸载命令速查
| 命令 | 功能 | 自动处理依赖 | 常用选项 |
|---|---|---|---|
insmod hello.ko |
直接加载模块 | 否 | — |
modprobe hello |
加载模块及其依赖 | 是 | -r 卸载,--show-depends 查看依赖 |
rmmod hello |
卸载模块 | 否 | -f 强制卸载(危险) |
modinfo hello.ko |
查看模块元信息 | — | -p 查看参数,-F depends 查看依赖 |
lsmod |
列出所有已加载模块 | — | 输出:模块名、大小、引用计数、依赖 |
2.4 本章小结
- 内核模块是 .ko 格式 的 ELF 目标文件,包含初始化/退出代码段、符号表和版本信息。
- 模块与应用程序在 运行空间、入口函数、库支持、错误影响 等方面存在根本差异。
- 模块生命周期分为 加载 → 运行 → 卸载 → 释放 四个阶段,由
module_init和module_exit回调驱动。 - 引用计数机制确保模块在正被使用时不会被意外卸载,
lsmod的第三列显示当前引用计数。 - 使用
insmod/modprobe/rmmod管理模块,modprobe会自动处理依赖关系。