Linux驱动基础(四):符号导出

1 阅读10分钟

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

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

写在前面

Linux 内核是宏内核架构,却支持模块在运行时动态加载。是否有疑问:模块在编译时并不链接内核,它引用的 printk、register_chrdev 等内核函数,地址从何而来?EXPORT_SYMBOL 系列宏就是内核给出的答案——它把指定符号在编译期写入特定 ELF 段,在链接期汇成内核符号表,在运行期提供模块加载器进行符号解析。

本文基于内核源码,沿宏展开、ELF 段布局、链接脚本至模块加载器的顺序,系统梳理一个内核符号从导出到被模块引用的完整链路。

阅读本文你将了解:

  • EXPORT_SYMBOL 宏的完整展开内容,以及 GPL 变体如何以参数区分
  • __ksymtab、__ksymtab_strings 等 ELF 段的设计意图
  • kernel_symbol 结构体在不同配置下的定义
  • 模块加载时符号解析的执行顺序:符号查找、许可检查与 CRC 校验

一、问题:模块如何解析对内核符号的引用

模块是独立的 ELF 对象文件,编译时不与内核镜像链接。当引用的内核符号时,在其 .symtab 中均为标记 U 的未定义条目而导致地址空缺。普通程序在静态链接阶段由链接器完成符号解析,内核模块不具备这一条件:内核符号表在内核链接时才最终成形,而模块的加载发生在运行期。

因此,符号解析只能在模块加载时进行。内核需要维护一张运行期可查询的符号表,记载各导出符号的名称与地址;该表在内核构建过程中自动生成,不依赖任何运行期注册动作。

EXPORT_SYMBOL 的方案可以概括为三个阶段,贯穿内核与模块的完整生命周期:

阶段动作执行者
编译期宏展开,把符号的名称字符串与 kernel_symbol 条目写入目标文件的特定 ELF 段编译器 / 汇编器
链接期链接脚本把散落在各目标文件中的段收集、排序,汇成内核符号表链接器
运行期模块加载器在符号表中查找并校验,将地址填入模块的重定位条目内核模块加载器
flowchart LR
    subgraph 编译期
        A[EXPORT_SYMBOL 宏展开] --> B[写入 __ksymtab_strings 与 ___ksymtab 段]
    end
    subgraph 链接期
        B --> C[链接脚本 SORT 收集排序]
        C --> D[内核符号表 __ksymtab 与 __ksymtab_gpl]
    end
    subgraph 运行期
        D --> E[模块加载器查找与校验]
        E ==> F[符号地址填入模块重定位条目]
    end

图 1:导出符号的完整流程。

除基本导出外,EXPORT_SYMBOL 还承担一项功能:GPL 许可控制——非 GPL 模块不可引用 GPL-only 导出符号。

二、核心设计:以 ELF 段导出符号信息

EXPORT_SYMBOL 的核心设计:符号导出不依赖任何运行期机制,符号的名称与地址在编译期写入 ELF 段,由链接器在链接期汇总成表。内核启动后可直接读取符号表数据。

每个导出符号在内核镜像中被链接在目标段中:

  1. 符号地址——kernel_symbol 条目,位于 ___ksymtab 或 ___ksymtab_gpl 段;
  2. 符号名称字符串——位于 __ksymtab_strings 段;
  3. 版本 CRC(启用 CONFIG_MODVERSIONS 时)——位于 ___kcrctab 段。

GPL 许可策略同样借助段的划分实现:EXPORT_SYMBOL_GPL 导出的符号被置于 GPL 专用段,模块加载器依据符号所在段判定其能否被当前模块引用。

三、数据结构:kernel_symbol

//include/linux/export.h
#ifdef CONFIG_HAVE_ARCH_PREL32_RELOCATIONS
struct kernel_symbol {
    int value_offset;
    int name_offset;
    int namespace_offset;
};
#else
struct kernel_symbol {
    unsigned long value;
    const char *name;
    const char *namespace;
};
#endif

CONFIG_HAVE_ARCH_PREL32_RELOCATIONS启用前提是工具链与模块加载器均支持 32 位位置相对重定位。是否开启该配置会导致该结构体的字段含义与使用方式均不相同。

未启用该配置时,三个字段直接存储最终结果:value 为符号的运行期地址,name 与 namespace 分别指向名称字符串与命名空间字符串。对应地__KSYMTAB_ENTRY宏展开生成一个静态常量变量,以符号地址与字符串地址完成初始化;使用时直接读取字段。

启用该配置时,三个字段改为存储相对偏移:value_offset 表示符号地址相对该字段自身地址的字节偏移,name_offset 与 namespace_offset 的编码方式与之相同。对应地__KSYMTAB_ENTRY宏展开改为内联汇编,生成三个 .long 相对偏移;使用时由 offset_to_ptr 还原出绝对地址,即字段地址加上偏移值。

源码注释中对于开启该配置的考量有明确说明:

  • 缩减体积:64 位架构下单个条目由 24 字节(3 个指针)减至 12 字节(3 个 int),符号表整体体积减半;
  • 消除运行期重定位:绝对地址在可重定位内核(relocatable kernel)中须于加载时逐条修正,而相对偏移的取值不随加载基址变化,无需这部分运行期处理。

结构体中的 namespace 相关字段服务于符号命名空间机制,不在本文讨论范围。

四、EXPORT_SYMBOL

4.1 宏定义展开

EXPORT_SYMBOL 由多层宏定义逐步展开,最终指向核心宏 ___EXPORT_SYMBOL:

#define ___EXPORT_SYMBOL(sym, sec, ns)		\
	extern typeof(sym) sym;					\
	extern const char __kstrtab_##sym[];	\
	extern const char __kstrtabns_##sym[];	\
	__CRC_SYMBOL(sym, sec);					\
	asm("	.section \"__ksymtab_strings\",\"aMS\",%progbits,1	\n"	\
	    "__kstrtab_" #sym ":				    \n"\
	    "	.asciz 	\"" #sym "\"					\n"\
	    "__kstrtabns_" #sym ":					\n"\
	    "	.asciz 	\"" ns "\"					\n"\
	    "	.previous						\n");\
	__KSYMTAB_ENTRY(sym, sec)

#endif

#define _EXPORT_SYMBOL(sym, sec)      __EXPORT_SYMBOL(sym, sec, "")
#define EXPORT_SYMBOL(sym)            _EXPORT_SYMBOL(sym, "")
#define EXPORT_SYMBOL_GPL(sym)        _EXPORT_SYMBOL(sym, "_gpl")

EXPORT_SYMBOL 与 EXPORT_SYMBOL_GPL 的差异体现在参数 sec 上:"" 对应普通符号表,"_gpl" 对应 GPL-only 符号表。两类导出在编译期写入不同的段,许可策略由段名区分。参数 ns 用于符号命名空间机制。

两个值得注意的细节:

  • extern typeof(sym) sym;: typeof 是 GCC 扩展,取符号的声明类型。这行声明保证若符号未定义或不存在于当前编译单元,编译立即报错,宏自身不提供任何类型信息;
  • __kstrtab_##sym 与 __kstrtabns_##sym 只有前向声明,实际定义由宏内的内联汇编在 __ksymtab_strings 段中给出。## 是预处理器的 token 拼接运算符,sym 为 foo 时展开为 __kstrtab_foo。

4.2 __ksymtab_strings 段

asm("    .section \"__ksymtab_strings\",\"aMS\",%progbits,1  \n"
    "__kstrtab_" #sym ":                        \n"
    "    .asciz     \"" #sym "\"                \n"
    "__kstrtabns_" #sym ":                      \n"
    "    .asciz     \"" ns "\"                  \n"
    "    .previous                              \n");

这段内联汇编为每个导出符号生成两条以 null 结尾的字符串:前者为符号名称,后者记录符号所属的命名空间,普通导出时为空串。末尾的 .previous 返回上一个段,保证后续代码不被误写入 __ksymtab_strings。

4.3 kernel_symbol 段

//include/linux/export.h
#define __KSYMTAB_ENTRY(sym, sec)                    \
    static const struct kernel_symbol __ksymtab_##sym \
    __attribute__((section("___ksymtab" sec "+" #sym), used)) \
    __aligned(sizeof(void *))                    \
    = { (unsigned long)&sym, __kstrtab_##sym, __kstrtabns_##sym }

普通路径下,__KSYMTAB_ENTRY 定义一个 C 静态常量变量,以 section 属性放入指定段。初值将符号运行期地址 &sym 与名称字符串 __kstrtab_##sym 绑定于同一变量,第三个成员为命名空间字符串,普通导出时为空串。

段名是理解链接期的关键,以 EXPORT_SYMBOL_GPL(foo) 为例,段名为 ___ksymtab_gpl+foo:

  • 前缀 ___ksymtab 与后缀 sec:区分普通导出与 GPL 导出,二者进入不同的输出段;
  • +<符号名>:使每个导出符号拥有唯一的输入段名。链接脚本通过 SORT 按段名对输入段排序,段名末尾的符号名使最终符号表中的符号随符号名顺序排列;模块加载器对该表的二分查找依赖这一顺序。

五、链接脚本:汇成内核符号表

单个目标文件中的条目是零散的,链接脚本负责把它们拼成可供查询的表:

//include/asm-generic/vmlinux.lds.h
/* Kernel symbol table: Normal symbols */
__ksymtab         : AT(ADDR(__ksymtab) - LOAD_OFFSET) {
    __start___ksymtab = .;
    KEEP(*(SORT(___ksymtab+*)))
    __stop___ksymtab = .;
}
/* Kernel symbol table: GPL-only symbols */
__ksymtab_gpl     : AT(ADDR(__ksymtab_gpl) - LOAD_OFFSET) {
    __start___ksymtab_gpl = .;
    KEEP(*(SORT(___ksymtab_gpl+*)))
    __stop___ksymtab_gpl = .;
}
/* __kcrctab 与 __kcrctab_gpl 结构相同,收集 CRC 条目 */
/* __ksymtab_strings : 收集全部名称字符串 */

链接脚本的处理包含三项:

  • SORT(___ksymtab+*):将所有名字匹配 ___ksymtab+* 的输入段按段名排序后拼接。由于段名末尾嵌有符号名,输出段 __ksymtab 内的条目即按符号名顺序存放。
  • KEEP:防止链接器垃圾回收(--gc-sections)把无引用的段丢弃。当符号没有任何代码引用它时不 KEEP 就会被当作死代码删除;
  • __start___ksymtab / __stop___ksymtab:链接器生成的边界符号,内核用这对指针界定符号表范围,无需额外的计数变量。

kernel_symbol 条目与 CRC 条目按相同的段名规则排序(___kcrctab+*),两条表按下标一一对应。

六、运行期:模块加载时的符号解析

模块加载时,加载器针对模块中的每个未定义符号在符号表中查找对应记录,并执行许可与版本校验。

6.1 查找与校验的完整流程

核心入口是 resolve_symbol,其执行顺序如下:

  1. 查找:find_symbol 依次遍历搜索内核普通符号表(__start___ksymtab 至 __stop___ksymtab)、内核 GPL 符号表,再遍历各已加载模块的符号表。
  2. GPL 许可检查:加载时内核依据模块的 MODULE_LICENSE 声明判定许可,非 GPL 兼容的许可证会为模块设置 TAINT_PROPRIETARY_MODULE 污染标志。符号查找由参数 gplok 控制能否使用 GPL-only 符号:设置了该污染标志的模块 gplok 为假,查找将跳过 GPL-only 符号表;
  3. taint 继承:taint(污染标志)是内核记录模块许可状态的位标志。若解析到的符号来自已设置 TAINT_PROPRIETARY_MODULE 标志的模块,当前模块也会被设置该标志;若当前模块自身使用 GPL-only 符号,则不允许再引用设置了该标志的模块导出的符号,解析失败;
  4. CRC 校验(CONFIG_MODVERSIONS):对比模块记录的符号 CRC 与内核符号表中的 CRC,不一致说明内核与模块对同一符号的函数原型理解不同,加载失败;
  5. 重定位:校验全部通过后,调用方将符号地址填入模块的重定位条目,解析完成。若某个未定义符号最终未能找到,加载失败并报 Unknown symbol。
sequenceDiagram
    participant M as 模块加载器
    participant F as find_symbol
    participant T as 内核符号表
    M->>F: resolve_symbol 未定义符号名
    F->>T: bsearch 普通表 __ksymtab
    T-->>F: 命中 GPL-only 表则跳过
    F->>T: bsearch GPL 表 __ksymtab_gpl
    T-->>F: 返回 kernel_symbol 条目
    F-->>M: 校验 taint 继承与 CRC
    M->>M: 符号地址填入重定位条目

图 2:模块加载时的符号解析时序。

6.2 宏速查表

API语义导出位置说明
EXPORT_SYMBOL(sym)导出符号到普通符号表___ksymtab任何模块均可引用
EXPORT_SYMBOL_GPL(sym)导出符号到 GPL-only 符号表___ksymtab_gpl仅 GPL 兼容模块可引用

七、设计哲学小结

  1. 数据即段:符号导出不依赖运行时注册,而是编译期布置进 ELF 段、链接期汇总。
  2. 策略编码进布局:GPL 许可限制不依赖独立的运行时判断逻辑,导出宏决定符号写入哪个段,许可属性即告确定。模块加载器依据符号所在段判定其能否被引用。
  3. 排序内建于链接:段名嵌入符号名加 SORT,让链接器在构建期完成排序,运行期直接二分查找。构建期多做一件事,换取运行期每次查询的 O(log n)。

附录 A:延伸阅读

  • include/linux/export.h:宏定义与 kernel_symbol 结构体
  • kernel/module.c:符号解析的完整实现
  • include/asm-generic/vmlinux.lds.h:__ksymtab 等段的链接脚本定义

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

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