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

日期 :2026-09-04 · 参考源码版本:Linux 5.15.49
版权声明 本文为原创技术文章,著作权归作者所有。未经作者书面授权,严禁任何形式的转载、摘编、复制、建立镜像、商业使用

写在前面

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

c 复制代码
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-y obj-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

c 复制代码
/* 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 宏二选一:

c 复制代码
/* 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) 用了两层宏的间接字符串化:

c 复制代码
/* 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 换了个名字:

c 复制代码
/* 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_LICENSE license=... 内核 GPL 兼容性检查、modinfo
MODULE_AUTHOR / MODULE_DESCRIPTION author=... / description=... modinfo
MODULE_FIRMWARE firmware=... 固件子系统(request_firmware 加载路径)
MODULE_IMPORT_NS import_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

c 复制代码
/* 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:一个别名串起自动加载

c 复制代码
/* 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 机制:

c 复制代码
/* 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 的函数指针:

c 复制代码
/* include/linux/init.h */
#define __initcall(fn) device_initcall(fn)
#define device_initcall(fn)  __define_initcall(fn, 6)

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

c 复制代码
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 已定义时,展开骤然变复杂:

c 复制代码
/* 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 的符号表:

c 复制代码
/* 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:完全对称的镜像

c 复制代码
/* 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.

相关推荐
Shadow(⊙o⊙)1 小时前
Linux网络——文件与网络的连接桥梁struct sock {
linux·运维·网络
新时代牛马1 小时前
Linux 性能调优与追踪完整篇:ftrace、perf、eBPF从采样到落地验证
linux·运维·服务器
sunoo-2291 小时前
【网络编程 + 数据库】select 与 epoll 核心区别详解 + SQLite 入门到 C 接口全攻略
linux·网络·数据库·vscode·学习·sqlite
zbyyd1 小时前
Linux 系统编程 :select/epoll 多路复用与 SQLite 数据库实战
linux·数据库·sqlite
Mortalbreeze2 小时前
深入理解 Linux IO 模型(二):多路复用——select
linux·运维·服务器·网络·tcp/ip
koi77u2 小时前
嵌入式学习———sqlite数据表
linux·数据库·学习·sqlite
笑梦无境2 小时前
nginx安装(5)
linux·运维·nginx
xx~t2 小时前
嵌入式——Linux软件编程——数据库
linux·c语言·数据库·oracle·sqlite
流浪0012 小时前
Linux系统篇26——通信(三)补:共享内存访问真的不进内核?
linux·运维·服务器