Linux驱动基础(一):驱动模块机制的设计与实现

51 阅读13分钟

日期:2026-09-04 · 参考源码版本:Linux 5.x

版权声明 本文为原创技术文章,著作权归作者所有。未经作者书面授权,严禁任何形式的转载、摘编、复制、建立镜像、商业使用

写在前面

每个内核驱动开发者都写过这样几行代码:

MODULE_LICENSE("GPL v2");
module_init(hello_init);
module_exit(hello_exit);

它们看起来只是"填表"式的模板,但实际上,这几行代码背后是一套完整的工程方案:如何让同一份驱动源码,既能编译成运行时可插拔的 .ko 模块,又能静态编进内核镜像,而源码一行不改

本文围绕 include/linux/module.hinclude/linux/moduleparam.h,剖析 MODULE_* 系列模块信息宏(声明许可证、作者、所需固件等模块描述信息)与 module_init/module_exit 的实现。

阅读本文你将了解:

  • MODULE_LICENSE 等模块信息宏如何借 ELF 段在编译期"登记"信息,谁在何时消费它
  • 为什么同一套 module_init 宏,在内建与模块两种形态下展开成完全不同的代码
  • init_module 这个固定函数名背后的 ABI 约定来自哪里(答案在构建工具 modpost,而非内核)
  • MODULE_DEVICE_TABLE 一个"别名声明"如何串起驱动的自动加载
  • __inittest__CFI_ADDRESSABLE 这些冷门部件分别在防什么错

一、问题:一份源码,两种形态

Linux 驱动的分发形态有两种:

内建(built-in)模块(loadable module)
Kbuild 配置obj-yobj-m
产物链入 vmlinux独立 xxx.ko
初始化时机内核启动阶段 do_initcalls()insmod/modprobe
可卸载否(module_exit 无效)是(rmmod
入口约定函数指针进 initcall 段固定符号 init_module

两种形态的注册要求截然不同:内建代码需要把初始化函数注册进启动流程的某个级别;模块则需要把入口地址、许可证、设备表等信息以标准格式写进 .ko 文件,供内核加载器和用户态工具解析。

如果让驱动作者为两种形态各写一套注册代码,#ifdef 将污染所有驱动源码。内核的做法是把这一差异交给宏处理:由编译开关 MODULE 决定展开路径。构建系统(Kbuild)根据 obj-m / obj-y 决定是否在编译命令行传入 -DMODULE,如图 1 所示。

图 1:同一份源码的两条编译路径

graph TB
    SRC[驱动源码 hello.c]
    KB[Kbuild 构建系统]
    OBJM[obj-m 配置]
    OBJY[obj-y 配置]
    DM[命令行定义 MODULE 宏]
    NOM[命令行不定义 MODULE 宏]
    KO[hello.ko 可加载模块]
    VI[vmlinux 内建代码]
    SRC --> KB
    KB --> OBJM
    KB --> OBJY
    OBJM --> DM
    OBJY --> NOM
    DM --> KO
    NOM --> VI

MODULE 宏是区分两种形态的唯一开关。本文所有宏的"双重展开",都由它决定。

二、核心设计思想

2.1 模块信息写入 ELF 段

MODULE_LICENSEMODULE_AUTHOR 这些宏不产生任何可执行代码,它们只做一件事:.modinfo 段里放一个 tag=value 格式的字符串。信息在编译期写入 ELF 文件,消费方有两类:

  • 用户态工具modinfo 命令直接读 .ko.modinfo 段展示许可证、作者;depmod 从中提取别名生成 modules.alias
  • 内核加载器kernel/module.cget_modinfo() 在加载时遍历同一批字符串,做许可证检查、命名空间检查

一份编译期生成的数据,多方按约定格式读取——这是内核里典型的"数据接口优于函数接口"设计:写入方(驱动)与读取方(工具/内核)通过 ELF 段解耦,谁也不需要链接谁。

2.2 固定名称的入口 ABI

模块加载器需要一个确定的入口。Linux 的约定是:每个 .ko 必须提供名为 init_module / cleanup_module 的符号。驱动作者的函数名五花八门,于是用 GCC 的 alias 属性把固定名"别名"到真实函数上。这个约定由构建工具 modpost 强制执行,是跨越内核与 kbuild 的 ABI 契约

2.3 用宏吸收编译形态差异

同一套宏,靠 #ifdef MODULE 展开成两套代码,驱动源码对编译形态保持完全无感知。这是一种 复杂度转移:宁可让宏的实现复杂,也要让宏的使用简单。内核里有数万个调用点,宏定义处的一次简化会放大数万倍。

三、模块信息宏:MODULE_* 家族

3.1 共同的落点:MODULE_INFO 的展开

所有 MODULE_* 信息宏最终都汇聚到 __MODULE_INFO

/* include/linux/moduleparam.h */
#define __MODULE_INFO(tag, name, info)                      \
    static const char __UNIQUE_ID(name)[]                  \
        __used __section(".modinfo") __aligned(1)          \
        = __MODULE_INFO_PREFIX __stringify(tag) "=" info

短短几行,每个修饰符都有明确用途:

  • __section(".modinfo"):把字符串放进自定义段。这是整个机制的基石——信息成为 ELF 文件结构的一部分,而非运行期数据
  • __used:这个静态数组没有任何代码引用它,不标记则链接器会将其丢弃
  • __aligned(1):取消默认对齐,多个字符串在段内紧凑排列。消费方按 strlen + 1 逐个遍历,紧凑排列让遍历逻辑最简单
  • __UNIQUE_ID(name):展开为带 __COUNTER__(编译器内建计数器)的唯一符号名。同一个编译单元里写多个 MODULE_LICENSE 或多个 MODULE_FIRMWARE 时,数组名不会冲突

前缀 __MODULE_INFO_PREFIXMODULE 宏二选一:

/* include/linux/moduleparam.h */
#ifdef MODULE
#define __MODULE_INFO_PREFIX /* empty */
#else
#define __MODULE_INFO_PREFIX KBUILD_MODNAME "."
#endif

模块形态下前缀为空,因为每个 .ko 是独立文件,tag=value 不会相互冲突;内建形态下多个编译单元的 .modinfo 会汇总,KBUILD_MODNAME. 前缀(如 hello.)标记每条信息的归属。

__stringify(tag) 用了两层宏的间接字符串化:

/* include/linux/stringify.h */
#define __stringify_1(x...)    #x
#define __stringify(x...)    __stringify_1(x)

若只用一层 #x,当参数 x 本身是宏时不会展开,得到的是宏名字符串;先经过一层替换再 #,才能拿到宏的值。

最终,一个 .ko.modinfo 段大致如图 2 所示。

图 2:.modinfo 段中的 tag=value 字符串

graph LR
    KO[hello.ko]
    subgraph MODINFO[.modinfo 段]
        E1[license=GPL v2]
        E2[author=xxx]
        E3[description=hello driver]
        E4[firmware=hello.bin]
        E5[name=hello]
        E6[vermagic=5.15.49 ...]
    end
    TOOLS[modinfo / depmod 用户态工具]
    KERNEL[内核加载器 get_modinfo]
    KO --> MODINFO
    MODINFO --> TOOLS
    MODINFO --> KERNEL

namevermagic 等条目不是驱动作者写的,而是 modpost 自动生成的(scripts/mod/modpost.c),用于加载时的版本魔术字校验。

3.2 其余模块信息宏:一行 MODULE_INFO 的封装

理解了共同落点,其余信息宏都只是给不同 tag 换了个名字:

/* include/linux/module.h */
#define MODULE_LICENSE(_license)        MODULE_FILE MODULE_INFO(license, _license)
#define MODULE_AUTHOR(_author)          MODULE_INFO(author, _author)
#define MODULE_DESCRIPTION(_description) MODULE_INFO(description, _description)
#define MODULE_FIRMWARE(_firmware)      MODULE_INFO(firmware, _firmware)
#define MODULE_IMPORT_NS(ns)            MODULE_INFO(import_ns, #ns)
生成的 .modinfo 条目读取方
MODULE_LICENSElicense=...内核 GPL 兼容性检查、modinfo
MODULE_AUTHOR / MODULE_DESCRIPTIONauthor=... / description=...modinfo
MODULE_FIRMWAREfirmware=...固件子系统(request_firmware 加载路径)
MODULE_IMPORT_NSimport_ns=...内核符号命名空间检查

其中两个值得展开。

MODULE_LICENSE 不只是注释。 内核加载器读到 license 后会调用 license_is_gpl_compatible() 判断(kernel/module.c):非 GPL 兼容许可证会给内核打上 taint 标记,且该模块不得绑定 EXPORT_SYMBOL_GPL 导出的符号。源码注释里那串 "Dual BSD/GPL" 等可接受标识符,就是这份白名单。它同时支撑社区治理:modinfo 让用户自查系统是否纯净,维护者可拒绝含专有模块的缺陷报告。

MODULE_IMPORT_NS 是符号命名空间的"访问许可"。 自 5.x 起,内核可把一批导出符号归入命名空间(如 USB_STORAGE)。模块想使用这些符号,必须显式声明导入;加载时内核逐条校验 import_ns 条目,未声明的导入直接拒绝加载,把"隐式依赖"变成"显式契约"。

3.3 MODULE_FILE:为什么 LICENSE 的展开里多了它

注意 MODULE_LICENSE 展开里排在最前面的 MODULE_FILE

/* include/linux/module.h */
#ifdef MODULE
#define MODULE_FILE
#else
#define MODULE_FILE    MODULE_INFO(file, KBUILD_MODFILE);
#endif

仅内建形态写一条 file=路径。它服务于构建期:kbuild 据此生成 modules.builtin——记录哪些"模块"其实已编进内核的清单。modprobe 查到目标在清单内时直接跳过加载。模块形态下此信息无意义(.ko 文件本身就是自己),故为空展开。

3.4 MODULE_DEVICE_TABLE:一个别名串起自动加载

/* include/linux/module.h */
#ifdef MODULE
/* Creates an alias so file2alias.c can find device table. */
#define MODULE_DEVICE_TABLE(type, name)                    \
extern typeof(name) __mod_##type##__##name##_device_table        \
  __attribute__ ((unused, alias(__stringify(name))))
#else  /* !MODULE */
#define MODULE_DEVICE_TABLE(type, name)
#endif

这可能是最费解的一个宏。它声明了一个只有名字、没有函数体的外部符号:__mod_pci__xxx_device_table,通过 alias 属性让它与驱动的设备 ID 表 xxx 等价。

为什么不直接找 xxx?因为设备表是个普通的静态结构体数组,名字由驱动作者随意起,没有可检索的命名规律。这个宏用 __mod_<type>__<name>_device_table 的固定模式为设备表生成了一个符合命名约定的检索符号。构建期的 file2aliasscripts/mod/file2alias.c)扫描 .ko 中所有符合该模式的符号,读出设备 ID 表内容,为每一条 ID 计算 alias=pci:vXXXdYYY... 形式的 modalias 字符串写入 .modinfo。此后,自动加载链路得以闭合,如图 3。

图 3:MODULE_DEVICE_TABLE 驱动的自动加载链路

sequenceDiagram
    participant D as 设备插入
    participant U as udev 用户态
    participant M as modprobe
    participant K as 内核 finit_module
    D->>U: uevent 携带 modalias
    U->>M: 请求加载匹配驱动
    M->>M: 查 modules.alias 命中 hello
    M->>K: finit_module 加载 hello.ko
    K->>K: 执行入口 init_module

内建形态下宏为空展开:驱动已在内核里,不存在"加载"问题,自然无需别名。这个细节再次体现"一套宏两种形态"的设计——同一语义,按形态裁剪到最小必要动作

四、module_init / module_exit:一套宏,两条路径

4.1 内建形态:注册进 initcall 段

MODULE 未定义时,module_init 转手交给 initcall 机制:

/* include/linux/module.h */
#ifndef MODULE
#define module_init(x)    __initcall(x);
#define module_exit(x)    __exitcall(x);
#endif

__initcall(x)include/linux/init.h 中等价于 device_initcall(x),最终在 .initcall6.init 段放入一个指向 x 的函数指针:

/* include/linux/init.h */
#define __initcall(fn) device_initcall(fn)
#define device_initcall(fn)  __define_initcall(fn, 6)

编译产物形如(节选自预处理输出):

static initcall_t __initcall____KBUILD_MODNAME__128_17_hello_init6
    __used __section__(".initcall6" ".init") = hello_init;

符号名中的 6 就是 initcall 级别。内核启动时 do_initcalls()0→7 的段顺序逐级调用所有注册函数,驱动初始化由此汇入启动流程。数字级别提供了依赖排序:总线(subsys_initcall,4)先于设备驱动(device_initcall,6),驱动自然可以假定它依赖的总线已经就绪。

module_exit__exitcall,但内建代码永不卸载,这个指针实际不会被调用——保留注册仅为语义完整性。

4.2 模块形态:别名、类型检查与 CFI

MODULE 已定义时,展开骤然变复杂:

/* include/linux/module.h */
#define module_init(initfn)                    \
    static inline initcall_t __maybe_unused __inittest(void)        \
    { return initfn; }                    \
    int init_module(void) __copy(initfn)            \
        __attribute__((alias(#initfn)));        \
    __CFI_ADDRESSABLE(init_module, __initdata);

int init_module(void) ... alias(#initfn)——固定名称 ABI 的落地。 加载器只认 init_module 这个名字。用 alias 属性让 init_module 成为 initfn 的别名:两个符号指向同一地址,驱动作者保有自己的命名自由。

这个约定真正的执法者是构建工具 modpost。scripts/mod/modpost.c 扫描 .ko 的符号表:

/* scripts/mod/modpost.c */
if (strcmp(symname, "init_module") == 0)
    mod->has_init = 1;
if (strcmp(symname, "cleanup_module") == 0)
    mod->has_cleanup = 1;

随后 modpost 为每个 .ko 生成一个 struct module __this_module(位于 .gnu.linkonce.this_module 段),把 .init = init_module 写进去。内核加载器(finit_module/init_module 系统调用 → load_module)读取这个结构体,拿到入口指针后由 do_init_module() 执行 do_one_initcall(mod->init)。完整流程如图 4 所示。

图 4:module_init 在两种形态下的展开与执行路径

graph TB
    MI[module_init 宏调用]
    IC[展开为 __initcall 即 device_initcall]
    SEC[函数指针存入 .initcall6.init 段]
    BOOT[内核启动 do_initcalls 按级别调用]
    AL[展开为别名定义]
    AL1[init_module 别名指向 initfn]
    AL2[__inittest 编译期类型检查]
    AL3[__cfi_jt_init_module 跳转表项]
    TM[modpost 生成 __this_module 结构体]
    LOAD[load_module 读取 init 指针并执行]
    MI -->|未定义 MODULE 内建| IC
    IC --> SEC
    SEC --> BOOT
    MI -->|定义 MODULE 模块| AL
    AL --> AL1
    AL --> AL2
    AL --> AL3
    AL1 --> TM
    TM --> LOAD

__inittest——编译期类型门卫。 宏还附带定义了一个内联函数,函数体只有 return initfn;initcall_tint (*)(void),若 initfn 的签名与之不符(比如带了参数),这条 return 语句直接编译报错。把运行期才能发现的调用约定错误,提前到编译期,代价是一个多余的、__maybe_unused 抑制告警的内联函数。__exittest 同理。

__copy(initfn)——属性继承。 __copyinclude/linux/compiler_attributes.h)让 init_module 继承 initfn 声明上的属性(可见性、CFI 规范性等),保证别名符号与本体在链接器与 CFI 眼中行为一致。

__CFI_ADDRESSABLE(init_module, __initdata)——控制流完整性适配。 开启 Clang CFI 时,include/linux/cfi.h 将其展开为一个跳转表项 __cfi_jt_init_module。内核通过函数指针调用 mod->init 时,CFI 要校验目标类型;直接调用真实函数会绕过跳转表导致校验失败。于是加载器的 cfi_init()kernel/module.c)专门查找 __cfi_jt_init_module,把 mod->init 改指为跳转表项。冷门,但在开启 CFI 的内核上缺了它模块就无法加载。

4.3 module_exit:完全对称的镜像

/* include/linux/module.h */
#define module_exit(exitfn)                    \
    static inline exitcall_t __maybe_unused __exittest(void)        \
    { return exitfn; }                    \
    void cleanup_module(void) __copy(exitfn)        \
        __attribute__((alias(#exitfn)));        \
    __CFI_ADDRESSABLE(cleanup_module, __exitdata);

结构上逐项对应 module_init:别名 cleanup_module、类型门卫 __exittest、CFI 跳转表项。rmmod 触发 delete_module 系统调用时执行 mod->exit。两点差异:内建形态下完全空操作(不可卸载的代码无需清理路径);且模块可以选择不写 module_exit——不可卸载模块(如带 __init 数据的)是合法存在。

五、设计哲学小结

回看全文,module.h 的这些宏示范了几条可迁移的设计原则:

  1. 将编译形态差异集中到宏定义处obj-m/obj-y 的形态差异由 MODULE 宏的展开吸收,数万个驱动源码无需编写任何 #ifdef。复杂度并未减少,而是集中于宏定义一处,调用方因此保持简洁
  2. 用数据段做接口.modinfotag=value 字符串为契约,写入方与多个消费方(modinfo、depmod、内核加载器)彻底解耦,新增一种信息只需新增一个 tag,无需改动任何读取方
  3. 用固定命名约定换零成本对接init_module 这个固定符号名虽然限制了入口命名,却换来了 modpost 与内核加载器之间一条极简的约定;驱动侧的命名自由由 alias 属性补偿
  4. 把错误前移__inittest 用一个多余的内联函数,把入口签名错误从运行期崩溃提前到编译期报错
  5. 属性修饰符皆有明确用途__used__aligned(1)__copy__CFI_ADDRESSABLE 均非装饰,理解它们就是理解链接器与加载器的约束

附录 A:延伸阅读

  • include/linux/module.h — 所有 MODULE_* 宏定义
  • include/linux/moduleparam.h__MODULE_INFO、模块参数机制
  • include/linux/init.h — initcall 分级机制、.initcallN.init
  • kernel/module.c — 模块加载器:load_moduledo_init_moduleget_modinfo
  • scripts/mod/modpost.c__this_module 生成、固定符号名检查
  • scripts/mod/file2alias.c — 设备表、modalias 字符串

附录 B:宏速查总表

作用
MODULE_LICENSE声明许可证;供内核做 GPL 兼容性检查,非兼容模块被标记 taint 且不能使用 EXPORT_SYMBOL_GPL 符号
MODULE_AUTHOR声明作者,modinfo 可见
MODULE_DESCRIPTION声明模块功能描述,modinfo 可见
MODULE_FIRMWARE声明运行时需要的固件文件名
MODULE_IMPORT_NS声明导入的符号命名空间,未声明则不能使用该空间的导出符号
MODULE_DEVICE_TABLE为设备 ID 表生成固定命名的别名符号,构建期据此生成 modalias,支撑驱动自动加载
module_init(fn)注册入口函数:内建时挂入 initcall 段随启动调用;模块时以 init_module 别名供加载器调用
module_exit(fn)注册卸载函数:模块时以 cleanup_module 别名供 rmmod 调用;内建时不生效

⚠️ 内建形态下 module_exit 注册的指针永远不会被调用;依赖卸载逻辑清理资源的驱动,内建时资源生命周期等同内核生命周期。


版权声明(重申) 本文为原创技术文章,著作权归作者所有。未经作者书面授权,严禁任何形式的转载、摘编、复制、建立镜像、商业使用

Copyright © 2026. All rights reserved. Unauthorized reproduction, distribution, or commercial use is strictly prohibited.