内核模块符号导出实战:EXPORT_SYMBOL与Unknown symbol排查指南

发布时间:2026/9/12 4:00:21
内核模块符号导出实战:EXPORT_SYMBOL与Unknown symbol排查指南 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);对应Makefileobj-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, counter100。随后在/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)的声明。真正的符号解析发生在加载时而不在编译链接时。加载模块Bsudo insmod mod_b.ko dmesg | tail你应该看到mod_b: read counter100 mod_b: after set counter200如果一切顺利就说明模块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工具链这些都是同一套知识体系里的深层内容但理解了基础导出机制再去碰它们会轻松很多。