这一篇在干嘛?

Linux 是宏内核,但借助”模块”机制,它在不牺牲性能的前提下获得了微内核式的灵活性:文件系统、驱动、网络协议都能在系统运行时动态插入和拔出。本章讲透模块的方方面面——什么该做成模块什么不该、内核如何管理 module 对象与引用计数、符号如何在内核与模块之间往来、insmod/rmmod/modprobe 各自干了什么、以及”按需自动加载”是怎么实现的。写驱动、排查 lsmod、理解 CONFIG_…=m 的含义,都绕不开这一章。

做成模块还是静态链接 | 模块许可证 | 模块的实现与使用计数 | 符号导出 | 模块依赖 | insmod 与 rmmod | 按需自动加载

做成模块还是静态链接

正如第 1 章所说,模块是 Linux 的一张”秘方”:它有效实现了微内核的许多理论优点,却不引入微内核的性能代价。微内核把驱动、文件系统放进用户态进程,模块间通信要付出消息传递的开销;模块则直接链接进内核地址空间,性能与静态代码毫无二致,但可以随时装卸。

当系统程序员想给 Linux 内核增加新功能时,面对一个基本抉择:把新代码编译成模块,还是静态链接进内核?

一般规则是:优先做成模块。因为模块可以按需链接(后面会讲),内核不必被数百个很少用到的功能撑得臃肿不堪。Linux 内核几乎所有高层组件——文件系统、设备驱动、可执行格式、网络层次等——都能编译成模块。发行版对模块的运用极其充分:比如发行版会在某个目录里放几十个声卡驱动模块,而一台具体机器上真正被加载的只有一个,其余的都是”备胎”。

但有些内核代码必须静态链接——也就是说,相应组件要么包含在内核里,要么干脆不编译。典型情形是:该组件需要修改某些静态链接的数据结构或函数。看两个例子:

  1. 要往进程描述符里加新字段的组件。链接一个模块无法改变已经定义好的数据结构(如 task_struct):就算模块用它自己修改过的版本,所有静态链接的代码看到的仍是旧版本,数据很容易被破坏。一个折中方案是”静态地”把新字段加进进程描述符,无论组件怎么链接都可用;但如果组件永远不被使用,每个进程描述符里重复的这些额外字段就是纯粹的内存浪费。所以若新组件会让进程描述符大很多,只有把它静态链接进内核,性能才更好。
  2. 要替换静态链接代码的组件。这种组件显然不可能编译成模块,因为内核无法在链接模块时改写 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_statestate模块的内部状态
struct list_headlist模块链表的指针
char [60]name模块名
struct module_kobjectmkobj包含一个 kobject 结构和指向本模块对象的指针
struct module_param_attrs *param_attrs指向模块参数描述符数组
const struct kernel_symbol *syms指向导出符号数组
unsigned intnum_syms导出符号的个数
const unsigned long *crcs导出符号的 CRC 校验值数组
const struct kernel_symbol *gpl_syms指向 GPL 导出符号数组
unsigned intnum_gpl_symsGPL 导出符号的个数
const unsigned long *gpl_crcsGPL 导出符号的 CRC 数组
unsigned intnum_exentries模块异常表的表项数
const struct exception_table_entry *extable指向模块的异常表
int (*)(void)init模块的初始化方法
void *module_init为模块初始化分配的动态内存区指针
void *module_core为模块核心函数和数据结构分配的动态内存区指针
unsigned longinit_size初始化所需动态内存区的大小
unsigned longcore_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 执行下列操作:

  1. 从命令行读取要链接的模块名;
  2. 在系统目录树中定位包含模块目标代码的文件,通常在 /lib/modules 之下的某个子目录里;
  3. 从磁盘读入包含模块目标代码的文件;
  4. 调用 init_module() 系统调用,传入三个参数:包含模块目标代码的用户态缓冲区地址、目标代码的长度、包含 insmod 程序参数的用户态内存区;
  5. 结束。

真正的重活全在 sys_init_module() 服务例程里,它的主要操作按顺序是:

  1. 检查用户是否被允许链接模块——当前进程必须具有 CAP_SYS_MODULE 能力。凡是要给能访问系统全部数据和进程的内核添加功能,安全都是头等大事;
  2. 为模块目标代码分配一个临时内存区,把系统调用第一个参数所指用户态缓冲区里的数据复制进去;
  3. 检查这块内存里的数据确实是模块的 ELF 目标文件,否则返回错误码;
  4. 为传给 insmod 的参数分配内存区,并用第三个参数所指用户态缓冲区的数据填充它;
  5. 遍历 modules 链表,通过比较模块对象 name 字段确认该模块尚未被链接(模块名唯一);
  6. 为模块的核心可执行代码分配内存区,用模块相应节的内容填充;
  7. 为模块的初始化代码分配内存区,用模块相应节的内容填充;
  8. 确定新模块 module 对象的地址:这个对象的一份镜像包含在模块 ELF 文件代码段的 gnu.linkonce.this_module 节里,因此它就在第 6 步填充的内存区之中;
  9. 把第 6、7 步分配的内存区地址存进模块对象的 module_code 和 module_init 字段;
  10. 初始化 module 对象的 modules_which_use_me 链表;把模块的所有引用计数清零——唯独正在执行的这个 CPU 的计数器置 1(防止自己刚链上就被卸掉);
  11. 根据模块声明的许可证类型,设置模块对象的 license_gplok 标志;
  12. 利用内核符号表和模块符号表重定位模块的目标代码——把所有外部和全局符号的出现处替换成相应的逻辑地址偏移;
  13. 初始化模块对象的 syms 和 gpl_syms 字段,让它们指向模块在内存中的导出符号表;
  14. 模块的异常表(见第 10 章)在 ELF 文件的 __ex_table 节里,已在第 6 步被复制进内存区:把它的地址存进 extable 字段;
  15. 解析 insmod 程序的参数,据此设置相应模块变量的值(背景补充:这就是 insmod foo.ko debug=1 这类写法生效的机制);
  16. 注册模块对象 mkobj 字段里的 kobject,于是 sysfs 特殊文件系统的模块目录下出现该模块的新子目录(见第 13 章”Kobjects”);
  17. 释放第 2 步分配的临时内存区;
  18. 把模块对象加入 modules 链表;
  19. 把模块状态设为 MODULE_STATE_COMING
  20. 如果定义了模块对象的 init 方法,执行它(对应模块源码里的 module_init() 注册的函数);
  21. 把模块状态设为 MODULE_STATE_LIVE
  22. 返回 0(成功)。

要卸载模块,用户执行 rmmod 外部程序,它做的事简单得多:

  1. 从命令行读取要卸载的模块名;
  2. 打开 /proc/modules 文件——它列出内核已链接的所有模块——确认要移除的模块确实已链接;
  3. 调用 delete_module() 系统调用,传入模块名;
  4. 结束。

接着 sys_delete_module() 服务例程执行下列主要操作:

  1. 检查用户是否被允许卸载模块(同样需要 CAP_SYS_MODULE 能力);
  2. 把模块名复制进内核缓冲区;
  3. 遍历 modules 链表找到该模块的模块对象;
  4. 检查该模块的 modules_which_use_me 依赖链表:非空则返回错误码——还有别的模块压在它上面呢;
  5. 检查模块状态:不是 MODULE_STATE_LIVE 就返回错误码;
  6. 若模块有自定义 init 方法,还要检查它是否也有自定义 exit 方法;没定义 exit 方法的模块不应该被卸载,返回错误码;
  7. 为避免竞争条件,停止系统中除当前 CPU 之外所有 CPU 的活动
  8. 把模块状态设为 MODULE_STATE_GOING;
  9. 若模块所有引用计数之和大于 0,返回错误码——还有人在用;
  10. 若定义了 exit 方法则执行它
  11. 把模块对象从 modules 链表移除,并从 sysfs 特殊文件系统注销该模块;
  12. 从它正在使用的那些模块的依赖链表里移除自己;
  13. 释放包含模块可执行代码、模块对象以及各种符号表和异常表的内存区;
  14. 返回 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 的调用链。