ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

内核模块符号导出:从Unknown symbol到EXPORT_SYMBOL实战解析

内核模块符号导出:从Unknown symbol到EXPORT_SYMBOL实战解析

做内核模块开发的人,估计都见过这么一幕:辛辛苦苦编译出一个ko,insmod的时候内核却丢出来一句Unknown symbol in module。你回头翻代码,函数明明定义了,也编译进去了,怎么就找不到?这时候十有八九就是你把EXPORT_SYMBOL给漏了。

这个宏看起来不起眼,但它才是内核模块之间互相调用的命门。模块A想把一个函数借给模块B用,光靠编译器在nm里看到这个符号是不够的,你必须明确告诉内核:“这个符号我允许其他模块来找我解析。” 这句话的载体就是EXPORT_SYMBOL以及它的一堆变体。这篇文章我把它的原理、用法、坑,以及排查路径全部摊开讲,新手可以照着做,有经验的人也可以直接跳到后面的常见问题部分看有没有自己踩过的雷。

1. 内容整体设计与思路拆解

1.1 内核模块之间到底怎么“看见”对方

先说个背景。内核本身其实是一个巨大的静态链接产物vmlinux,而各个模块在编译出来之后是独立的 ELF 可重定位文件,也就是.ko。它们不是像用户态程序那样在链接阶段就被揉进同一个可执行文件里,而是要等到insmod的时候,由内核模块加载器把它们加载到内存里,再现场做重定位。

重定位意味着什么?就是模块代码里如果出现了一个“未定义”的外部符号引用,加载器必须找到这个符号真正所在的地址,然后把调用指令里的地址改成那个真实地址。如果找不到,加载直接失败。

问题在于,内核里的符号可见性是默认关闭的。你写了一个非static函数,编译出来的.ko里当然有这个符号,但它是属于“这个ELF内部可见”的。别的模块加载时,并不会自动知道你的模块里有个这样的符号。用户态动态库里我们可以通过-rdynamic或者在.symtab里留符号让其他库调用,但在内核里这个动作是显式的:你必须在源代码里用一个导出宏,把这个符号登记到内核的导出符号表里,否则它就只能被自己这个编译单元使用。

所以EXPORT_SYMBOL解决的不是“能不能调用”的问题,而是“让内核知道这个符号可被外部模块解析”的问题。没有这一步,函数写在任何.c文件里,用任何声明方式,另一个模块都调不到。

1.2 为什么不干脆把所有符号全部导出

按说把所有全局符号都导出来,模块之间调用起来不就更方便了吗?确实有些刚接触内核模块的人会这么想,甚至会在自己的实验代码里把每个函数都导出。但内核社区从来不这么做,原因也很现实。

第一是接口膨胀。导出符号等于对外承诺“这个符号是稳定的、可依赖的”,可一旦你准备改函数名、改参数、改返回结构体,下游所有用到这个符号的模块全都会跟着遭殃。符号不导出,你重构自己模块内部的代码就毫无负担。第二是命名冲突。每个模块都想导出init、cleanup、debug_dump这种名字,全部放开之后,命名空间会乱得一塌糊涂。第三是许可边界。内核里大量符号用EXPORT_SYMBOL_GPL导出的,就是为了在许可证层面提醒调用方这个接口涉及GPL协议的兼容性问题。如果所有符号“无差别导出”,这种边界也就没有了。

所以导出的思路从来都是“按需、有节制”。你要给别人用,才需要导出;你内部自己用的函数,哪怕它写得很牛,也请继续让它保持static或者在文件内可见就行。

2. 核心细节解析与实操要点

2.1 导出宏家族:不只是 EXPORT_SYMBOL

新手往往只知道EXPORT_SYMBOL,其实内核里还提供了EXPORT_SYMBOL_GPL、EXPORT_SYMBOL_NS、EXPORT_SYMBOL_NS_GPL这几种变体。它们在写法上只差一个后缀,行为差别却很大。

导出宏可见范围调用方要求典型场景
EXPORT_SYMBOL(sym)所有模块都能解析无额外许可证要求通用的驱动接口、测试接口
EXPORT_SYMBOL_GPL(sym)模块可以解析,但校验许可证调用模块许可证必须GPL兼容内核基础设施、核心API
EXPORT_SYMBOL_NS("ns", sym)带命名空间调用模块需要MODULE_IMPORT_NS("ns")大子系统内部共享,避免跨子系统误用
EXPORT_SYMBOL_NS_GPL("ns", sym)带命名空间+许可证校验需要导入命名空间且许可证GPL兼容内核核心代码的精细化导出

EXPORT_SYMBOL_GPL是我实际用得最多的宏。它并不是说“非GPL模块不能看这个符号”,而是说加载器在解析这个符号时会去检查调用模块声明的 license 信息,发现模块没有声明GPL兼容许可证,就直接拒绝解析。这种拒绝在运行时往往表现为Unknown symbol,因为符号解析失败后统一走到的是同一个错误路径。

EXPORT_SYMBOL_NS这套机制是内核5.3以后逐步推开的。它把符号再包了一层命名空间概念,哪怕两个模块里都有一个同名符号,只要命名空间不同,调用方也不会被随便绑定。内核里很多大模块,比如某些块设备子系统、网络子系统,都在逐步迁移到命名空间导出。对普通驱动来说,我个人建议先从EXPORT_SYMBOL_GPL开始,用熟了再去管命名空间。

2.2 导出宏展开以后发生了什么

从写代码的角度看,你只是在函数下面加了一行宏。但从内核构建的角度看,这一步会改变很多细节。

include/linux/export.h里定义了一系列EXPORT_SYMBOL相关的展开逻辑。展开之后,编译器会在当前编译单元里生成一个struct kernel_symbol类型的常量,把这个符号的名字、地址、所属命名空间等信息放在一个特殊的.ksymtab段里。内核镜像里所有被导出的符号都会被链接器脚本收集到一起,形成一个全局的导出符号表。

光有编译时的收集还不够。构建系统里还有一个叫modpost的脚本工具,它负责扫描所有编译出来的模块和vmlinux,把导出的符号汇总到Module.symvers文件里。这个文件对后续模块编译极其重要,因为你在编译一个引用外部符号的模块时,modpost要拿这个文件里的记录来审核:“你要用的这个符号到底存不存在,CRC版本对不对。” 所以你的内核树或者模块目录里如果缺少Module.symvers,后面很容易出现奇怪的unknown symbol。

运行时也有一套对应流程。模块加载器在其中某个阶段会调用find_symbol(),在vmlinux和当前已加载模块的导出表里查找需要的符号。如果找到,就把struct module的引用计数关系建立起来;找不到,加载直接失败。这就是为什么insmod报错后面跟着err -2,-2就是-ENOENT,符号不存在。

2.3 导出位置、static 限制和常见错误理解

很多人会把EXPORT_SYMBOL误以为是“声明”,放在头文件里,或者放在调用方模块里。这是不对的。

导出宏必须和符号定义放在同一个编译单元里。也就是说,哪个.c文件里定义了cmd_calc_sum这个函数,EXPORT_SYMBOL(cmd_calc_sum)就应该出现在哪个.c文件的末尾。原因很简单:导出宏展开后要引用那个符号的地址,并生成一个常量结构体。如果符号不是当前编译单元定义的,这个地址就不是唯一的,或者根本就是未定义的。

同时,被导出的符号一定不能是static。导出宏本质上制造的是一个“外部可见”的符号入口,static符号只具备局部绑定,编译器在链接阶段就会处理掉这种无效导出。如果你把导出宏放在一个static函数下面,编译时要么直接报错,要么在后续modpost阶段被忽略,结果还是Unknown symbol。

还有一个容易忽略的坑:不要把导出宏往头文件里塞。头文件被多个.c文件包含之后,等于在每个编译单元里都生成了一份__ksymtab_xxx的展开,最后链接时会看到重复的导出符号记录,轻则警告,重则构建失败。导出宏的位置就一个原则:定义所在的.c文件内部,通常放在文件末尾。

3. 实操过程与核心环节实现

3.1 准备环境:一个干净的实验平台

内核模块开发实验我强烈建议在虚拟机或者单独的测试机上跑,不要拿日常用的宿主机直接试。主要原因倒不是怕编译,而是频繁insmod、rmmod、dmesg来回看日志,一旦模块里写出野指针或者非法访问,宿主机可能直接宕机,丢失你正在写的东西。

准备一个 Linux 环境,确认当前内核版本,并保证有对应的内核头文件。以 Debian/Ubuntu 为例:

uname -r sudo apt-get install linux-headers-$(uname -r)

uname -r查出来的版本和头文件版本必须一致。如果平时自己编过内核,也可以把KDIR指向你编译内核的源码目录,但最省事的方式还是用发行版自带的linux-headers-*包。

工作目录我建议这样安排:

~/kmod_lab/ ├── demo_a/ # 导出符号的模块 └── demo_b/ # 引用导出符号的模块

分开两个目录的原因马上就能看到,模块B的构建需要显式引用模块A生成的Module.symvers。

3.2 模块A:导出符号的一方

先写一个最简单的模块module_a.c,里面导出一个加法函数。虽然功能简单,但整个过程和真实驱动导出一个功能接口没有任何区别。

#include <linux/module.h> #include <linux/init.h> #include <linux/export.h> int cmd_calc_sum(int lhs, int rhs) { return lhs + rhs; } EXPORT_SYMBOL(cmd_calc_sum); static int __init demo_a_init(void) { pr_info("demo_a: loaded, cmd_calc_sum exported\n"); return 0; } static void __exit demo_a_exit(void) { pr_info("demo_a: unloaded\n"); } module_init(demo_a_init); module_exit(demo_a_exit); MODULE_LICENSE("GPL");

注意几个细节。导出宏放在函数定义之后,同一编译单元内,且函数不是static。我额外包含了<linux/export.h>,虽然有些内核版本通过<linux/module.h>也能间接包含到,但显式包含更稳妥,这是为了避免你在不同内核版本之间迁移代码时踩到莫名其妙的编译差异。

然后写这个模块的 Makefile:

KDIR := /lib/modules/$(shell uname -r)/build obj-m := module_a.o all: $(MAKE) -C $(KDIR) M=$(PWD) modules clean: $(MAKE) -C $(KDIR) M=$(PWD) clean

编译:

cd ~/kmod_lab/demo_a make

完成后你会在目录里看到module_a.ko,还有一个非常重要的文件Module.symvers。用cat打开它,会看到类似这样的记录:

0x00000000 cmd_calc_sum /home/you/kmod_lab/demo_a/module_a.ko EXPORT_SYMBOL

这一行就是“极简索引”。如果内核开了CONFIG_MODVERSIONS,第一列会是一个很长的 CRC 哈希值;没开的时候就是0x00000000。它告诉后续模块:“这个符号由哪个模块导出,用什么宏导出,CRC是多少。”

你还可以用nm看看.ko里的符号状态:

nm module_a.ko | grep cmd_calc_sum

输出应该是:

0000000000000000 T cmd_calc_sum 0000000000000000 r __ksymtab_cmd_calc_sum

T表示该符号是全局文本符号,__ksymtab_前缀的就是导出宏生成的导出表记录。看到T,说明函数不是static;看到__ksymtab_,说明导出宏生效了。

3.3 模块B:引用导出符号的一方

再写一个模块module_b.c,它在初始化函数里调用cmd_calc_sum,验证跨模块调用是否成功。调用方不需要包含<linux/export.h>,只需要像调用普通外部函数那样声明extern。

#include <linux/module.h> #include <linux/init.h> extern int cmd_calc_sum(int lhs, int rhs); static int __init demo_b_init(void) { int result = cmd_calc_sum(10, 32); pr_info("demo_b: cmd_calc_sum(10, 32) = %d\n", result); return 0; } static void __exit demo_b_exit(void) { pr_info("demo_b: unloaded\n"); } module_init(demo_b_init); module_exit(demo_b_exit); MODULE_LICENSE("GPL");

这里最关键的 Makefile 配置是KBUILD_EXTRA_SYMBOLS。因为模块B编译时,构建系统默认只知道内核自身有哪些导出符号,并不知道你的module_a.ko里导出了cmd_calc_sum。如果不告诉它,modpost在检查依赖时就会认为cmd_calc_sum是个不存在的外部符号,轻则警告,重则构建产物无法使用。于是模块B的 Makefile 写成:

KDIR := /lib/modules/$(shell uname -r)/build obj-m := module_b.o KBUILD_EXTRA_SYMBOLS := $(PWD)/../demo_a/Module.symvers all: $(MAKE) -C $(KDIR) M=$(PWD) modules clean: $(MAKE) -C $(KDIR) M=$(PWD) clean

KBUILD_EXTRA_SYMBOLS后面要写的是完整的Module.symvers路径,路径用空格分隔,多个路径可以继续追加。这是我在多个模块协同开发时最依赖的一个变量,没有它,跨模块引用的编译期检查基本没法做。

编译并检查:

cd ~/kmod_lab/demo_b make

这次编译应该很干净。如果modpost阶段报出类似WARNING: modpost: "cmd_calc_sum" [...] is not defined,那基本就是KBUILD_EXTRA_SYMBOLS路径没写对,或者demo_a还没编译生成Module.symvers。

3.4 加载顺序与验证

跨模块调用的加载顺序必须先把导出方加载进来,再加载引用方。顺序反了,内核解析不到符号,照样报Unknown symbol。

sudo insmod ~/kmod_lab/demo_a/module_a.ko sudo insmod ~/kmod_lab/demo_b/module_b.ko dmesg | tail -n 20

正常情况下能在dmesg里看到两条日志:

demo_a: loaded, cmd_calc_sum exported demo_b: cmd_calc_sum(10, 32) = 42

然后在/proc/kallsyms里确认符号可见:

sudo grep cmd_calc_sum /proc/kallsyms

可以看到类似:

ffffffffc00a1000 T cmd_calc_sum [module_a]

方括号里的module_a表示这个符号是由模块导出的,而不是vmlinux核心自带的。如果你用的是普通用户身份,grep出来可能全是0,这是kptr_restrict权限限制,用sudo就能看到真实地址。

卸载的时候要注意顺序:先卸载引用方,再卸载导出方。

sudo rmmod module_b sudo rmmod module_a

其实当你尝试先卸载module_a的时候,内核会因为module_a仍然被module_b引用而拒绝,提示Module module_a is in use。这是内核用引用计数在帮我守规矩,而不是出了什么故障。

4. 常见问题与排查技巧实录

4.1Unknown symbol in module的几种根因

这个错误几乎是所有内核模块开发者的第一课。现象是insmod module_b.ko时报:

insmod: ERROR: could not insert module module_b.ko: Unknown symbol in module

dmesg里则可能是:

module_b: Unknown symbol cmd_calc_sum (err -2)

err -2对应-ENOENT,代表内核在导出符号表里找不到这个符号。常见的根因按发生频率排序大概是这几条:

现象可能原因排查动作
err -2,但module_b.ko编译时看过符号存在module_a.ko还没加载,或者加载失败先insmod导出方模块
导出方模块已加载,但还是err -2符号根本没被导出,只是普通全局函数打开导出方源码,确认有EXPORT_SYMBOL
Module.symvers里有符号,但编出的模块加载失败导出方模块在 B 之前加载失败或崩溃先单独insmod导出方,看dmesg
编译时就警告符号未定义KBUILD_EXTRA_SYMBOLS没配或路径错检查KBUILD_EXTRA_SYMBOLS内容

排查顺序我建议是:先查Module.symvers,再查源码导出宏,最后看加载顺序。Module.symvers是最直接的第一现场,因为只要它里面有一行对应符号,编译阶段就不会出问题;没有,基本就是导出操作没写对。

4.2 导出符号版本 CRC 不一致

另一种让人头疼的错误是:

module_b: disagrees about version of symbol cmd_calc_sum

这个错误只会在内核开启CONFIG_MODVERSIONS时出现,通常发行版内核都会开。它的本质是:模块B编译时期望的符号版本 CRC,和module_a实际导出的符号 CRC 不匹配。

比如你用当前发行版内核的头文件编译了module_a.ko,但module_b是用旧内核源码树的头文件编译的,那么modpost在编译期生成的那个引用 CRC 就基于旧版头文件算出来的,和当前内核里的cmd_calc_sum对不上。也可能两边编译的时候,其中一个模块修改了函数返回结构体后没有同步重新编译另一个模块。

这种问题的处理方式没有捷径,就是把两个模块放到同一套内核头文件环境下重新编译。如果两个模块都来自同一个内核版本,但你还是遇到这个错误,检查一下有没有混用了保存下来的旧.ko,删掉重新make clean后再来一次。顺带提一句,vermagic不匹配的错误是另一种,类似:

module_b: version magic 'x.x.x SMP mod_unload ...' should be 'x.x.x SMP mod_unload ...'

那是整个模块和当前内核编译参数不匹配,不是符号级问题,重新编译模块即可。

4.3 GPL-only 符号的许可证冲突

如果动态库导出宏用的是EXPORT_SYMBOL_GPL,而引用模块的 license 声明不是 GPL 兼容的,调试过程会有点绕。

编译时你会在modpost阶段看到:

WARNING: modpost: module_b uses GPL-only symbol 'cmd_calc_sum'

这段警告还比较容易理解,但到运行时,dmesg里经常看到的是Unknown symbol而不是“GPL only”字样。因为符号解析失败后,内核统一走“无法找到符号”的错误提示。很多新手会反复检查导出宏、重新编译引用方,完全想不到竟然是许可证检查挡在门口。

解决方式是把引用模块的MODULE_LICENSE("GPL")改对,或者干脆在你自己的源码里就明确MODULE_LICENSE("GPL"),再配合对接口使用的合规判断。这里不是为了骗过内核,而是说你用 GPL 导出的接口,按协议本来就应该以 GPL 兼容方式发布使用方模块。

我做项目时有一条习惯:只要是我自己写给别的模块用的接口,一律先评估接口是否涉及 GPL 语义。如果是我自己的独立驱动模块,就用EXPORT_SYMBOL;如果接口最终面向的是内核公共逻辑层,我会更倾向EXPORT_SYMBOL_GPL,避免自己的接口被闭源模块依赖后陷入许可纠缠。

4.4 导出数据符号、依赖关系和卸载顺序的补充

导出一个全局变量也是可行的,写法上和函数一模一样:

int shared_counter = 0; EXPORT_SYMBOL_GPL(shared_counter);

但这里我建议慎重。跨模块直接读写一个裸变量,等于在模块之间制造了一个没有锁保护的可变状态。如果你只是想暴露一个配置项,更好的是提供一个访问函数,在里面完成加锁、校验、同步的整套流程,再把函数导出。如果一定要导出数据符号,请保证对它的访问有原子变量、锁或者其他同步原语保护,否则并发场景下会非常刺激。

依赖关系方面,前面提过module_a在被引用时不能卸载。这个机制依赖struct module的引用计数。加载module_b时,内核解析到cmd_calc_sum属于module_a,就会把module_a的引用计数加一。所以你不能在module_b仍加载的时候rmmod module_a。反过来,rmmod module_b之后,引用计数减一,这时代想卸载module_a就顺利了。

最后还有个容易被忽略的坑:如果你把导出宏写在了.c文件里看似正确的位置,但符号被EXPORT_SYMBOL之后又不想给别人用了,直接删掉导出宏是不够的,还需要把函数改回static或者确认没有其他模块在引用它,否则下次其他模块编译时modpost会给你抛未定义依赖的警告。

说实话,我在实际项目里对导出符号的态度已经从“能用就行”变成了“能少导就少导出”。每次我打开Module.symvers看到一堆自己都记不清用途的符号,心里就知道这里迟早会埋雷。内核模块之间的接口本质上是定义了一个契约,EXPORT_SYMBOL只是把契约贴到了墙上。真正稳定、可维护的驱动代码,一定会在墙前想清楚哪些接口值得贴上去,哪些应该继续闷头自己用。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进