
第一次在一台生产服务器上编自定义内核模块时我撞上过这么一幕gcc编译干净利落一个警告都没有可就在我以为马上要拿到.ko文件的时候modpost阶段劈头盖脸甩出来一行ERROR: modpost: my_symbol [xxx.ko] undefined!当时整个人是懵的。后来把这套机制彻底摸透才发现这个报错并不是说你代码写错了而是内核的符号解析器在告诉你你引用的某个符号在它能看到的世界里根本不存在。这篇文章就围绕这个报错展开把常见成因、对应修法和排查链路挨个讲清楚。无论你是在编第一个 hello world 模块还是正在给驱动加钩子、做模块间依赖应该都能从中找到对症下药的那一段。1. 先把报错本身看透modpost 在编译流程里到底做了什么1.1 一次内核模块构建的完整阶段很多人一开始把内核模块的构建理解成“和普通 C 程序一样跑一下make就出.ko”这是最大的误解来源。实际上kbuild 体系下编译一个外部模块会经历好几个阶段modpost是里面最容易让人一头雾水但又最关键的一环。第一个阶段是常规编译gcc把.c文件编译成.o目标文件。到了这一步编译器只关心语法、类型、头文件声明对不对它不会去管你引用的外部函数到底有没有人实现。第二个阶段就是modpost。kbuild 会生成一个.mod.c间接文件里面记录模块的 license、依赖、入口点信息同时调用scripts/mod/modpost程序把所有.o文件里的未定义符号拿去和“全局符号表”做一次匹配。匹配不上的就输出上面那个让人头疼的ERROR: modpost: xxx [xxx.ko] undefined!默认情况下会直接终止构建。第三个阶段才是真正的链接ld -r把.o和.mod.o合成最终的.ko。所以你可以这么理解modpost是链接之前的“体检医生”它的职责就是提前阻止那些带着悬空引用的模块进入最终产物。1.2 modpost 拿什么做匹配依据modpost 并不是凭空虚想某个符号存在与否它有一张自己的“已知符号清单”来源有三个内核源码树里的Module.symvers文件这个文件列出了当前内核或者正在构建的内核)导出的全部符号及其 CRC 校验值通过KBUILD_EXTRA_SYMBOLS指定的额外Module.symvers一般用于跨模块依赖当前这次构建中其他模块已经导出的符号。如果符号在这个清单里modpost 就认为解析成功如果不在它就把符号名和目标模块路径拼成一条错误消息打出来。1.3 报错信息的三个关键要素ERROR: modpost: symbol_name [path/to/module.ko] undefined!这条消息看起来简单其实包含三个必须看清楚的信息符号名就是引号里的那一串这是排查的起点目标模块路径方括号里的path/to/module.ko它告诉你哪个模块引用了不存在的符号ERROR:级别表示这一次构建会失败。有时候你会看到WARNING: modpost:那只是提醒比如“模块缺少版本信息”“GPL-only 符号被非 GPL 模块使用”之类不会立刻中断构建但往往埋着运行时加载失败的雷。2. 跨模块引用符号时最经典的翻车点Module.symvers 对不上2.1 场景还原模块 A 导出符号模块 B 引用它这是我在实际项目中遇到最多的情况。你写了两个外部模块modA里定义并且导出了custom_multiplymodB里声明引用它。单独编译modA一切正常轮到编译modB的时候modpost 却报custom_multiplyundefined。你的第一反应可能是“我明明在modA里导出了啊”没错modA是导出了但modB在编译时并不知道这件事。模块之间的符号信息不是全局共享的它是通过Module.symvers文件传递的。2.2 Module.symvers 里的每一行意味着什么成功构建modA之后它的目录下会多出一个Module.symvers文件。拿cat看每行记录大概长这样0x3f2a4b5c custom_multiply /root/modA/modA EXPORT_SYMBOL这一行从左到右依次是符号的 CRC 校验值、符号名、导出该符号的模块路径、导出类型EXPORT_SYMBOL或EXPORT_SYMBOL_GPL。modB想引用custom_multiply前提就是它的构建过程能看到这个文件否则在 modpost 眼里这个符号就是不存在。2.3 标准修法把导出方的 Module.symvers 喂给导入方最简单粗暴的做法是在命令里直接指给它make -C /lib/modules/$(uname -r)/build M/root/modB modules \ KBUILD_EXTRA_SYMBOLS/root/modA/Module.symvers这样modB的 modpost 阶段就会额外加载/root/modA/Module.symvers找到custom_multiply符号解析通过。如果你希望以后每次make都自动带上就把它写进modB的 Makefileifneq ($(KERNELRELEASE),) obj-m : modB.o KBUILD_EXTRA_SYMBOLS : /root/modA/Module.symvers export KBUILD_EXTRA_SYMBOLS else KERNELDIR ? /lib/modules/$(shell uname -r)/build PWD : $(shell pwd) default: $(MAKE) -C $(KERNELDIR) M$(PWD) modules endif注意那个export KBUILD_EXTRA_SYMBOLS一定不能省。kbuild 在外部模块里会重新解析这个 Makefile变量如果只在第一层定义而不导出子 make 进程里根本拿不到等于白写。如果有多个导出方比如modA和modC都导出了你需要的符号可以用空格分隔多个路径KBUILD_EXTRA_SYMBOLS : /root/modA/Module.symvers /root/modC/Module.symvers2.4 怎么验证依赖关系真的建立起来了改完之后不要急着insmod先做两步验证grep custom_multiply /root/modA/Module.symvers modinfo /root/modB/modB.ko | grep dependsmodinfo里的depends字段如果能看出对modA的依赖说明 modpost 阶段已经正确识别了跨模块符号。加载的时候也要注意顺序先insmod modA.ko再insmod modB.ko否则内核解析不了modB里的未定义符号照样会加载失败。3. 符号本身没导出或导出方式不对EXPORT_SYMBOL、GPL 与 __this_module3.1 函数实现存在但没加 EXPORT_SYMBOL跨模块问题之外另一个高频原因是你引用的函数在内核源码或者你自己的模块里其实实现了但它没有通过EXPORT_SYMBOL或EXPORT_SYMBOL_GPL导出。内核模块的符号导出不是默认行为它需要显式声明。假设你在一个内核文件里找到了这个实现int foo_bar(void) { return 42; }但如果文件底部没有对应这行EXPORT_SYMBOL(foo_bar);那么其他模块引用foo_bar时modpost 就会认为它没有被导出直接报 undefined。编译器在编译阶段不会拦你因为头文件里只要声明了int foo_bar(void);语法检查就是通过的链接期的符号可见性只有 modpost 会严格把关。我常用的一个排查动作是直接看目标文件的符号表nm /path/to/foo.o | grep foo_bar如果输出里是T foo_bar表示它是全局函数符号可以被导出如果是小写的t foo_bar说明它是static的局部可见即使你补了EXPORT_SYMBOL也没用得先把它改成非static。这个细节经常让人排查很久因为光看代码根本发现不了问题必须借助nm看 ELF 层面的可见性。3.2 EXPORT_SYMBOL_GPL 和模块许可证的坑内核源码里很多核心符号用的是EXPORT_SYMBOL_GPL这意味着只有声明为 GPL 兼容许可证的模块才能使用。如果你的模块没有写MODULE_LICENSE(GPL)或者写成了别的许可证modpost 一般会给一个WARNING而不是直接失败WARNING: modpost: GPL-only symbol used by non-GPL module: mutex_lock这类警告很容易被当成无害噪音忽略掉但到了insmod阶段内核会因为许可证不匹配而拒绝加载或者运行时报出“disagrees about version of symbol”之类的错误。所以项目里如果引用了EXPORT_SYMBOL_GPL的符号模块文件里就老老实实写MODULE_LICENSE(GPL);写GPL v2、Dual MIT/GPL也可以只要属于 GPL 兼容集合就行。某些驱动为了商业考虑不想用 GPL 许可证那就得刻意避开所有EXPORT_SYMBOL_GPL的符号调整自己的实现方案没有第三条捷径。3.3 特殊案例__this_moduleundefined 其实就是缺 MODULE_LICENSE有一种报错看起来最吓人因为符号名特别抽象ERROR: modpost: __this_module [/root/mydrv/mydrv.ko] undefined!我第一次见到的时候完全无从下手还以为是编译器内部符号出了问题。后来才明白__this_module是每个模块的“自引用”符号内核加载模块时靠它来识别模块对象本身。这个符号不在任何.c文件里显式定义而是由 kbuild 在你构建模块时生成的.mod.c文件里构造的。如果.mod.c生成有问题最常见的原因就是模块源文件里缺少MODULE_LICENSE、MODULE_AUTHOR、MODULE_DESCRIPTION这一组宏。kbuild 在生成模块信息的时候发现元数据不完整导致__this_module没有被正确构造于是 modpost 报出 undefined。补上MODULE_LICENSE(GPL); MODULE_AUTHOR(Your Name); MODULE_DESCRIPTION(Your module description);再重新make问题基本就消失了。这个案例我一直记着因为它提醒我看到一个奇怪的报错先别急着怀疑代码逻辑很可能是构建元数据层面的缺失。4. 内核配置与版本差异带来的符号错位CONFIG_MODVERSIONS 与头文件不匹配4.1 符号版本机制CRC 校验是怎么参与进来的大多数发行版内核默认开启了CONFIG_MODVERSIONSy这个配置让所有导出的符号都带一个 CRC 值。CRC 是根据函数的原型、参数类型、返回类型等运行时签名信息计算出来的不是简单的字符串哈希。当模块引用一个导出符号时modpost 会在Module.symvers里记录下对应的 CRC。真正加载模块时内核会比对模块里记录的 CRC 和当前内核符号的 CRC 是否一致。不一致就会抛modB: disagrees about version of symbol custom_multiply这个问题有时候会伪装成 modpost 的 undefined 错误出现。比如你手上有一份旧版本的内核模块源码函数foo_bar在新内核里已经改了参数你的模块头文件是新的实现引用的符号名依然存在但导出的 CRC 已经和旧的Module.symvers对不上了。这时候 modpost 可能直接判定符号不可用报 undefined即使侥幸编译通过insmod阶段也会被版本校验拦下来。想确认当前内核的模块版本机制开没开可以查grep CONFIG_MODVERSIONS /lib/modules/$(uname -r)/build/.config输出CONFIG_MODVERSIONSy就是开了。内核开发机里modprobe --dump-modversions /path/mod.ko能把模块引用的所有符号 CRC 列出来和内核里的比对排查版本错位很快。4.2 最常见的内核头文件不匹配我见过最多的“版本不一致”并不是内核源码更新导致的而是构建环境和运行环境压根不是同一个内核。有人图方便在一台 5.15 内核的机器上编好.ko拷贝到 5.10 内核的机器上加载结果自然是一堆符号解析失败。排查方法第一条永远是对版本uname -r ls -l /lib/modules/$(uname -r)/build如果你在别的机器上交叉编译一定要确保KERNELDIR指向的是目标机器相同版本的内核源码树或 headers 包。在 Ubuntu/Debian 上就是安装linux-headers-$(uname -r)然后让/lib/modules/$(uname -r)/build这个软链接存在且指向正确位置。这个软链接一旦缺失或者指错你会看到一连串诡异的 modpost 报错因为 kbuild 根本拿不到完整的内核导出符号表。4.3 符号所在的内核功能没被编译进去还有一种情况符号在内核源码里存在但那部分功能并没有被编译进当前内核。典型例子是你在代码里调用了某个 netfilter 的辅助函数但当前内核的.config里把CONFIG_NETFILTER相关的某些子项设成了n。符号没有编译进去自然不会进入Module.symversmodpost 只能报 undefined。这种坑很难从源码层面发现因为源码里明明有函数定义。排查时先在运行环境里确认符号是否存在grep T symbol_name /proc/kallsyms如果查不到就去检查对应内核功能的配置项决定是换一种实现方式还是在当前内核配置下重新编译并安装一个功能完整的内核。重新编译内核工程量不小但对那些必须依赖特定内核特性的项目来说这是绕不过去的一步。5. Makefile 写法与静态库链接导致的符号静默丢失5.1 外部模块 Makefile 的双段结构你真的理解了吗外部模块的 Makefile 有个经典写法ifneq ($(KERNELRELEASE),) obj-m : mymod.o else KERNELDIR ? /lib/modules/$(shell uname -r)/build PWD : $(shell pwd) default: $(MAKE) -C $(KERNELDIR) M$(PWD) modules endif这个结构第一个else分支是给第一次make用的它负责跳进内核源码树调用 kbuildifneq分支才是 kbuild 进入模块目录后真正执行的模块定义部分。这里如果写错了比如把obj-m放到了else里modpost 阶段就会找不到目标模块报出各种奇怪的错误。我第一次写外部模块 Makefile 时就因为这个双段结构没搞明白把变量放错位置折腾了大半天。5.2 多个源文件构成的模块要注意 obj-m 和 xxx-objs 的配合当模块由多个.c文件构成时不能直接写obj-m : mymod.o再加上一堆.o而是要用连字符变量obj-m : mymod.o mymod-objs : main.o helper.o如果这里写错比如只写mymod-objs而忘了定义obj-mkbuild 不会报错但也不会把你的对象链接进模块最后 modpost 阶段同样会报一堆 undefined。这类问题隐蔽在“编译命令全部正常唯独链接产物不对”的表象下面建议出问题先查一眼mymod-objs是否和实际文件名一一对应。5.3 静态库 .a 参与链接时符号会被“惰性提取”吃掉有些内核模块会把一部分代码预先打包成静态库比如libhelper.a然后在 Makefile 里这么写mymod-objs : main.o libhelper.a编译过程看起来一切正常但最终 modpost 报my_static_funcundefined。原因在于链接器处理.a归档文件时有一个特性默认情况下它只从归档库里提取那些“已经被引用”的目标文件。如果my_static_func是libhelper.a内部某两个文件互相依赖之后才暴露出来的链接器可能没有把对应的目标文件拉进链接流程这个符号就成了悬空引用。解决办法是用--whole-archive强制链接器把整个归档库的所有目标文件都包含进来mymod-objs : main.o libhelper.a LDFLAGS_mymod.o : -Wl,--whole-archive这个参数会显著增加.ko的体积因为归档库里所有代码都被装进去了但它能解决静态库符号被静默丢弃的问题。非必要不推荐大面积使用更多时候应该检查归档库的组织方式确保接口符号所在的目标文件能被正常引用到。6. 一个完整案例从报错出现到修复落地的全过程6.1 案例背景与环境为了把上面的几个知识点串起来我用一个实际调试过的场景做完整复盘。场景是这样的我有两个外部模块modA导出一个简单的函数demo_multiplymodB调用它实现一个 netfilter 钩子。构建机器内核版本是 6.1.0开发目录在/root/module_demo。先构建modAMakefile 正常源文件里有EXPORT_SYMBOL(demo_multiply)整个流程一次通过。然后切到modB目录执行make报错ERROR: modpost: demo_multiply [/root/module_demo/modB/modB.ko] undefined!6.2 逐步排查链路我没急着改代码先顺着报错信息问了自己三个问题。第一demo_multiply在modA里到底有没有导出检查grep demo_multiply /root/module_demo/modA/Module.symvers输出存在说明modA导出没问题。第二modB构建时有没有把这个Module.symvers传进去看modB的 Makefile发现KBUILD_EXTRA_SYMBOLS没有定义。问题定位到这里大概率就是这个原因。第三为了验证我先按最直接的方式重新构建一次make -C /lib/modules/$(uname -r)/build M/root/module_demo/modB modules \ KBUILD_EXTRA_SYMBOLS/root/module_demo/modA/Module.symvers这次 modpost 没有报 undefined但冒出一条新提示WARNING: modpost: missing MODULE_LICENSE() ?这个警告预示__this_module的坑要来了。我打开modB.c一看果然文件里只写了MODULE_AUTHOR和MODULE_DESCRIPTION唯独少了MODULE_LICENSE。这可能是早期复制代码时删掉了。补上MODULE_LICENSE(GPL);再次构建警告消失.ko顺利生成。6.3 加载验证构建通过不等于万事大吉我还做了加载验证sudo insmod /root/module_demo/modA/modA.ko sudo insmod /root/module_demo/modB/modB.ko dmesg | taildmesg里能看到modB成功调用demo_multiply打印的信息。这里强调先加载modA再加载modB是因为modB依赖modA导出的符号加载顺序反了内核会直接拒绝。这个案例其实一点都不复杂但它几乎把本文前面提到的两个高频问题全踩了一遍跨模块Module.symvers缺失以及MODULE_LICENSE缺失导致的元数据不完整。很多新手在这类问题上耗一整天的原因不是问题本身有多难而是没有按“符号在哪里导出、构建时有没有把导出信息传进来、模块自身元数据是否完整”这个顺序去排查。7. 经验汇总排查步骤、常用命令与避坑清单7.1 遇到报错后按这个顺序查我把这几年的排查经验收敛成一个固定顺序每次遇到ERROR: modpost undefined都按这个思路走先确认符号本身是否存在。用grep T symbol_name /proc/kallsyms普通用户可能受限加sudo看运行内核里有没有或者在内核源码里grep -rn EXPORT_SYMBOL.*symbol_name看有没有导出声明。确认构建环境与运行环境的内核版本一致。uname -r、ls -l /lib/modules/$(uname -r)/build这两条命令几秒钟就能排除最大嫌疑。确认模块是否依赖其他模块导出的符号。如果是检查Module.symvers是否被KBUILD_EXTRA_SYMBOLS正确传入。确认模块自身元数据完整。MODULE_LICENSE缺失时会出现__this_moduleundefined这个问题排查速度是最快的。7.2 常用验证命令速查查看内核符号是否存在以及是否可加载sudo cat /proc/kallsyms | grep T symbol_name查看模块目标文件里的全局函数符号nm /path/to/module.ko | grep T symbol_name查看模块引用的符号和 CRCmodprobe --dump-modversions /path/to/module.ko查看当前内核是否开启符号版本控制grep CONFIG_MODVERSIONS /lib/modules/$(uname -r)/build/.config查看构建目录的导出符号表cat /lib/modules/$(uname -r)/build/Module.symvers | grep symbol_name彻底清理构建产物make clean7.3 几个容易忽略的细节第一构建产物里的.cmd文件会“记住”旧符号。有时候你改了代码、加了EXPORT_SYMBOL重新make却依旧报同样的 undefined。问题往往出在 kbuild 复用了旧的.cmd和.o文件没真正重新编译。遇到这种情况不要犹豫直接make clean必要时手动删掉模块目录下的*.cmd文件再重来。第二多个源码文件里同名符号会互相干扰。模块比较大、文件比较多的时候可能在a.c和b.c里都定义了一个非static的同名辅助函数。链接器合到一起后符号归属变得混乱modpost 可能报出指向完全错误位置的 undefined。解决方法是给辅助函数加static或者统一改名字保持符号唯一性。第三交叉编译时要额外留意架构相关的符号。比如某些 32 位架构下汇编导出的符号名会带下划线前缀C 语言里引用时又自动处理掉了modpost 在对比符号表时可能因为前缀不一致而判定符号不存在。如果项目涉及交叉编译建议先用nm确认目标架构下符号在 ELF 文件里的真实名字再判断是不是架构前缀引发的问题。第四不要忽略WARNING: modpost开头的提示。我见过不少同事看到 WARNING 就跳过结果后面加载时被依赖关系、许可证问题折腾得不行。modpost 给出的每一个提示几乎都有实际意义要么影响当前构建要么影响运行时加载值得花几十秒查清楚再继续。这些经验是在一次次“编译过了、加载失败、查符号、重编、再验证”的循环里攒出来的。说句实话ERROR: modpost undefined这个错误本身并不可怕它反而是内核构建系统里最有价值的一道防线在.ko还没生成之前就帮你把悬空引用抓出来总比模块装上去之后才在运行日志里炸开要好得多。把符号的来龙去脉搞清楚这个报错就不再是拦路虎而是你理解内核模块构建机制的一扇门。