这一篇在干嘛?
Linux 是宏内核,但借助”模块”机制,它在不牺牲性能的前提下获得了微内核式的灵活性:文件系统、驱动、网络协议都能在系统运行时动态插入和拔出。本章讲透模块的方方面面——什么该做成模块什么不该、内核如何管理 module 对象与引用计数、符号如何在内核与模块之间往来、insmod/rmmod/modprobe 各自干了什么、以及”按需自动加载”是怎么实现的。写驱动、排查 lsmod、理解 CONFIG_…=m 的含义,都绕不开这一章。
做成模块还是静态链接 | 模块许可证 | 模块的实现与使用计数 | 符号导出 | 模块依赖 | insmod 与 rmmod | 按需自动加载
做成模块还是静态链接
正如第 1 章所说,模块是 Linux 的一张”秘方”:它有效实现了微内核的许多理论优点,却不引入微内核的性能代价。微内核把驱动、文件系统放进用户态进程,模块间通信要付出消息传递的开销;模块则直接链接进内核地址空间,性能与静态代码毫无二致,但可以随时装卸。
当系统程序员想给 Linux 内核增加新功能时,面对一个基本抉择:把新代码编译成模块,还是静态链接进内核?
一般规则是:优先做成模块。因为模块可以按需链接(后面会讲),内核不必被数百个很少用到的功能撑得臃肿不堪。Linux 内核几乎所有高层组件——文件系统、设备驱动、可执行格式、网络层次等——都能编译成模块。发行版对模块的运用极其充分:比如发行版会在某个目录里放几十个声卡驱动模块,而一台具体机器上真正被加载的只有一个,其余的都是”备胎”。
但有些内核代码必须静态链接——也就是说,相应组件要么包含在内核里,要么干脆不编译。典型情形是:该组件需要修改某些静态链接的数据结构或函数。看两个例子:
- 要往进程描述符里加新字段的组件。链接一个模块无法改变已经定义好的数据结构(如 task_struct):就算模块用它自己修改过的版本,所有静态链接的代码看到的仍是旧版本,数据很容易被破坏。一个折中方案是”静态地”把新字段加进进程描述符,无论组件怎么链接都可用;但如果组件永远不被使用,每个进程描述符里重复的这些额外字段就是纯粹的内存浪费。所以若新组件会让进程描述符大很多,只有把它静态链接进内核,性能才更好。
- 要替换静态链接代码的组件。这种组件显然不可能编译成模块,因为内核无法在链接模块时改写 RAM 里已有的机器码。比如不可能链接一个改变页框分配方式的模块——伙伴系统的函数是永远静态链接的。
内核在管理模块上有两个关键任务:
- 保证可达性:让内核其余部分能找到模块的全局符号(比如模块主函数的入口),同时模块也要知道内核里和其他模块里的符号地址。也就是说,所有引用都在模块链接时一次性解析完毕;
- 跟踪使用情况:保证任何模块在被其他模块或内核其他部分使用时不会被卸载。用一个简单的引用计数来记录每个模块的使用情况。
模块许可证
Linux 内核的许可证是 GPL 第 2 版。它对用户和产业界用源码做什么相当宽松,但严格禁止把从 Linux 派生的——或严重依赖 Linux 代码的——源码以非 GPL 许可证发布。内核开发者要的就是这一点:他们的代码以及一切派生代码,将永远对全体用户自由可用。
模块却对这一模式构成威胁:有人可能只以二进制形式发布 Linux 模块——比如厂商以二进制模块分发自家硬件的驱动,如今这种做法并不少见。理论上,二进制模块可能显著改变 Linux 内核的特性和行为,从而实际把一个 Linux 派生内核变成商业产品。
因此二进制模块在 Linux 内核开发者社区里不受欢迎,模块机制的实现也反映了这种态度。每个模块开发者应在模块源码里用 MODULE_LICENSE 宏声明许可证类型。如果许可证与 GPL 不兼容(或者干脆没声明),模块将无法使用内核的许多核心函数和数据结构。此外,使用非 GPL 许可证的模块会”污染”(taint)内核——此后内核里出现的任何疑似 bug,内核开发者都不再受理。
这条规矩的现实意义(背景补充):当你带着 bug 报告去找社区时,对方第一句往往就是问 dmesg 里有没有 “tainted” 标记——二进制驱动加载过的内核,报了 bug 也没人管。
模块的实现与使用计数
模块以 ELF 目标文件的形式存放在文件系统里,通过执行 insmod 程序链接到内核(详见”insmod 与 rmmod”一节)。对每个模块,内核都会分配一个内存区,其中包含:
- 一个模块对象(module object);
- 一个以 null 结尾的字符串,表示模块名(所有模块的名字必须唯一);
- 实现模块功能的代码。
模块对象描述一个模块,其关键字段如下:
| 类型 | 字段名 | 说明 |
|---|---|---|
| enum module_state | state | 模块的内部状态 |
| struct list_head | list | 模块链表的指针 |
| char [60] | name | 模块名 |
| struct module_kobject | mkobj | 包含一个 kobject 结构和指向本模块对象的指针 |
| struct module_param_attrs * | param_attrs | 指向模块参数描述符数组 |
| const struct kernel_symbol * | syms | 指向导出符号数组 |
| unsigned int | num_syms | 导出符号的个数 |
| const unsigned long * | crcs | 导出符号的 CRC 校验值数组 |
| const struct kernel_symbol * | gpl_syms | 指向 GPL 导出符号数组 |
| unsigned int | num_gpl_syms | GPL 导出符号的个数 |
| const unsigned long * | gpl_crcs | GPL 导出符号的 CRC 数组 |
| unsigned int | num_exentries | 模块异常表的表项数 |
| const struct exception_table_entry * | extable | 指向模块的异常表 |
| int (*)(void) | init | 模块的初始化方法 |
| void * | module_init | 为模块初始化分配的动态内存区指针 |
| void * | module_core | 为模块核心函数和数据结构分配的动态内存区指针 |
| unsigned long | init_size | 初始化所需动态内存区的大小 |
| unsigned long | core_size | 核心函数与数据结构所需动态内存区的大小 |
所有模块对象被一条双向循环链表收集起来,链表头存放在 modules 变量中,指向相邻元素的指针存放在每个模块对象的 list 字段里。
state 字段编码模块的内部状态,可以是三种之一:MODULE_STATE_LIVE(模块活跃)、MODULE_STATE_COMING(模块正在初始化)、MODULE_STATE_GOING(模块正在被移除)。
正如第 10 章”动态地址检查:修正代码”一节提到的,每个模块有自己的异常表(exception table),包含该模块修正代码(fixup code)的地址(如果有的话)。链接模块时异常表被复制进 RAM,起始地址存进模块对象的 extable 字段。
每个模块有多个使用计数器
每个模块都有一组使用计数器——每个 CPU 一个——存放在相应模块对象的 ref 字段里。涉及模块功能的操作开始时递增计数器,操作结束时递减。只有当所有计数器之和为 0 时,模块才允许被卸载。
为什么每 CPU 一个计数器而不是全局一个?因为在多处理器上,全局计数器会成为所有 CPU 竞争的”热点”缓存行,加锁开销大;每 CPU 计数器让增减操作都打在自己的缓存上,无锁无竞争,只在需要判断总数时才求和。
举例来说,假设 MS-DOS 文件系统层被编译成了模块并在运行期链接。初始时模块使用计数为 0。用户挂载一个 MS-DOS 软盘时,某一个计数器加 1;用户卸载软盘时,某一个计数器——可以不是之前加 1 的那个——减 1。模块的总使用计数就是所有 CPU 计数器的总和。
符号导出
链接模块时,模块目标代码里对全局内核符号(变量和函数)的所有引用都必须替换成合适的地址。这个操作与编译用户态程序时链接器的工作非常相似(见第 20 章”库”一节),它被委托给 insmod 外部程序完成。
内核用一些特殊的内核符号表存放”可被模块访问的符号”及其地址,它们位于内核代码段的三个节(section)里:
- __kstrtab 节:符号的名字;
- __ksymtab 节:可被所有类型模块使用的符号地址;
- __ksymtab_gpl 节:可被GPL 兼容许可证模块使用的符号地址。
在静态链接的内核代码中使用 EXPORT_SYMBOL 宏和 EXPORT_SYMBOL_GPL 宏,会分别强制 C 编译器把指定符号加进 __ksymtab 和 __ksymtab_gpl 节。
注意:符号表里只包含确实被某个现有模块使用的内核符号。如果系统程序员需要在某个模块里访问一个尚未导出的内核符号,他只要在 Linux 源码里为它加上相应的 EXPORT_SYMBOL_GPL 宏即可——当然,为非 GPL 许可证的模块导出新符号在法律上是不允许的。
已链接的模块也能导出自己的符号供其他模块使用。模块的符号表放在模块代码段的 __ksymtab、__ksymtab_gpl 和 __kstrtab 节里;要导出符号子集,同样使用上述两个宏。模块的导出符号在链接时被复制进两个内存数组,地址分别存进模块对象的 syms 和 gpl_syms 字段。
模块依赖
模块 B 可以引用另一个模块 A 导出的符号——这时我们说 B 装载在 A 之上,或等价地说 A 被 B 使用。要链接模块 B,模块 A 必须已经链接,否则 B 里对 A 导出符号的引用无法正确解析。简言之,模块之间存在依赖关系。
模块对象 A 的 modules_which_use_me 字段是一条依赖链表的表头,链表里是所有使用 A 的模块;链表的每个元素是一个小的 module_use 描述符,包含指向链表相邻元素的指针和指向相应模块对象的指针——在上例中,一条指向 B 模块对象的 module_use 描述符会出现在 A 的 modules_which_use_me 链表里。每当有模块装载到 A 之上,这条链表都要动态更新。A 的依赖链表非空时,A 不能被卸载。
除了 A 和 B,当然还可能有另一个模块 C 装载在 B 之上,以此类推。**模块堆叠(stacking)**是模块化内核源代码、加速其开发的有效手段:每一层只关心紧邻的下层暴露的接口。
insmod 与 rmmod
用户可以执行 insmod 外部程序,把一个模块链接进正在运行的内核。insmod 执行下列操作:
- 从命令行读取要链接的模块名;
- 在系统目录树中定位包含模块目标代码的文件,通常在 /lib/modules 之下的某个子目录里;
- 从磁盘读入包含模块目标代码的文件;
- 调用 init_module() 系统调用,传入三个参数:包含模块目标代码的用户态缓冲区地址、目标代码的长度、包含 insmod 程序参数的用户态内存区;
- 结束。
真正的重活全在 sys_init_module() 服务例程里,它的主要操作按顺序是:
- 检查用户是否被允许链接模块——当前进程必须具有 CAP_SYS_MODULE 能力。凡是要给能访问系统全部数据和进程的内核添加功能,安全都是头等大事;
- 为模块目标代码分配一个临时内存区,把系统调用第一个参数所指用户态缓冲区里的数据复制进去;
- 检查这块内存里的数据确实是模块的 ELF 目标文件,否则返回错误码;
- 为传给 insmod 的参数分配内存区,并用第三个参数所指用户态缓冲区的数据填充它;
- 遍历 modules 链表,通过比较模块对象 name 字段确认该模块尚未被链接(模块名唯一);
- 为模块的核心可执行代码分配内存区,用模块相应节的内容填充;
- 为模块的初始化代码分配内存区,用模块相应节的内容填充;
- 确定新模块 module 对象的地址:这个对象的一份镜像包含在模块 ELF 文件代码段的 gnu.linkonce.this_module 节里,因此它就在第 6 步填充的内存区之中;
- 把第 6、7 步分配的内存区地址存进模块对象的 module_code 和 module_init 字段;
- 初始化 module 对象的 modules_which_use_me 链表;把模块的所有引用计数清零——唯独正在执行的这个 CPU 的计数器置 1(防止自己刚链上就被卸掉);
- 根据模块声明的许可证类型,设置模块对象的 license_gplok 标志;
- 利用内核符号表和模块符号表重定位模块的目标代码——把所有外部和全局符号的出现处替换成相应的逻辑地址偏移;
- 初始化模块对象的 syms 和 gpl_syms 字段,让它们指向模块在内存中的导出符号表;
- 模块的异常表(见第 10 章)在 ELF 文件的 __ex_table 节里,已在第 6 步被复制进内存区:把它的地址存进 extable 字段;
- 解析 insmod 程序的参数,据此设置相应模块变量的值(背景补充:这就是
insmod foo.ko debug=1这类写法生效的机制); - 注册模块对象 mkobj 字段里的 kobject,于是 sysfs 特殊文件系统的模块目录下出现该模块的新子目录(见第 13 章”Kobjects”);
- 释放第 2 步分配的临时内存区;
- 把模块对象加入 modules 链表;
- 把模块状态设为 MODULE_STATE_COMING;
- 如果定义了模块对象的 init 方法,执行它(对应模块源码里的 module_init() 注册的函数);
- 把模块状态设为 MODULE_STATE_LIVE;
- 返回 0(成功)。
要卸载模块,用户执行 rmmod 外部程序,它做的事简单得多:
- 从命令行读取要卸载的模块名;
- 打开 /proc/modules 文件——它列出内核已链接的所有模块——确认要移除的模块确实已链接;
- 调用 delete_module() 系统调用,传入模块名;
- 结束。
接着 sys_delete_module() 服务例程执行下列主要操作:
- 检查用户是否被允许卸载模块(同样需要 CAP_SYS_MODULE 能力);
- 把模块名复制进内核缓冲区;
- 遍历 modules 链表找到该模块的模块对象;
- 检查该模块的 modules_which_use_me 依赖链表:非空则返回错误码——还有别的模块压在它上面呢;
- 检查模块状态:不是 MODULE_STATE_LIVE 就返回错误码;
- 若模块有自定义 init 方法,还要检查它是否也有自定义 exit 方法;没定义 exit 方法的模块不应该被卸载,返回错误码;
- 为避免竞争条件,停止系统中除当前 CPU 之外所有 CPU 的活动;
- 把模块状态设为 MODULE_STATE_GOING;
- 若模块所有引用计数之和大于 0,返回错误码——还有人在用;
- 若定义了 exit 方法则执行它;
- 把模块对象从 modules 链表移除,并从 sysfs 特殊文件系统注销该模块;
- 从它正在使用的那些模块的依赖链表里移除自己;
- 释放包含模块可执行代码、模块对象以及各种符号表和异常表的内存区;
- 返回 0(成功)。
常见坑:rmmod 失败的三大原因
rmmod 报错的常见原因都能在上面流程里找到出处:一是依赖未清(第 4 步,modules_which_use_me 非空,要先卸上层模块);二是使用计数非零(第 9 步,比如设备还被 mount 或打开着);三是模块没定义 exit 方法(第 6 步,这种模块设计上就不允许卸载)。看到 “Module xxx is in use” 时先查 lsmod 的 Used by 列,而不是反复重试。
按需自动加载
模块可以在其功能被请求时自动链接,事后又被自动移除。
设想 MS-DOS 文件系统没有被链接(静态、动态都没有)。用户尝试挂载一个 MS-DOS 文件系统时,mount() 系统调用本该因 MS-DOS 不在已注册文件系统的 file_systems 链表里而失败并返回错误码。但如果配置内核时启用了自动链接模块的支持,Linux 会尝试链接 MS-DOS 模块,然后重新扫描已注册文件系统链表。模块链接成功的话,mount() 系统调用就能继续执行,仿佛 MS-DOS 文件系统从头就在似的。
modprobe 程序
为了自动链接模块,内核创建一个内核线程去执行 modprobe 外部程序,由它处理模块依赖带来的麻烦。依赖关系前面讨论过:一个模块可能需要一个或多个其他模块,而这些模块又可能需要更多模块。例如 MS-DOS 模块需要另一个叫 fat 的模块——它包含所有基于文件分配表(FAT)的文件系统共用的代码。因此请求 MS-DOS 模块时,如果 fat 模块不在,就必须自动链接进内核。解析依赖和寻找模块这类活儿最适合放在用户态做,因为它需要在文件系统里定位和访问模块目标文件。
modprobe 与 insmod 相似,也链接命令行指定的模块;但它还会递归链接指定模块所使用的所有模块。比如用户用 modprobe 链接 MS-DOS 模块,程序会(如有必要)先链接 fat 模块,再链接 MS-DOS 模块。实际上 modprobe 只负责检查模块依赖;每个模块的真正链接由它 fork 新进程并执行 insmod 来完成。
modprobe 从哪里知道模块依赖?系统启动时会执行另一个外部程序 depmod,它查看为当前内核编译的所有模块(通常存放在 /lib/modules 目录),把所有模块依赖写进名为 modules.dep 的文件。modprobe 只要把该文件里的信息与 /proc/modules 文件给出的已链接模块清单对照即可。
request_module() 函数
某些情况下,内核会调用 request_module() 函数尝试自动链接模块。还是看挂载 MS-DOS 文件系统的例子:如果 get_fs_type() 函数发现该文件系统未注册,就调用 request_module(),寄希望于 MS-DOS 被编译成了模块。request_module() 成功链接所请求的模块后,get_fs_type() 就能像模块一直都在那样继续执行。当然这不总会成功——MS-DOS 模块可能根本没被编译——这时 get_fs_type() 只能返回错误码。
request_module() 接收要链接的模块名作为参数。它执行 kernel_thread() 创建一个新内核线程并等待其结束;该内核线程同样以模块名为参数,调用 execve() 系统执行 modprobe 外部程序并把模块名传给它;modprobe 随后把请求的模块连同它依赖的所有模块一并链接进内核。
整条自动加载链路串起来就是:
mount(msdos) → get_fs_type() 发现未注册
→ request_module("msdos") → kernel_thread() → execve("modprobe msdos")
→ 查 modules.dep 得知依赖 → insmod fat → insmod msdos
→ 回到 get_fs_type() 重扫 file_systems → mount 继续
通关标准
能完整叙述 insmod 的工作流程(读文件 → init_module 系统调用 → sys_init_module 里权限检查、重定位符号、异常表、执行 init 方法、状态 LIVE→…)和 rmmod 的约束(依赖链表空、计数总和为 0、有 exit 方法),能解释 EXPORT_SYMBOL 与 EXPORT_SYMBOL_GPL 的区别及 MODULE_LICENSE/tainted 的含义,并能画出 mount 触发 modprobe 自动加载 fat+msdos 的调用链。
哪些内核组件不适合编译成模块?为什么?
需要修改静态链接数据结构或函数的组件不适合。往 task_struct 这类已定义结构里加字段的组件,做成模块后静态代码仍看旧布局,会造成数据破坏;需要替换静态链接代码(如伙伴系统的页框分配函数)的组件更不可能做成模块,因为内核无法在运行期改写 RAM 里已有的机器码。
MODULE_LICENSE 声明与 license_gplok 标志有什么关系?非 GPL 模块会怎样?
insmod 链接时,sys_init_module() 根据模块源码中 MODULE_LICENSE 宏声明的许可证类型设置模块对象的 license_gplok 标志。GPL 兼容的模块可以使用 __ksymtab_gpl 里的符号和内核更多核心函数;非 GPL 或未声明许可证的模块用不了这些,且会”污染”内核——此后报的内核 bug 社区不予受理。
为什么模块使用计数器要每个 CPU 一个?模块何时才能卸载?
每个模块有一组使用计数器、每 CPU 一个,涉及模块功能的操作开始时递增、结束时递减(增和减不要求发生在同一个 CPU 上)。设计动机是避免多处理器上所有 CPU 竞争同一全局计数器带来的锁开销与缓存争用。只有当所有 CPU 的计数器总和为 0、依赖链表 modules_which_use_me 为空、状态为 LIVE 且定义了 exit 方法时,模块才允许被卸载。
内核符号表的三个节各存放什么?模块如何导出自己的符号?
__kstrtab 存符号名,__ksymtab 存可被所有模块使用的符号地址,__ksymtab_gpl 存仅供 GPL 兼容模块使用的符号地址;EXPORT_SYMBOL/EXPORT_SYMBOL_GPL 宏在内核编译时把符号分别登记进后两个节。模块用同样的宏从自己的代码段节里导出符号,链接时这些符号被复制进内存数组,地址存入模块对象的 syms 与 gpl_syms 字段,供堆叠其上的其他模块解析引用。
从 mount 一个未注册文件系统到模块自动加载完成,中间经过哪些环节?
mount() 发现文件系统不在 file_systems 链表里,get_fs_type() 调用 request_module()(模块名作参数);request_module() 用 kernel_thread() 创建内核线程并等待,线程 execve() 执行 modprobe;modprobe 对照 depmod 启动时生成的 modules.dep 文件与 /proc/modules 的已链接清单,解析出依赖(如 msdos 依赖 fat),fork 进程逐个执行 insmod;全部链接成功后内核重新扫描 file_systems,get_fs_type() 找到 msdos,mount() 继续正常执行。