在第 11 章:设备驱动模型中,我们了解到 kobject、sysfs 等基础设施如何将设备、驱动、总线组织为统一的层次结构。但有一个核心问题始终没有深入展开:驱动程序是如何进入内核的? 在第 13 章:中断与软中断中,我们看到了中断处理程序注册到内核的过程,但那些处理程序本身又来自哪里?
答案就是本章的主题——内核模块(Kernel Module)。
Linux 内核是一个宏内核(Monolithic Kernel),所有核心功能——调度器、内存管理、文件系统、网络协议栈——都运行在同一个地址空间中。宏内核的优势是性能极高(没有进程间切换开销),劣势是扩展性差:每增加一个驱动或功能,就必须重新编译、重启整个内核。Linux 通过内核模块机制巧妙地化解了这一矛盾——在保持宏内核架构的同时,获得了类似微内核的动态扩展能力。
本章将从模块的设计动机出发,深入剖析 .ko 文件的 ELF 结构、模块加载与卸载的完整流程、符号导出与依赖解析、运行时传参机制、调试接口、模块签名安全机制,以及编写内核模块的最佳实践。
一、模块的设计动机
1.1 宏内核的困境
Linux 采用宏内核架构,所有子系统共享同一地址空间,函数调用直接进行,无需进程间通信(IPC)的开销。这种设计带来了卓越的性能,但也带来了一个严峻的问题:内核体积膨胀。
考虑以下场景:
- 服务器需要支持 10 种网卡驱动,但实际只插了 1 块网卡
- 桌面系统需要支持 50 种文件系统,但实际只用了 ext4 和 tmpfs
- 嵌入式设备需要支持 USB 摄像头驱动,但该设备根本没接摄像头
如果把所有可能的驱动都编译进内核,内核镜像将从几 MB 膨胀到数百 MB,启动时间大幅增加,内存也被大量无用代码占据。
1.2 模块化方案的核心思想
Linux 的解决方案是内核模块——一种可以在内核运行时动态加载和卸载的目标代码文件。模块在加载时被链接到内核地址空间,成为内核的一部分;卸载时从内核中移除,释放占用的资源。
这种设计实现了三个关键目标:
| 目标 | 说明 | 效果 |
|---|---|---|
| 按需加载 | 只在需要时加载驱动和功能 | 减少内核内存占用 |
| 动态扩展 | 无需重启即可添加新功能 | 提高系统可用性 |
| 开发便捷 | 修改驱动只需重新编译模块 | 加速开发迭代 |
模块并非”运行在内核之外”——加载后的模块与内核其他代码处于同一地址空间,拥有相同的特权级,可以直接调用内核导出的任何函数。模块本质上就是”延迟链接的内核代码”。
1.3 模块与微内核的对比
| 特性 | Linux 模块 | 微内核服务进程 |
|---|---|---|
| 地址空间 | 与内核共享 | 独立地址空间 |
| 通信方式 | 直接函数调用 | IPC 消息传递 |
| 故障隔离 | 模块 bug 可导致内核崩溃 | 服务崩溃不影响内核 |
| 性能 | 接近零开销 | IPC 开销显著 |
| 加载方式 | insmod/modprobe | 启动服务进程 |
Linux 选择了性能优先的路径:模块与内核共享地址空间,调用开销极低,但代价是模块中的 bug 可能导致整个系统崩溃。这是 Linux 设计哲学的一贯体现——信任开发者,提供最大灵活性,由开发者自己负责正确性。
二、.ko 文件的 ELF 结构
内核模块文件以 .ko(Kernel Object)为扩展名,它本质上是一个经过特殊处理的 ELF(Executable and Linkable Format)可重定位目标文件。与普通 .o 文件不同,.ko 文件包含了内核模块子系统所需的额外元数据。
2.1 ELF 基本结构
一个典型的 .ko 文件包含以下 ELF 段:
hello.ko 的 ELF 段布局(简化):
ELF Header├── .text — 模块代码(module_init/module_exit 等)├── .rodata — 只读数据(字符串常量等)├── .data — 可读写全局变量├── .bss — 未初始化全局变量├── .modinfo — 模块元信息(作者、许可证、描述等)├── __versions — 模块使用的内核符号 CRC 校验├── .gnu.linkonce.this_module — struct module 实例├── .symtab — 符号表├── .strtab — 字符串表└── .shstrtab — 段名字符串表2.2 .modinfo 段:模块的”身份证”
.modinfo 段存储模块的元信息,由 MODULE_INFO() 宏和一系列专用宏生成。这些信息可以通过 modinfo 命令查看:
// include/linux/module.h — 模块信息宏#define MODULE_INFO(tag, info) __MODULE_INFO(tag, tag, info)
// 常用信息宏MODULE_LICENSE("GPL"); // 许可证(必须)MODULE_AUTHOR("Zhang San"); // 作者MODULE_DESCRIPTION("A demo"); // 描述MODULE_VERSION("1.0"); // 版本MODULE_ALIAS("demo-module"); // 别名.modinfo 段中的信息以 key=value\0 格式存储:
license=GPL\0author=Zhang San\0description=A demo\0version=1.0\0depends=net_drv,usbcore\0MODULE_LICENSE("GPL") 不仅仅是声明——内核会检查许可证标签。标记为非 GPL 的模块(如 “Proprietary”)无法使用内核导出的 GPL-only 符号,且内核社区不为非 GPL 模块提供支持。这是 Linux 内核保护开源生态的重要机制。
2.3 __versions 段:版本一致性校验
内核模块与内核之间的接口可能随版本变化。为了防止加载与当前内核不兼容的模块,Linux 使用 CRC(循环冗余校验) 机制进行版本校验。
__versions 段存储了模块引用的每个内核符号的 CRC 值:
// 模块编译时生成,存储在 __versions 段struct modversion_info { unsigned long crc; char name[MODULE_NAME_LEN]; // 符号名};加载模块时,内核会比较模块中记录的 CRC 与当前内核导出符号的 CRC。如果不匹配,说明模块编译时使用的内核头文件与当前运行的内核不一致,接口可能已经变化,加载将被拒绝(除非设置了 TAINT_FORCED_MODULE)。
2.4 .gnu.linkonce.this_module 段
这个段存储了一个预分配的 struct module 结构体实例。编译器在编译模块时,会将模块名、init/exit 函数指针等信息填入这个结构体,加载时内核直接使用它作为模块的主数据结构。
// include/linux/module.h — 模块核心结构体(简化)struct module { enum module_state state; // 模块状态
/* 模块基本信息 */ char name[MODULE_NAME_LEN]; // 模块名 const struct kernel_symbol *syms; // 模块导出的符号 const s32 *crcs; // 导出符号的 CRC unsigned int num_syms; // 导出符号数量
/* 依赖关系 */ struct list_head list; // 链入全局模块链表 struct list_head source_list; // 依赖本模块的模块列表 struct list_head target_list; // 本模块依赖的模块列表
/* 初始化与清理 */ int (*init)(void); // 模块初始化函数 void (*exit)(void); // 模块卸载函数
/* 代码与数据段 */ void *module_core; // 模块代码+数据在内核中的起始地址 unsigned int core_size; // 代码+数据总大小 unsigned int core_text_size; // 代码段大小
/* 符号表与字符串表 */ Elf_Sym *symtab; // 模块符号表 unsigned int num_symtab; // 符号数量 char *strtab; // 字符串表
/* 参数 */ struct kernel_param *kp; // 模块参数数组 unsigned int num_kp; // 参数数量
/* 状态标志 */ unsigned int taints; // 污染标志
/* 引用计数 */ atomic_t refcnt; // 模块引用计数};三、模块加载流程
模块加载是从用户空间发起、由内核完成的一系列复杂操作。理解这个流程对于排查模块加载问题至关重要。
3.1 完整加载流程
3.2 insmod 与 init_module 系统调用
insmod 是最基础的模块加载命令,它的工作非常简单:
- 读取
.ko文件到用户空间缓冲区 - 调用
init_module()系统调用,将 ELF 数据和参数传递给内核
// kernel/module/main.c — init_module 系统调用入口SYSCALL_DEFINE3(init_module, void __user *, umod, unsigned long, len, const char __user *, uargs){ int err; struct load_info info = { };
// 基本安全检查 err = may_init_module(); if (err) return err;
// 将用户空间的 ELF 数据拷贝到内核空间 err = copy_module_from_user(umod, len, &info); if (err) return err;
// 核心加载逻辑 return load_module(&info, uargs, 0);}3.3 load_module:核心加载逻辑
load_module() 是模块加载的核心函数,它完成了从 ELF 解析到模块初始化的全部工作。其主要步骤如下:
步骤 1:ELF 验证与段布局
// kernel/module/main.c — ELF 头验证(简化)static int elf_header_check(struct load_info *info){ if (info->len < sizeof(*(info->hdr))) return -ENOEXEC;
// 验证 ELF 魔数 if (memcmp(info->hdr->e_ident, ELFMAG, SELFMAG) != 0) return -ENOEXEC;
// 验证架构匹配(x86_64 模块不能加载到 ARM 内核) if (info->hdr->e_machine != CONFIG_ARCH_MCU_TYPE) return -ENOEXEC;
return 0;}内核计算各段所需的总大小,通过 module_alloc() 分配一块连续的内核虚拟地址空间(使用 vmalloc 机制),然后将各段拷贝到对应位置。
步骤 2:符号重定位
模块代码中引用的内核函数(如 printk、kmalloc)在编译时地址未知,需要通过重定位在加载时解析。内核查找模块的 .rela / .rel 重定位段,对每个重定位项:
- 根据符号名在内核导出符号表中查找实际地址
- 根据重定位类型(R_X86_64_PC32、R_X86_64_64 等)计算修正值
- 将修正值写入模块代码的对应位置
步骤 3:依赖解析
模块可以依赖其他模块。.modinfo 段中的 depends= 字段列出了依赖的模块名。内核逐一检查这些依赖是否已经加载,如果有未满足的依赖,加载将失败。
步骤 4:版本校验
如果内核启用了 CONFIG_MODVERSIONS,内核会比较模块 __versions 段中记录的 CRC 与当前内核符号的 CRC。不匹配则拒绝加载,防止接口不兼容导致的内核崩溃。
步骤 5:状态转换与初始化
模块状态机如下:
模块从 MODULE_STATE_UNLOAD 开始,进入 MODULE_STATE_COMING 状态表示正在加载。加载成功后转为 MODULE_STATE_LIVE,此时模块的 init() 函数已被调用。如果 init() 返回非零值,模块将被回滚卸载。
3.4 modprobe:智能模块加载
insmod 只能加载单个模块,且不会自动处理依赖。modprobe 是更高级的工具,它:
- 在
/lib/modules/$(uname -r)/modules.dep.bin中查找模块及其依赖 - 按依赖顺序依次加载所有依赖模块
- 最后加载目标模块
# insmod 需要指定完整路径,不处理依赖insmod /lib/modules/6.12.7/kernel/drivers/net/ethernet/intel/e1000e/e1000e.ko
# modprobe 只需模块名,自动处理依赖modprobe e1000e
# modprobe 还支持别名和通配符modprobe -r e1000e # 卸载模块及其未使用的依赖modprobe 依赖 depmod 生成的模块依赖数据库。每次安装新内核或新模块后,应运行 depmod -a 更新数据库。
四、模块卸载流程
4.1 完整卸载流程
模块卸载是加载的逆过程,但需要更多的安全检查——一个正在被使用的模块如果被强制卸载,将导致内核崩溃。
// kernel/module/main.c — delete_module 系统调用SYSCALL_DEFINE2(delete_module, const char __user *, name_user, unsigned int, flags){ struct module *mod; char name[MODULE_NAME_LEN]; int ret, forced = 0;
// 查找目标模块 ret = strncpy_from_user(name, name_user, MODULE_NAME_LEN); mod = find_module(name); if (!mod) return -ENOENT;
// 检查模块是否有其他模块依赖 if (list_empty(&mod->source_list) == false) { // 有其他模块依赖本模块,不允许卸载 return -EBUSY; }
// 检查引用计数 if (atomic_read(&mod->refcnt) != 0) { if (flags & O_TRUNC) // 强制卸载 forced = 1; else return -EBUSY; }
// 等待 RCU 宽限期结束 wait_for_zero_refcount(mod);
// 设置状态为 GOING mod->state = MODULE_STATE_GOING;
// 调用模块的 exit 函数 if (mod->exit != NULL) mod->exit();
// 从全局链表中移除 list_del_rcu(&mod->list);
// 释放模块占用的内存 free_module(mod);
return 0;}4.2 卸载的安全检查
内核在卸载模块时执行以下安全检查:
| 检查项 | 说明 | 失败后果 |
|---|---|---|
| 依赖检查 | 是否有其他模块依赖本模块 | 返回 -EBUSY |
| 引用计数 | 是否有内核组件正在使用本模块 | 返回 -EBUSY(除非强制) |
| 状态检查 | 模块是否处于 LIVE 状态 | 返回 -EPERM |
| 权限检查 | 调用者是否有 CAP_SYS_MODULE 权能 | 返回 -EPERM |
| exit 函数 | 模块是否提供了 exit 函数 | 不允许卸载(无 exit 意味着不支持卸载) |
强制卸载(rmmod -f)是极其危险的操作。它跳过引用计数检查,可能导致内核中使用该模块代码的线程跳转到已释放的内存地址,引发 Oops 或死锁。生产环境绝对禁止使用强制卸载。
4.3 rmmod 与 modprobe -r
# rmmod:直接卸载指定模块rmmod hello
# modprobe -r:卸载模块及其不再被使用的依赖modprobe -r e1000e
# 查看模块引用计数cat /proc/modules | grep e1000elsmod | grep e1000elsmod 命令读取 /proc/modules 文件,显示当前已加载的模块列表,包括模块大小和引用计数:
Module Size Used bye1000e 327680 0nvme 57344 3nvme_core 184320 5 nvmeext4 921600 2jbd2 1228800 1 ext4Used by 列显示引用该模块的模块列表和计数。只有 Used by 为 0 的模块才能被卸载。
五、符号导出与依赖解析
5.1 EXPORT_SYMBOL:模块间的”公共 API”
内核模块之间的协作依赖于符号导出机制。一个模块通过 EXPORT_SYMBOL() 将自己的函数或变量导出为公共符号,其他模块就可以引用这些符号。
// 导出符号——任何模块都可以使用EXPORT_SYMBOL(symbol_name);
// 仅 GPL 许可的模块可以使用EXPORT_SYMBOL_GPL(symbol_name);
// 仅 GPL + 未来版本的模块可以使用EXPORT_SYMBOL_GPL_FUTURE(symbol_name);这些宏在模块的 __ksymtab 段中创建一个 kernel_symbol 条目:
struct kernel_symbol { int value; // 符号地址(相对或绝对) const char *name; // 符号名 const char *namespace; // 符号命名空间(可选)};5.2 符号查找过程
当模块 A 引用模块 B 导出的符号时,加载器按以下顺序查找:
- 内核导出符号表 — 内核自身导出的符号(
vmlinux的__ksymtab段) - 已加载模块的导出符号 — 按模块加载顺序查找
- 未加载的依赖模块 — 如果
modprobe处理,会先加载依赖模块
// kernel/module/main.c — 符号查找(简化)static const struct kernel_symbol *resolve_symbol(struct module *mod, const struct load_info *info, const char *name, char ownername[]){ const struct kernel_symbol *sym; const unsigned long *crc; int err;
// 在内核和已加载模块中查找符号 sym = find_symbol(name, &ownername, &crc, true, true);
if (!sym) return ERR_PTR(-ENOENT);
// CRC 版本校验 err = check_version(info, name, mod, crc); if (err) return ERR_PTR(err);
// 检查 GPL 许可兼容性 if (!check_export_symbol(sym, mod)) return ERR_PTR(-EPERM);
return sym;}5.3 符号命名空间
从 Linux 5.4 开始,内核引入了**符号命名空间(Symbol Namespace)**机制,允许开发者将导出符号分组到不同的命名空间中,模块必须通过 MODULE_IMPORT_NS() 声明导入才能使用:
// 导出符号到特定命名空间EXPORT_SYMBOL_NS(my_func, MY_NAMESPACE);
// 使用方模块需要显式导入MODULE_IMPORT_NS(MY_NAMESPACE);命名空间的好处:
- 接口分层:将内部符号和公共 API 分开
- 依赖声明:模块必须显式声明对特定命名空间的依赖
- 重构安全:修改命名空间可以追踪所有使用者
5.4 /proc/kallsyms:运行时符号表
/proc/kallsyms 文件列出了内核和所有已加载模块导出的符号及其地址:
# 查看内核导出的符号cat /proc/kallsyms | head -5# ffffffff81000000 T _text# ffffffff81001000 T startup_64# ffffffff81002000 T do_one_initcall
# 查看特定模块导出的符号cat /proc/kallsyms | grep "e1000e" | head -5# ffffffffc0420000 T e1000_probe [e1000e]# ffffffffc0420100 T e1000_remove [e1000e]
# 非特权用户看到的地址为 0(安全保护)# 需要设置 /proc/sys/kernel/kptr_restrict = 0 才能看到真实地址出于安全考虑,kptr_restrict 默认为 1,非 root 用户在 /proc/kallsyms 中看到的地址全为 0。这防止了攻击者利用符号地址进行内核漏洞利用。
六、模块参数:运行时传参
6.1 module_param 机制
内核模块支持在加载时通过命令行传递参数,这通过 module_param() 宏族实现:
#define module_param(name, type, perm) \ module_param_named(name, name, type, perm)
// 常用类型:bool, int, long, charp, ulong, uint 等static int count = 1;module_param(count, int, 0644);MODULE_PARM_DESC(count, "Number of instances to create");
static char *name = "default";module_param(name, charp, 0644);MODULE_PARM_DESC(name, "Instance name");
static bool debug = false;module_param(debug, bool, 0644);MODULE_PARM_DESC(debug, "Enable debug output");module_param() 的三个参数:
| 参数 | 说明 | 示例 |
|---|---|---|
name | 变量名(既是变量名也是参数名) | count |
type | 参数类型 | int, charp, bool |
perm | sysfs 中的文件权限(0 表示不在 sysfs 中暴露) | 0644, 0 |
6.2 参数的 sysfs 表示
当 perm 非零时,参数会在 /sys/module/<模块名>/parameters/ 下创建对应的文件:
# 查看模块参数ls /sys/module/hello/parameters/# count name debug
# 读取参数值cat /sys/module/hello/parameters/count# 1
# 运行时修改参数(需要 perm 允许写入)echo 5 > /sys/module/hello/parameters/count这种设计使得模块参数可以在加载时通过 insmod/modprobe 命令行设置,也可以在运行时通过 sysfs 动态调整——非常适合调试和性能调优。
6.3 高级参数宏
// 自定义参数名(参数名与变量名不同)static int max_size = 1024;module_param_named(maximum, max_size, int, 0644);// insmod hello.ko maximum=2048
// 字符串数组参数static int num_names;static char *names[10];module_param_array(names, charp, &num_names, 0644);// insmod hello.ko names=alice,bob,charlie
// 参数回调函数(参数被修改时触发)static int notify_param(const char *val, const struct kernel_param *kp){ int ret = param_set_int(val, kp); if (ret == 0) pr_info("Parameter changed to %d\n", *(int *)kp->arg); return ret;}static const struct kernel_param_ops notify_ops = { .set = notify_param, .get = param_get_int,};module_param_cb(threshold, ¬ify_ops, &threshold, 0644);七、内核调试接口
7.1 printk:内核的 printf
printk() 是内核中最基本的调试输出函数。它与 C 标准库的 printf() 类似,但运行在内核空间,输出到内核环形缓冲区(kernel ring buffer)。
// include/linux/printk.h — 日志级别#define KERN_EMERG "<0>" // 系统不可用#define KERN_ALERT "<1>" // 必须立即处理#define KERN_CRIT "<2>" // 严重条件#define KERN_ERR "<3>" // 错误条件#define KERN_WARNING "<4>" // 警告条件#define KERN_NOTICE "<5>" // 正常但值得注意#define KERN_INFO "<6>" // 信息性#define KERN_DEBUG "<7>" // 调试信息
// 使用示例printk(KERN_INFO "Hello from module\n");printk(KERN_ERR "Failed to allocate memory: %d\n", err);pr_info("Module loaded with count=%d\n", count); // 等价于 printk(KERN_INFO ...)pr_err("Something went wrong\n"); // 等价于 printk(KERN_ERR ...)pr_warn("Suspicious condition\n"); // 等价于 printk(KERN_WARNING ...)7.2 日志级别控制
内核日志级别决定了哪些消息会显示到控制台:
# 查看当前控制台日志级别cat /proc/sys/kernel/printk# 4 4 1 7# │ │ │ └── 默认日志级别(printk 未指定级别时使用)# │ │ └───── 最小控制台日志级别# │ └──────── 默认控制台日志级别# └─────────── 当前控制台日志级别
# 只有级别 < 当前控制台日志级别的消息才会显示到控制台# 例如:当前级别为 4,则 KERN_EMERG(0)~KERN_ERR(3) 会显示
# 修改控制台日志级别echo 8 > /proc/sys/kernel/printk # 显示所有级别的日志
# 使用 dmesg 查看环形缓冲区中的所有日志dmesg | tail -20dmesg -w # 实时跟踪(类似 tail -f)dmesg -l err,warn # 只显示错误和警告7.3 pr_debug 与动态调试
printk(KERN_DEBUG ...) 的问题在于:调试信息默认不输出,但代码仍然存在,增加了内核镜像体积。更糟糕的是,修改调试输出需要重新编译模块。
动态调试(Dynamic Debug) 解决了这个问题:
// 使用 pr_debug 代替 printk(KERN_DEBUG ...)pr_debug("Processing item %d\n", item_id);
// 使用 dev_dbg(设备驱动推荐)dev_dbg(dev, "Register %x = %x\n", reg, val);pr_debug 在未启用动态调试时编译为空操作(零开销),启用后可以通过 sysfs 接口在运行时按需开关:
# 启用所有动态调试echo 'p' > /sys/kernel/debug/dynamic_debug/control
# 启用特定文件的调试输出echo 'file hello.c +p' > /sys/kernel/debug/dynamic_debug/control
# 启用特定函数的调试输出echo 'func my_init +p' > /sys/kernel/debug/dynamic_debug/control
# 启用特定模块的调试输出echo 'module my_module +p' > /sys/kernel/debug/dynamic_debug/control
# 启用并附加行号echo 'file hello.c +pl' > /sys/kernel/debug/dynamic_debug/control
# 关闭调试输出echo 'file hello.c -p' > /sys/kernel/debug/dynamic_debug/control
# 查看当前启用的调试规则cat /sys/kernel/debug/dynamic_debug/control动态调试的查询语法支持多种过滤条件:
| 过滤器 | 说明 | 示例 |
|---|---|---|
file | 源文件名 | file drivers/net/e1000e/* |
module | 模块名 | module e1000e |
func | 函数名 | func e1000_probe |
format | 格式字符串 | format "timeout" |
line | 行号范围 | line 100-200 |
八、模块签名与安全
8.1 模块签名机制
从 Linux 3.7 开始,内核支持模块签名验证。当启用 CONFIG_MODULE_SIG 时,内核在加载模块时会验证模块的数字签名,确保模块未被篡改。
签名流程:
- 编译时签名:使用私钥对
.ko文件的哈希值签名,签名附加到模块文件末尾 - 加载时验证:内核使用内嵌的公钥验证签名,验证失败则拒绝加载
# 内核配置选项CONFIG_MODULE_SIG=y # 启用模块签名CONFIG_MODULE_SIG_ALL=y # 自动为模块签名CONFIG_MODULE_SIG_SHA256=y # 使用 SHA-256 哈希CONFIG_MODULE_SIG_FORCE=y # 强制模式:签名验证失败则拒绝加载8.2 强制模式与非强制模式
| 模式 | 行为 | 适用场景 |
|---|---|---|
| 非强制(默认) | 签名验证失败则标记内核为”污染”(tainted),但仍允许加载 | 开发环境 |
| 强制 | 签名验证失败则拒绝加载模块 | 生产环境、安全敏感系统 |
8.3 内核污染标志
内核维护了一个”污染”(taint)标志位图,记录了各种可能导致内核行为异常的情况:
# 查看内核污染状态cat /proc/sys/kernel/tainted
# 污染标志含义(位图):# P (0) - 加载了非 GPL 模块# F (1) - 强制加载了模块# S (2) - 在不支持的架构上运行# R (3) - 模块被强制卸载# M (4) - 处理器报告了机器检查异常# B (5) - 引用了错误的页# U (6) - 用户空间导致 taint# D (7) - 内核最近死锁# A (8) - ACPI 表被覆盖# W (9) - 警告# C (10) - 加载了 staging 驱动# I (11) - 平台固件问题# O (12) - 加载了外部构建的模块# E (13) - 签名验证失败的模块# L (14) - 软锁定# K (15) - 实时补丁# X (16) - aux 污染当向内核社区报告 bug 时,污染内核的 bug 报告通常不会被处理——因为非 GPL 模块或强制加载的模块可能引入了不可预测的行为,无法确定 bug 是内核自身还是模块导致的。
8.4 限制模块加载
系统管理员可以通过多种方式限制模块加载:
# 方式一:通过 sysctl 禁止加载新模块echo 1 > /proc/sys/kernel/modules_disabled# 注意:此设置一旦启用,无法在运行时关闭(需要重启)
# 方式二:通过 /etc/modprobe.d/ 黑名单echo "blacklist e1000e" > /etc/modprobe.d/blacklist.confecho "install e1000e /bin/true" >> /etc/modprobe.d/blacklist.conf
# 方式三:通过 Linux Security Module (LSM) 限制# SELinux、AppArmor 等可以限制模块加载操作
# 方式四:内核编译时禁用模块支持# CONFIG_MODULES=n — 完全禁止模块加载(嵌入式系统常用)九、编写内核模块的最佳实践
9.1 最小可运行示例
// hello.c — 最简单的内核模块#include <linux/init.h>#include <linux/module.h>#include <linux/kernel.h>
MODULE_LICENSE("GPL");MODULE_AUTHOR("Linux Explorer");MODULE_DESCRIPTION("Hello World Kernel Module");MODULE_VERSION("1.0");
static int count = 1;module_param(count, int, 0644);MODULE_PARM_DESC(count, "Number of greetings");
static int __init hello_init(void){ int i; for (i = 0; i < count; i++) pr_info("Hello, kernel world! (greeting %d)\n", i + 1); return 0;}
static void __exit hello_exit(void){ pr_info("Goodbye, kernel world!\n");}
module_init(hello_init);module_exit(hello_exit);对应的 Makefile:
obj-m += hello.o
KDIR := /lib/modules/$(shell uname -r)/build
all: make -C $(KDIR) M=$(PWD) modules
clean: make -C $(KDIR) M=$(PWD) clean9.2 关键编码规范
1. 使用 __init 和 __exit 标记
static int __init my_init(void) { ... } // 初始化完成后释放代码内存static void __exit my_exit(void) { ... } // 不支持卸载时释放代码内存__init 标记的函数在初始化完成后会被内核回收(其代码占用的内存被释放),因为它们不会再被调用。__exit 标记的函数在内建模块(编译进内核的模块)中会被完全优化掉——内建模块永远不会被卸载。
2. 错误处理必须完整
static int __init my_init(void){ int err;
err = register_chrdev(MY_MAJOR, "mydev", &my_fops); if (err < 0) return err; // 直接返回,无需清理
err = device_create(my_class, NULL, MKDEV(MY_MAJOR, 0), NULL, "mydev"); if (IS_ERR(err)) { unregister_chrdev(MY_MAJOR, "mydev"); // 回滚之前的注册 return PTR_ERR(err); }
return 0;}3. init 失败必须回滚所有已分配资源
// 错误示范:泄漏资源static int __init bad_init(void){ if (request_irq(irq, handler, 0, "mydev", NULL)) return -EBUSY; // 如果下面的注册失败,IRQ 不会被释放!
if (misc_register(&my_misc)) { // 忘记 free_irq! return -ENOMEM; } return 0;}
// 正确示范:goto 链式清理static int __init good_init(void){ int err;
err = request_irq(irq, handler, 0, "mydev", NULL); if (err) goto err_irq;
err = misc_register(&my_misc); if (err) goto err_misc;
return 0;
err_misc: free_irq(irq, NULL);err_irq: return err;}内核社区广泛使用 goto 进行错误处理——这不是糟糕的编程风格,而是内核代码的惯用模式,它避免了深层嵌套和代码重复。
4. 避免全局可变状态
模块应尽量减少全局变量。使用设备特定结构体(device-specific struct)将所有状态集中管理:
struct my_device { struct cdev cdev; struct device *dev; void __iomem *regs; spinlock_t lock; wait_queue_head_t wq; // ... 所有设备相关状态};
// 在 open 时分配,在 release 时释放static int my_open(struct inode *inode, struct file *filp){ struct my_device *dev = container_of(inode->i_cdev, struct my_device, cdev); filp->private_data = dev; return 0;}5. 正确处理并发
// 共享数据必须保护static DEFINE_SPINLOCK(my_lock);static LIST_HEAD(my_list);
static void add_item(struct my_item *item){ unsigned long flags; spin_lock_irqsave(&my_lock, flags); list_add(&item->list, &my_list); spin_unlock_irqrestore(&my_lock, flags);}9.3 Makefile 与构建系统
内核模块使用 Kbuild 构建系统。一个多文件模块的 Makefile:
# 多文件编译为一个模块obj-m += mydriver.omydriver-objs := main.o utils.o io.o
# 条件编译ccflags-y := -DDEBUGccflags-y += -I$(src)/include
# 构建目标KDIR := /lib/modules/$(shell uname -r)/build
all: $(MAKE) -C $(KDIR) M=$(PWD) modules
install: $(MAKE) -C $(KDIR) M=$(PWD) modules_install
clean: $(MAKE) -C $(KDIR) M=$(PWD) clean十、向上游贡献模块
10.1 内核代码风格
Linux 内核有严格的代码风格规范,由 scripts/checkpatch.pl 工具检查:
# 检查补丁是否符合代码风格scripts/checkpatch.pl my_patch.patch
# 检查单个文件scripts/checkpatch.pl --file drivers/my_driver.c关键规则:
- 缩进使用 Tab(8 字符宽度),而非空格
- 行宽不超过 80 字符
- 左花括号不换行(函数定义除外)
- 变量声明放在块的开头
- 使用
/* */风格注释,而非//
10.2 提交流程
- 订阅邮件列表:
linux-kernel@vger.kernel.org和相关子系统列表 - 使用
git format-patch生成补丁 - 使用
git send-email发送补丁(不要用附件) - 补丁说明格式:
[PATCH v2] subsystem: brief description
Detailed description of what the patch does, why it is needed,and how it achieves the goal. Wrap at 72 columns.
Signed-off-by: Your Name <your@email.com>- 响应审阅意见,修改后发送新版本(
[PATCH v3])
10.3 staging 目录:新驱动的入口
新驱动通常先进入 drivers/staging/ 目录。staging 驱动是”尚不够成熟”的代码,需要在社区审阅和改进后才能”毕业”到正式目录。staging 驱动会设置 TAINT_CRAP 污染标志。
# 查看 staging 驱动ls drivers/staging/
# staging 驱动的 TODO 文件列出了需要改进的地方cat drivers/staging/rtl8192e/TODO十一、动手实践
实践 1:编写带参数的内核模块
// param_demo.c — 带参数的内核模块#include <linux/init.h>#include <linux/module.h>#include <linux/kernel.h>#include <linux/moduleparam.h>
MODULE_LICENSE("GPL");MODULE_AUTHOR("Linux Explorer");MODULE_DESCRIPTION("Parameter Demo Module");
static int count = 1;static char *name = "world";static bool verbose = false;
module_param(count, int, 0644);MODULE_PARM_DESC(count, "Number of greetings (default: 1)");
module_param(name, charp, 0644);MODULE_PARM_DESC(name, "Name to greet (default: world)");
module_param(verbose, bool, 0644);MODULE_PARM_DESC(verbose, "Verbose output (default: false)");
static int __init param_init(void){ int i; pr_info("param_demo: loaded with count=%d, name=%s, verbose=%d\n", count, name, verbose); for (i = 0; i < count; i++) { if (verbose) pr_info("param_demo: greeting #%d: Hello, %s!\n", i + 1, name); else pr_info("param_demo: Hello, %s!\n", name); } return 0;}
static void __exit param_exit(void){ pr_info("param_demo: Goodbye, %s!\n", name);}
module_init(param_init);module_exit(param_exit);obj-m += param_demo.oKDIR := /lib/modules/$(shell uname -r)/build
all: make -C $(KDIR) M=$(PWD) modules
clean: make -C $(KDIR) M=$(PWD) clean实践 2:insmod/rmmod 与 dmesg
# 编译模块make
# 加载模块(带参数)sudo insmod param_demo.ko count=3 name=Linux verbose=1
# 查看内核日志dmesg | tail -10# [ 1234.567890] param_demo: loaded with count=3, name=Linux, verbose=1# [ 1234.567891] param_demo: greeting #1: Hello, Linux!# [ 1234.567892] param_demo: greeting #2: Hello, Linux!# [ 1234.567893] param_demo: greeting #3: Hello, Linux!
# 查看模块信息cat /proc/modules | grep param_demo# param_demo 16384 0 - Live 0xffffffffc0500000 (OE)
# 查看模块参数ls /sys/module/param_demo/parameters/# count name verbose
cat /sys/module/param_demo/parameters/name# Linux
# 运行时修改参数echo 5 | sudo tee /sys/module/param_demo/parameters/count
# 卸载模块sudo rmmod param_demo
# 再次查看日志dmesg | tail -5# [ 1250.123456] param_demo: Goodbye, Linux!实践 3:modinfo 与模块元信息
# 查看模块文件元信息(无需加载)modinfo param_demo.ko# license: GPL# author: Linux Explorer# description: Parameter Demo Module# depends:# name: param_demo# vermagic: 6.12.7 SMP mod_unload modversions
# 查看模块参数描述modinfo -p param_demo.ko# count:Number of greetings (default: 1) (int)# name:Name to greet (default: world) (charp)# verbose:Verbose output (default: false) (bool)
# 查看已加载模块的信息modinfo param_demo实践 4:/proc/kallsyms 符号查找
# 查看内核导出的符号数量cat /proc/kallsyms | wc -l# 约 100,000+ 个符号
# 查找特定函数的地址cat /proc/kallsyms | grep " printkl$"# ffffffff8110a340 T printk
# 查看模块导出的符号(方括号中为模块名)cat /proc/kallsyms | grep "\[e1000e\]" | head -5
# 查看当前内核的污染状态cat /proc/sys/kernel/tainted# 0 表示未污染
# 加载非 GPL 模块后再次检查# 会出现非零值,表示内核已被污染实践 5:动态调试实验
# 确保内核启用了动态调试cat /boot/config-$(uname -r) | grep DYNAMIC_DEBUG# CONFIG_DYNAMIC_DEBUG=y
# 启用特定模块的调试输出echo 'module param_demo +p' > /sys/kernel/debug/dynamic_debug/control
# 查看已启用的调试规则cat /sys/kernel/debug/dynamic_debug/control | grep param_demo
# 关闭调试输出echo 'module param_demo -p' > /sys/kernel/debug/dynamic_debug/control参考资料
内核源码
| 路径 | 说明 |
|---|---|
kernel/module/main.c | 模块加载/卸载核心逻辑 |
kernel/module/signing.c | 模块签名验证 |
kernel/module/strict.c | 模块加载限制 |
include/linux/module.h | 模块相关宏和数据结构定义 |
include/linux/moduleparam.h | 模块参数宏定义 |
include/linux/export.h | 符号导出宏定义 |
include/linux/printk.h | printk 接口定义 |
include/linux/dynamic_debug.h | 动态调试接口 |
kernel/params.c | 模块参数处理实现 |
scripts/mod/ | 模块构建工具(modpost 等) |
权威书籍
| 书籍 | 作者 | 相关章节 |
|---|---|---|
| 《Linux 设备驱动程序》第 3 版 | Jonathan Corbet 等 | 第 2 章:构建和运行模块 |
| 《Linux 内核设计与实现》第 3 版 | Robert Love | 第 7 章:内核同步引言、第 8 章:内核数据结构 |
| 《深入理解 Linux 内核》第 3 版 | Daniel P. Bovet 等 | 附录 B:模块 |
| 《Professional Linux Kernel Architecture》 | Wolfgang Mauerer | Chapter 7: Modules |
在线文档
- Linux Kernel Module Programming Guide — 内核模块编程的现代指南
- Kernel Documentation: Modules — 内核黑客指南中的模块部分
- Dynamic Debug Howto — 动态调试使用指南
- Module Signing — 模块签名文档
- Submitting Patches — 内核补丁提交指南
- Linux Kernel Coding Style — 内核代码风格规范
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






