Chapter 18: Loadable Kernel Modules
可加载内核模块把一部分内核代码从“随内核镜像一起启动”移动到“运行中的内核按需接纳”。读完本章后,读者应能定位一个 .ko 文件进入内核的主路径,判断 module_init、module_exit、模块参数、依赖文件和签名策略分别影响哪个阶段,并能区分“模块加载成功”和“模块行为安全”这两个结论。
本章的贯穿材料是一段教学模块 probe_demo.ko。它只注册初始化入口、清理入口和一个整数参数,用来观察模块从文件变成内核对象的过程。它对应真实源码中的几类对象:include/linux/init.h 里的 module_init / module_exit 宏,include/linux/module.h 里的模块元数据,include/linux/moduleparam.h 与 kernel/params.c 里的参数描述和解析路径,以及 kernel/module/main.c 里的加载、检查、重定位、初始化和卸载主流程。
#include <linux/init.h>
#include <linux/module.h>
#include <linux/moduleparam.h>
static int probe_count = 1;
module_param(probe_count, int, 0444);
MODULE_PARM_DESC(probe_count, "number printed during module init");
static int __init probe_demo_init(void)
{
pr_info("probe_demo: init count=%d\n", probe_count);
return 0;
}
static void __exit probe_demo_exit(void)
{
pr_info("probe_demo: exit\n");
}
module_init(probe_demo_init);
module_exit(probe_demo_exit);
MODULE_LICENSE("GPL");
这段代码是教学示例。它省略了真实驱动常见的设备注册、资源申请、错误回滚和并发同步,但它保留了模块机制的主干:加载阶段有入口函数,卸载阶段有清理函数,参数在加载前后参与状态配置,元数据影响许可、依赖、签名、调试和维护判断。
18.1 Module Init, Exit, and Object Lifetime
模块生命周期的主问题是:一个磁盘上的 .ko 文件什么时候成为内核对象,什么时候允许退出,退出时必须交还哪些资源。.ko 是 kbuild 生成的内核对象文件,加载时由用户态工具把文件内容交给内核,内核再完成格式检查、符号解析、内存分配、重定位、参数解析、初始化入口调用和状态登记。真实入口可从 kernel/module/main.c 追踪,那里承接 init_module() / finit_module() 系统调用并组织模块加载主流程;模块的公共描述结构可从 include/linux/module.h 追踪。
module_init(probe_demo_init) 把 C 函数声明成模块初始化入口。对可加载模块而言,它最终成为加载阶段要调用的入口;对内建代码而言,同一类初始化函数会进入内核启动阶段的 initcall 体系。module_exit(probe_demo_exit) 把 C 函数声明成卸载阶段的清理入口;对内建代码而言,退出函数通常没有普通运行时卸载意义。这个差异说明同一份源码在 obj-y 与 obj-m 两种构建形态下会进入不同生命周期。
模块对象可以用一个状态链理解。下图只覆盖主干路径,省略架构重定位细节、许可检查细节和并发保护细节。
加载阶段的关键边界在 init 返回值。probe_demo_init() 返回 0,内核才把模块推进到可用状态;返回负错误码,加载流程进入回滚路径。这个返回值承担模块状态转换职责,决定模块是否出现在 /proc/modules、lsmod 输出和 /sys/module/<name>/ 目录中。真实驱动的 init 常见动作包括注册字符设备、注册总线驱动、申请 IRQ、创建 workqueue、注册 netdevice 或挂接文件系统类型;任一步失败都必须按已经完成的申请顺序回滚。
卸载阶段的核心约束是引用计数和资源清理。模块已经注册到其它子系统后,外部对象可能通过函数指针、回调、设备文件、sysfs 节点或正在执行的 work 持有它。内核在卸载前要确认模块可移除;模块自己的 exit 函数要撤销注册、停止异步任务、释放内存、注销接口,并让后续路径失去进入该模块代码的入口。生命周期错误通常表现为卸载后仍有回调执行、设备节点仍暴露、定时器仍触发,最终形成 use-after-free、悬空函数指针或内核崩溃。
__init 和 __exit 标注帮助链接器和模块加载器管理代码段。初始化函数成功执行后,初始化段可以释放;退出段只服务卸载路径。读源码时应把这些标注看成生命周期信息:它们告诉你某段代码应在什么阶段可达,也告诉你某个函数指针是否可能在模块进入 LIVE 状态后继续被调用。
对 probe_demo.ko,最小判断顺序是:先找 module_init 确认加载入口,再看入口函数申请了哪些资源,然后找 module_exit 确认对应释放动作,最后检查是否有注册出去的回调、工作项或对象引用跨过退出边界。这个顺序比单独搜索函数名更稳定,因为模块问题通常发生在“入口已经返回、对象仍被外部持有”的阶段。
18.2 insmod, modprobe, lsmod, and Module Dependency Handling
模块工具分成两层:内核提供系统调用和模块状态,用户态工具负责从文件系统找到模块、处理依赖、传递参数和展示结果。insmod 是低层插入工具,通常接收 .ko 路径并把模块交给内核;insmod(8) 的职责范围很窄,依赖处理主要交给调用者。modprobe 是高层工具,按模块名、别名、配置文件和依赖数据库定位模块;modprobe(8) 说明它会使用 modules.dep.bin 等索引来解析依赖。lsmod 是观察工具,按可读格式展示当前已加载模块;lsmod(8) 明确它基于 /proc/modules 展示状态。
insmod 和 modprobe 的差异可以用输入边界判断。insmod ./probe_demo.ko probe_count=3 表示“把这个具体文件交给内核,并附带参数”。它适合临时实验和明确路径的本地模块。modprobe probe_demo probe_count=3 表示“按系统模块目录、别名、配置和依赖关系找到这个名字对应的模块,再安排加载”。它适合发行版驱动、自动硬件发现和有依赖的模块树。
依赖处理的核心文件是 modules.dep / modules.dep.bin。这些文件由 depmod 从已安装模块的元数据中生成,位于 /lib/modules/$(uname -r)/ 下面;modules.dep(5) 把它描述为模块依赖列表,depmod(8) 负责生成依赖信息。源码读解时应把依赖文件看成用户态加载计划的输入,它能说明 modprobe 可能先加载哪些模块;内核是否已经接受这些模块、初始化函数是否已经成功,还要结合加载返回值和内核日志判断。
可以用只读命令建立观察边界。这些命令不加载新模块,适合在普通系统上确认工具回答的问题。
uname -r
lsmod | head
modinfo e1000e | head
modprobe --show-depends e1000e
uname -r 给出当前运行内核版本,帮助定位 /lib/modules/<release>/。lsmod 显示已经处于运行状态的模块。modinfo 读取模块文件元数据,例如 license、alias、depends、parm。modprobe --show-depends 展示高层加载工具准备按什么依赖顺序行动。它们各自回答不同问题:系统版本是什么、当前状态是什么、模块文件声明了什么、用户态加载计划是什么。
真正加载或卸载模块属于高风险操作。下面的命令只适合一次性虚拟机、测试内核或可恢复实验环境;在生产机器上执行可能改变驱动状态、影响设备访问、触发内核错误或留下 taint 记录。
sudo insmod ./probe_demo.ko probe_count=3
sudo rmmod probe_demo
sudo modprobe probe_demo probe_count=3
sudo modprobe -r probe_demo
加载失败时,应按层次分辨错误来源。文件路径错误、模块名错误和依赖索引缺失通常在用户态工具阶段暴露;未知符号、版本 CRC 不匹配、签名拒绝、许可限制和初始化入口返回错误通常在内核接纳阶段暴露。dmesg 常能补充内核侧原因,但日志只是证据入口,最终仍要回到模块文件元数据、导出符号、签名策略、参数解析和初始化返回值来判断。
18.3 Module Parameters and Runtime Customization
模块参数把一部分行为从编译期配置移动到加载期或运行期。probe_count 的定义由 module_param(probe_count, int, 0444) 完成:第一个参数是变量名,第二个参数是类型,第三个参数是 sysfs 权限。真实参数宏位于 include/linux/moduleparam.h,参数解析和类型转换可从 kernel/params.c 追踪。
参数的第一层作用发生在加载前。用户态把 probe_count=3 传给内核,内核在调用 probe_demo_init() 之前解析参数并写入变量。因此初始化函数看到的 probe_count 已经是加载者指定的值。这个顺序决定了参数适合控制初始化阶段的资源规模、调试开关、设备选择、队列长度或兼容模式。
参数的第二层作用发生在 sysfs 暴露阶段。加载成功后,很多模块参数会出现在 /sys/module/<module>/parameters/ 下,权限由宏里的 mode 决定。0444 表示可读;0644 常表示 root 可写。可写参数的 setter 会在运行期执行,所以它需要考虑并发、锁、已经初始化的对象状态和非法值回滚。参数是带类型转换、权限和回调边界的内核控制面,公开程度高于普通全局变量。
参数、Kconfig 和命令行三者处在不同时间点。Kconfig 决定代码是否参与构建,以及以 y、m、n 哪种形态进入目标;模块参数决定已经构建出来的模块在加载时或运行时选择哪种行为;内建代码的参数通常通过内核命令行传入,常见形式是 module_name.parameter=value。读源码时应先判断代码是否存在于当前内核,再判断它是否作为模块加载,最后判断参数是否改变了初始化路径或运行期分支。
参数也会带来维护边界。一个参数如果只影响 init 前的资源申请,修改它通常需要卸载后重新加载模块;一个参数如果通过 sysfs 可写,修改它会和正在运行的回调、锁和对象生命周期交叉。读驱动参数时,应从 module_param 一行继续追踪变量在哪里被读取、是否在热路径中读取、是否受锁保护、错误值是否被拒绝、修改后是否需要重新配置硬件。
probe_count 的读解路径可以压缩成四步:从 module_param 确认变量被导出为参数;从权限确认它是否能运行期修改;从 init 函数确认它是否影响加载阶段;从所有读取点确认它是否影响已经注册出去的对象。这个顺序能把“参数叫什么”转化成“参数改变了哪条路径”。
18.4 Built-In Code vs Loadable Modules
内建代码和可加载模块的核心差异是接入时间。内建代码被链接进 vmlinux,随内核镜像启动,并通过 initcall 等启动阶段入口完成初始化。可加载模块被构建成 .ko,在运行期由用户态请求加载,内核接纳后才进入 LIVE 状态。上一章讲过 obj-y 与 obj-m 的构建差异;本章关心的是同一份源码在两种形态下的启动顺序、符号边界、调试证据、部署方式和安全影响。
可以用同一组维度比较两种形态。
| 维度 | 内建代码 | 可加载模块 |
|---|---|---|
| 接入时间 | 内核启动期间进入 | 系统运行后按需进入 |
| 构建产物 | 链接进 vmlinux 或启动镜像 | 生成 .ko 文件 |
| 初始化路径 | initcall 阶段执行 | init_module() / finit_module() 后执行模块 init |
| 参数来源 | 常见为内核命令行 | insmod / modprobe 参数和 sysfs 参数 |
| 卸载能力 | 普通运行期无卸载路径 | 满足引用和清理条件时可卸载 |
| 依赖处理 | 链接顺序和 initcall 顺序更关键 | modprobe 依赖数据库和导出符号更关键 |
| 部署方式 | 更新内核镜像或 initramfs | 安装匹配当前内核的模块文件 |
| 风险表现 | 启动早期失败可能影响引导 | 运行期失败可能影响正在工作的系统 |
符号可见性是模块读解中的常见边界。模块只能链接到内核导出的符号;导出符号由 EXPORT_SYMBOL、EXPORT_SYMBOL_GPL 等机制提供,构建和 modpost 阶段会检查外部引用。内核文档 Building External Modules 说明 Module.symvers 记录导出符号以及 CONFIG_MODVERSIONS 相关 CRC;这说明模块和内核之间没有稳定的源码级内部 ABI 承诺,外部模块通常要针对目标内核版本重新构建。
调试证据也不同。内建代码的问题常出现在启动日志、initcall 排序、早期参数和内核镜像符号里;可加载模块的问题常出现在 modprobe 输出、dmesg 日志、lsmod 状态、/sys/module/ 参数、modinfo 元数据和 taint 标记里。读者排查一个模块问题时,应先判断代码形态:如果它是 y,就从启动顺序和内核命令行找证据;如果它是 m,就从模块文件、依赖数据库、加载日志和运行状态找证据。
部署边界决定工程取舍。内建驱动适合早期启动必需的设备路径,例如根文件系统所需的存储控制器,或者安全策略要求启动时已经存在的基础能力。可加载模块适合设备种类多、按需使用、实验迭代或发行版分发场景。发行版常通过 initramfs 在早期用户空间加载根设备所需模块,这在启动体验上接近“早期可用”,在工程形态上仍然是运行期加载 .ko。
18.5 Engineering Insight: Modules Are Runtime Extension Points with Kernel-Level Risk
模块的工程价值来自运行期扩展:不重建整个内核镜像,也能让新的驱动、文件系统、协议、调试设施或实验功能进入内核。它的工程风险来自同一个事实:模块进入后运行在内核态,和内建代码共享地址空间、权限、锁、内存分配器、调度上下文和故障后果。一个错误模块可以破坏任意内核数据结构,触发 panic,污染调试证据,或者让设备进入无法恢复的状态。
安全边界首先体现在签名和策略上。内核文档 Kernel module signing facility 说明模块签名在安装时生成、加载时由内核验证;CONFIG_MODULE_SIG_FORCE 或 module.sig_enforce=1 可使内核拒绝无有效签名的模块。签名证明“这个模块来自可信签名链并且内容未被签名后篡改”,它不证明模块逻辑正确,也不证明参数选择安全。
维护边界体现在 taint 状态上。内核文档 Tainted kernels 说明某些事件会让运行中内核带上 taint 标记,例如专有模块、外部构建模块、警告等。taint 是故障分析信号:它告诉维护者当前内核状态包含影响诊断可信度的因素。模块卸载后 taint 仍可能保留,因为已经发生过的内核态代码执行可能改变了系统状态和后续故障证据。
工程上评估一个模块,至少要沿五条线索判断。第一,看它是否匹配当前内核版本和配置;第二,看它依赖哪些导出符号和其它模块;第三,看它的初始化函数申请了哪些资源,失败路径如何回滚;第四,看它的运行期入口暴露给哪些子系统或用户态接口;第五,看它的卸载路径能否停止所有异步执行并解除所有外部引用。这个判断顺序把模块从“一个文件”还原成“运行中内核对象”。
probe_demo.ko 是安全边界的缩小模型。它只打印日志、暴露只读参数和注册 init/exit,因此风险集中在加载、参数解析和卸载回调本身。真实驱动会把风险扩展到硬件寄存器、DMA、IRQ、workqueue、netdev、block queue、file operations 和 sysfs 属性。模块机制给了这些对象运行期进入内核的通道,所以每个注册动作都要有对应撤销动作,每个外部入口都要有生命周期保护。
本章最终建立的判断是:模块是一种内核态运行期接入路径,由构建、加载、参数、依赖、符号、签名和生命周期共同定义。读源码时,先找模块入口,再追资源申请和注册对象,接着判断参数和依赖如何改变路径,最后检查卸载、签名和 taint 边界。这个顺序能帮助读者把 insmod 成功、modprobe 成功、init 成功、运行安全和可卸载这几个结论分开判断。
最小自检任务
请根据本章正文,分析一个名为 net_probe.ko 的教学模块。已知它有 module_init(net_probe_init)、module_exit(net_probe_exit)、参数 queue_depth,并且加载时需要先加载 helper_core.ko。请按下面格式回答:
insmod ./net_probe.ko queue_depth=64和modprobe net_probe queue_depth=64在依赖处理上有什么差异。queue_depth至少应该从哪三个位置判断它改变了什么路径。- 如果
net_probe_exit()忘记注销一个已经注册出去的回调,为什么卸载成功仍不足以推出系统安全。 - 如果该模块因为签名策略被拒绝加载,这个失败发生在“用户态依赖计划”还是“内核接纳阶段”。
答案要点
insmod面向具体.ko文件,依赖处理主要由调用者负责;modprobe面向模块名和系统模块索引,会根据依赖数据库先安排helper_core.ko等依赖模块。- 先看
module_param(queue_depth, ...)的类型和权限,再看初始化函数是否在加载前读取它,最后看运行期路径是否继续读取它以及是否有锁、范围检查和错误值处理。 - 卸载命令返回成功只说明卸载路径走完了当前可见检查;注册出去的回调如果仍能被其它子系统调用,就可能在模块代码释放后形成悬空函数指针或 use-after-free。
- 签名拒绝属于内核接纳阶段。
modprobe可以形成加载计划并提交模块文件,但最终是否接受未签名或签名无效的模块由内核签名策略决定。
本章知识点总结
- 模块入口:
module_init把函数接入加载阶段,module_exit把函数接入卸载清理阶段。 - 生命周期:模块从
.ko文件进入加载态,初始化成功后进入运行态,卸载时经过清理和引用检查后释放。 - 初始化返回:初始化函数返回
0才表示模块完成接纳,负错误码会触发加载回滚。 - 资源配对:模块入口申请或注册的资源,应在失败路径和退出路径中按依赖顺序撤销。
- 工具分层:
insmod面向具体文件,modprobe面向模块名、别名、配置和依赖数据库,lsmod展示当前加载状态。 - 依赖数据库:
modules.dep和modules.dep.bin支撑用户态加载计划,内核接纳结果还要结合加载返回值和日志判断。 - 参数时机:模块参数在初始化前解析,也可能通过 sysfs 暴露为运行期控制面。
- 参数边界:参数读解要同时看类型、权限、读取位置、锁保护和非法值处理。
- 内建差异:内建代码随内核镜像启动,可加载模块在运行期通过系统调用进入内核。
- 符号边界:模块依赖内核导出符号,
Module.symvers和 modpost 帮助检查符号与版本信息。 - 签名策略:模块签名由内核在加载时验证,签名有效只证明来源和完整性,不证明逻辑正确。
- taint 信号:taint 标记记录影响故障诊断可信度的事件,模块卸载后相关状态仍可能保留。
- 风险判断:模块是运行期扩展点,也是内核态代码入口,错误会影响整个系统。
- 阅读顺序:先找入口,再追资源和注册对象,接着判断参数和依赖,最后检查卸载、签名和 taint 边界。