1. 模块间符号共享,为什么这么绕?
几个月前我在给一个嵌入式项目写驱动,遇到一个特别头疼的问题:驱动A负责底层硬件初始化,驱动B要读取初始化后得到的关键数据结构。方案看着很简单——B直接声明一个extern变量,A那边定义好就行。结果编译通过,加载B的时候直接报Unknown symbol,整个模块被拒绝插入内核。当时我脱口而出一句:这不就是内核版的“头文件都写好了怎么还找不到人”吗?
后来查了一圈才发现,问题根本不在头文件,而在于内核模块的符号可见性。普通应用程序里,多个.c文件链接成可执行文件,符号在链接阶段就全部“对齐”了;内核模块不一样,每个模块是独立的“零件”,编译时只生成自己的符号信息,加载时再由内核动态解析。你想让模块B调用模块A的函数,A必须主动把函数“亮出来”,也就是导出符号。没导出,内核不会让B看到A的任何内部实现。
这正是本篇文章要聊的核心:EXPORT_SYMBOL与EXPORT_SYMBOL_GPL这套机制。它解决的问题,往大了说是模块间的资源与功能共享,往小了说就是让你能安全地让一个模块调用另一个模块的函数或全局变量。无论你是写设备驱动、内核网络模块还是做嵌入式BSP开发,只要代码需要被拆成多个.ko文件协作,这玩意儿就绕不开。
需要说明的是,我下面的所有内容都基于常见的内核版本行为(5.x 到 6.x 均适用),结合我自己在 x86_64 和 ARM 平台上的实际调试经历来写。新手可以把它当操作手册,老手也能当排查手册用。
2. 导出符号机制与使用方式
2.1 EXPORT_SYMBOL 和 EXPORT_SYMBOL_GPL,到底导出的是什么
先看一段最典型的导出代码。假设模块A里有个函数:
#include <linux/module.h> #include <linux/init.h> int my_driver_get_value(void) { return 42; } EXPORT_SYMBOL(my_driver_get_value);就这么简单。EXPORT_SYMBOL(my_driver_get_value)会把my_driver_get_value这个符号从模块A的局部符号表提升到内核全局符号表中。模块B加载时,如果引用了这个符号,模块加载器就能在内核符号表里找到它的地址,并把模块B中的引用位置修正为这个地址。
如果你希望“其他模块能用,但不希望被非GPL协议的模块使用”,就用:
EXPORT_SYMBOL_GPL(my_driver_get_value);这两个宏的区别很多人随口能说出来,但真正理解背后语义的人不多。内核在加载模块时会检查模块的 license 字段(MODULE_LICENSE("GPL")),如果当前模块声明了 GPL 兼容许可证,那么EXPORT_SYMBOL_GPL导出的符号对它可见;否则,对不起,只能访问用EXPORT_SYMBOL导出的部分。这算是一种许可证边界的约束机制,保证内核在“开源共享”和“商业模块兼容”之间划出一条相对清晰的线。
2.2 导出后符号进了哪张表,如何被查找
模块被加载后,导出符号会进入内核维护的符号表。你可以通过/proc/kallsyms查看当前系统中所有已导出的内核符号:
cat /proc/kallsyms | grep my_driver_get_value输出类似:
ffffffffc0186030 t my_driver_get_value [my_driver_a]注意这几个字段的含义:地址、符号类型、符号名、所属模块。如果你希望确认某个符号是否真的导出了,这个命令是最直接的验证手段,比看代码靠谱得多。
符号类型字母也值得看一眼,常见的有T(全局文本符号)、t(局部文本符号)、D(已初始化全局数据)、d(已初始化局部数据)、B(未初始化全局数据)等。只有T、D、B这类全局符号才可能被模块间共享,t、d、b是模块内部的局部符号,外部不可见。
这里有个容易踩的坑:导出变量和导出函数同样重要。全局变量也可以被导出:
int my_global_counter = 0; EXPORT_SYMBOL(my_global_counter);但我的实际经验是:能导出函数就别导出变量。模块A导出变量,模块B直接读写,短期看方便,长期维护时你会发现自己根本不知道这变量被谁改成什么样子了。改成通过函数接口访问,再配合spinlock或mutex保护,虽然代码量大一点,但排查问题的成本会低很多。
3. 一次完整的模块间符号调用实操
光看宏定义没感觉,我们还是跑一个完整例子。我假设你有两台环境:一台 x86_64 的 Ubuntu/Debian 开发机,内核头文件已安装;或者一块带内核头文件交叉编译环境的 ARM 开发板,都行。这里以 x86_64 本地编译为例。
3.1 模块A:提供者模块的编写
文件mod_a.c:
#include <linux/module.h> #include <linux/init.h> static int secret_counter = 100; int get_secret_counter(void) { return secret_counter; } EXPORT_SYMBOL(get_secret_counter); int set_secret_counter(int val) { secret_counter = val; return 0; } EXPORT_SYMBOL(set_secret_counter); static int __init mod_a_init(void) { printk(KERN_INFO "mod_a: loaded, counter=%d\n", secret_counter); return 0; } static void __exit mod_a_exit(void) { printk(KERN_INFO "mod_a: unloaded\n"); } module_init(mod_a_init); module_exit(mod_a_exit); MODULE_LICENSE("GPL"); MODULE_AUTHOR("Your Name");对应Makefile:
obj-m += mod_a.o all: make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules clean: make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean编译后得到mod_a.ko,加载:
sudo insmod mod_a.ko这时看内核日志:
dmesg | tail你应该能看到mod_a: loaded, counter=100。随后在/proc/kallsyms里查两个导出符号:
sudo cat /proc/kallsyms | grep secret_counter输出里会出现get_secret_counter和set_secret_counter,并且末尾带[mod_a]。如果你加载了模块后看不到[mod_a],大概率是权限不足,加sudo。
3.2 模块B:消费者模块的编写
文件mod_b.c:
#include <linux/module.h> #include <linux/init.h> extern int get_secret_counter(void); extern int set_secret_counter(int val); static int __init mod_b_init(void) { int val = get_secret_counter(); printk(KERN_INFO "mod_b: read counter=%d\n", val); set_secret_counter(200); printk(KERN_INFO "mod_b: after set counter=%d\n", get_secret_counter()); return 0; } static void __exit mod_b_exit(void) { printk(KERN_INFO "mod_b: unloaded\n"); } module_init(mod_b_init); module_exit(mod_b_exit); MODULE_LICENSE("GPL");Makefile 对应加一行:
obj-m += mod_a.o mod_b.o注意:模块B编译时不依赖模块A的源码或头文件。它只需要知道extern int get_secret_counter(void)的声明。真正的符号解析发生在加载时,而不在编译链接时。
加载模块B:
sudo insmod mod_b.ko dmesg | tail你应该看到:
mod_b: read counter=100 mod_b: after set counter=200如果一切顺利,就说明模块A导出的函数被模块B成功调用并修改了模块A内部的全局变量。
3.3 加载顺序、依赖与卸载顺序
上面两个模块能正常工作的前提是:先加载模块A,再加载模块B。为什么?因为模块B里的get_secret_counter符号在被内核解析时,必须已经存在于内核符号表中。如果把加载顺序反过来,模块B一加载就直接报Unknown symbol。
卸载顺序则相反:
sudo rmmod mod_b sudo rmmod mod_a必须先卸载依赖者,再卸载提供者。如果你反过来,先卸载了模块A,那么已经加载的模块B就变成“悬空引用”,指向一个失效地址。内核在卸载时会维护模块之间的依赖关系,rmmod mod_a会提示Module mod_a is in use by mod_b,内核本身就是一道安全屏障。
但这里有一个隐蔽的问题:依赖关系是基于符号引用自动追踪的,如果模块B只是用EXPORT_SYMBOL导出的符号做了间接调用(比如函数指针),依赖关系依然会被记录;如果是通过kallsyms_lookup_name这类方式动态查找符号地址来调用,内核就无法自动建立依赖,卸载时极可能出问题。所以我的建议很直接——正规场景不要用kallsyms_lookup_name来跨模块调用,老老实实加EXPORT_SYMBOL,让依赖关系透明化,内核才能帮你守住边界。
4. 常见问题与排查技巧实录
这部分是我真正想写的,因为导出符号本身代码量很小,但出问题时能把人绕晕。下面的问题全部来自我自己的调试经历或社区里被反复讨论的典型场景。
4.1 Unknown symbol:最常见也最容易忽略
模块B加载时报:
mod_b: Unknown symbol get_secret_counter (err 0)通常优先级最高的自查步骤如下:
| 排查步骤 | 操作 | 预期结果 |
|---|---|---|
| 确认模块A已加载 | lsmod | grep mod_a | 能看到 mod_a |
| 确认符号已导出 | sudo cat /proc/kallsyms | grep get_secret_counter | 能看到地址和[mod_a]后缀 |
| 确认模块A实际加载的路径 | ls -l /sys/module/mod_a/sections/.text | 路径为内核根目录,排除多内核树混乱 |
| 确认模块B编译内核版本与运行内核一致 | modinfo mod_b.ko | grep vermagic | 与uname -r完全一致 |
最常见的坑在“模块A确实加载了,但加载的是旧版本”或“编译环境和运行环境的内核源码树不是同一份”。我遇到过最离奇的一个情况:模块A在/lib/modules/$(uname -r)/extra/下有一份旧.ko,我当时手工insmod了新编译的模块,但系统在启动时已经自动加载了旧版本,导致/proc/kallsyms里符号名称一模一样,地址不同。排查了一晚上,最后lsmod看清楚加载路径瞬间破案。
Unknown symbol报错里的err值也很有用。err 0通常是符号确实不在符号表或模块没加载;err -EINVAL或-ENOENT则可能是模块版本校验不匹配或符号被 GPL-only 限制。看到负数别慌,对应errno.h查一下原因就能缩小范围。
4.2 版本校验失败:kernel module taint 与 vermagic
模块加载时报:
mod_b: disagrees about version of symbol get_secret_counter这类问题的本质是符号版本不匹配。内核在开启CONFIG_MODVERSIONS时会为每个导出符号计算一个 CRC 校验值,模块B编译时会把模块A导出的符号 CRC 记录到自己的模块信息中。当模块B实际加载时,内核发现模块B记录的 CRC 与当前内核 / 模块A导出的 CRC 不一致,就直接拒绝。
出现 CRC 不一致的常见原因:
- 模块A的导出函数签名变了,但模块B还是按旧声明编译。
- 模块A和模块B用了不同的内核源码树或不同配置文件编译。
- 模块A换了编译器版本或编译选项,导致结构体布局变化。
解决办法其实很“笨”:确保A、B在同一个内核源码树、同一份.config下重新编译,再一起加载。不要单独升级其中一个模块,除非你确认接口完全向后兼容。
另外补充一个我在ARM平台上的经验:交叉编译器版本差异也会导致符号 CRC 不一致。比如一个模块用 GCC 9 编译,另一个用 GCC 11 编译,即便源码相同,某些内联函数或结构体对齐方式变化也可能让 CRC 变掉。内核的modpost工具会打印相关警告,加载前先跑一遍make modules看清楚有没有 warning。
4.3 调试导出符号的几个实用手段
排查导出符号问题,我常用的“三板斧”:
第一板斧是看/proc/kallsyms。但提个醒:这个文件在非 root 权限下会隐藏真实地址,看到全是 0 并不代表符号有问题,只是权限限制。用sudo再看即可。
第二板斧是看模块自身的符号表:
nm mod_a.ko | grep get_secret_counter正常会看到类似0000000000000000 T get_secret_counter的输出。如果这里没有T标记,而是t或干脆没有这个符号,说明EXPORT_SYMBOL没生效,问题出在编译阶段而不是加载阶段。此时优先检查 Makefile 是否把模块A的.o正确编译进去了。
第三板斧是用modinfo看模块元信息:
modinfo mod_b.ko重点关注vermagic和depends字段。如果depends里没有mod_a,但模块B明明引用了模块A的符号,说明编译时的依赖标记没生成,这通常是因为模块B没有直接#include模块A生成的Module.symvers。你可以手动把模块A编译目录下的Module.symvers拷贝到模块B的编译目录,或者直接在同一 Makefile 里一起编译两个模块,让KBuild自动处理。
这里额外分享一个我后来养成的习惯:让所有需要互相调用的模块从同一个 Makefile 编译。单独编译各模块虽然灵活,但Module.symvers的传递经常让人头大。同目录一起编译,依赖关系由 Kbuild 自动追踪,省掉很多坑。
obj-m += mod_a.o mod_b.o一行搞定,省心。
4.4 关于 EXPORT_SYMBOL_GPL 的一个边界情况
再补充一个值得注意的边界情况:如果你把符号用EXPORT_SYMBOL_GPL导出,而模块B里没有MODULE_LICENSE("GPL"),那么即使模块A已经加载、符号已经出现在/proc/kallsyms,模块B加载时依然报Unknown symbol。这个报错很有迷惑性,因为它和“符号不存在”表现一样。
判断方法是看模块B的 license 声明。模块B若用了MODULE_LICENSE("Proprietary"),它就只能看到非 GPL-only 的导出符号;改成MODULE_LICENSE("GPL")后重新编译,再看是否能正常加载。这在现实里常发生在商业驱动与开源驱动协作时,属于故意设计,不是内核 bug。
5. 写在最后的经验之谈
我做内核模块开发这些年,最深的体会是:导出符号这套机制,表面上是“让模块间能互相访问”,本质上是在“共享”和“隔离”之间做平衡。内核把模块拆开,就是为了让故障和权限边界更清晰;EXPORT_SYMBOL提供共享通道,但每个通道都意味着你在打破模块的封装边界。所以能用函数接口就别导变量,能用EXPORT_SYMBOL_GPL就别用EXPORT_SYMBOL,该保护的内部数据结构一定要用static藏好。
如果你刚入门,建议按我第 3 节的例子把两个模块跑通,然后故意把加载顺序反过来、故意把导出宏改成EXPORT_SYMBOL_GPL且模块B不写 GPL license,再看报错现象。踩过这几个坑之后,你对内核符号解析的理解会比单纯看文档深刻得多。后续如果要进一步扩展,可以研究一下Module.symvers的格式、CONFIG_MODVERSIONS的 CRC 计算原理,以及内核模块的modpost工具链,这些都是同一套知识体系里的深层内容,但理解了基础导出机制再去碰它们,会轻松很多。