资讯动态

从undefined reference到链接器:C/C++编译链接排查指南

发布时间:2026/10/26 14:58:53 来源:尧图企业网站定制
undefined reference to foo这行报错我猜每个写 C 或 C 的人都在它面前坐过至少一个下午。我第一次遇到的时候把源码翻了三遍确信函数就在那儿声明也没写错可编译器就是说找不到。后来才明白那个下午我输给的不是代码而是编译和链接这条链路上一个我从来没认真看过一眼的环节——链接器。这篇东西不打算复述教科书里预处理→编译→汇编→链接四步流程图那玩意儿百度一搜一大把看完还是不会排查问题。我想聊的是这条链路上每一步究竟生成了什么、链接器到底按什么规则做决策、以及当它拒绝工作的时候你怎么一步步把原因揪出来。读者对象大概是这样的写过一些代码能跑通 Hello World但一旦项目变成十几个源文件加两三个第三方库就开始靠重启 IDE 和玄学清理缓存过日子的人。如果你正好卡在这个阶段下面这些内容应该能帮你省掉不少试错的夜晚。1. 从源码到进程镜像四个阶段各自到底产出了什么很多人对这四个阶段的理解停留在名字上知道有这回事但说不出每一步的产物长什么样、能不能单独拿出来看。这个模糊地带恰恰是排查问题的最大障碍——你连中间产物都没见过怎么判断问题出在哪一步1.1 预处理不只是把宏展开那么简单预处理阶段的产物是展开后的纯 C/C 源码扩展名通常写作.iC或.iiC。想亲眼看看它长什么样一条命令就够gcc -E hello.c -o hello.i打开这个文件你会吓一跳——几百行的程序变成几万行因为#include stdio.h被整个头文件的内容原地替换了连系统头文件里那些条件编译分支、__attribute__修饰、内联函数定义全都进来了。我第一次打开.i文件的时候最直观的感受是原来我写的代码只占最终编译输入的百分之几。这个阶段干的活可以列成一份清单宏替换、文件包含、条件编译#ifdef/#if、注释删除替换成一个空格注意是替换不是直接删否则int/**/x;会变成intx;、行号和文件名标记# 12 hello.c这种让后续编译器报错时能定位回原始文件。提示-E配合-dM可以只输出所有已定义的宏排查这个宏到底有没有生效的时候比翻代码快得多。为什么值得单独看这一步因为大量编译错误其实是预处理阶段的问题——头文件路径配错、宏定义顺序不对、不同头文件里同名宏打架、#ifdef分支走错了。这类问题的报错信息往往指向某个系统头文件的深处看起来跟你写的代码毫无关系但如果把.i文件拉出来搜一下关键字三分钟就能定位。还有一个常被忽略的点预处理是逐文件独立进行的没有任何跨文件的信息。这解释了后面很多现象——比如为什么宏定义不会跨源文件生效为什么头文件里的static变量在每个.c里都有一份独立副本。1.2 编译阶段优化器在你看不见的地方动手脚编译把.i变成汇编代码.s命令是gcc -S。这个阶段是真正意义上的翻译词法分析、语法分析、语义检查、中间表示生成、优化、目标代码生成一整套下来。我建议每个对性能或调试有要求的人都至少看过一次自己代码的汇编输出尤其是开着-O2的时候。因为优化器会做一些让源码层面完全无法理解的事情把你的循环展开、把函数内联掉、把变量完全消掉你在调试器里怎么都打不出这个变量的值就是因为它在汇编里已经不存在了、把看似无关的两次内存读取合并成一次。gcc -S -O2 hello.c -o hello.s这里有个特别容易踩的坑我在实际项目里遇到过不止一次调试构建和发布构建的行为不一致。代码在-O0下跑得好好的切到-O2就出问题。绝大多数情况不是编译器有 bug而是代码本身存在未定义行为比如有符号整数溢出、越界访问、未初始化变量、违反严格别名规则优化器只是合法地利用了这些假设。-O0恰好没触发而已。所以看到一个只在优化后出现的诡异问题时第一反应应该是回去查未定义行为而不是去关优化。关优化只是把症状藏起来。另外一个细节-S出来的汇编是针对特定目标架构的。同一份 C 代码gcc -S和aarch64-linux-gnu-gcc -S出来的东西完全不是一回事。这也是交叉编译的第一个落脚点。1.3 汇编与链接把填空这件事做完汇编阶段把.s变成可重定位目标文件.o这一步本身逻辑很轻——基本上是把汇编指令逐条编码成机器码同时生成符号表和重定位表。真正复杂的是最后一步链接。这里有个关键认知也是我觉得理解整个体系的分水岭编译器在生成单个.o文件时根本不知道其他文件里有什么。它看到你调用了一个函数foo()但它不知道foo的地址甚至不知道foo到底存不存在。于是它做两件事在.o里留下一个这里需要foo的地址的记录重定位项在符号表里标记foo是一个未定义符号undefined。后面的事情全部交给链接器把所有.o和库收集起来拼一张全局符号表检查每个未定义符号能不能找到唯一定义找到之后回填地址。找不到就是undefined reference找到多个就是multiple definition。gcc -c hello.c -o hello.o # 汇编 gcc hello.o -o hello # 链接gcc这个驱动命令其实是个调度器它根据文件扩展名决定调哪几个工具。.c走完整流程.o直接进链接.a/.so交给链接器处理。知道这一点后你就明白为什么gcc hello.o -o hello不需要再编译一遍——它只是把链接器ld拉起来干活。2. 拆开一个目标文件链接器的原料长什么样想搞懂链接器最好的办法是先看它吃进去的东西。.o文件是个二进制文件但它的结构是高度规整的用readelf和objdump完全可以一层层剥开看。2.1 节区、段以及文件视图和内存视图的分野一个典型的.o文件里包含这些节区节区名内容是否占文件空间是否占内存.text机器指令是是只读可执行.rodata只读数据、字符串常量是是只读.data已初始化的全局/静态变量是是可读写.bss未初始化或初始化为 0 的全局/静态变量否是可读写.symtab符号表是通常不加载.strtab符号名字符串是通常不加载.rel.text代码段的重定位项是通常不加载.debug_*调试信息是不加载.bss那一行值得单独说一下。static int buf[1024 * 1024];这样一个数组在.o文件里几乎不占空间因为.bss只记录需要多大不记录内容——反正全是零加载时操作系统给你清零的匿名页就行。这个设计的实际影响很直接大数组放在全局作用域比放在栈上安全得多栈空间通常只有几 MB而.bss可以轻松吃掉几百 MB。注意.bss不占文件空间但占虚拟内存。判断一个程序的真实内存占用不能只看可执行文件大小。readelf -S hello.o可以看到完整的节区列表和每个节区的大小、偏移、对齐要求。这些信息在排查为什么我的可执行文件这么大的时候很有用——通常罪魁祸首是没 strip 的调试信息或者静态链接进来的库。至于节和段的区别节section是链接视图里的概念段segment是加载视图里的概念。链接器会把多个属性相同的节合并成一个段写进程序头表program header。readelf -l看的是段readelf -S看的是节。理解这个区分才能在看到段错误发生在某个地址时反推出它落在哪个段、大概对应哪段代码。2.2 符号表链接器眼中的通讯录用nm hello.o或者readelf -s hello.o你会看到每个符号一行前面带个字母。这几个字母的含义值得背下来因为排查问题时它们是最快的判断依据T/t在.text段有定义大写是全局外部可链接小写是局部staticD/d在.data段有定义已初始化B/b在.bss段未初始化R/r只读数据U未定义需要别的文件提供W/w弱符号weakCcommon 符号老式 C 的未初始化全局变量现代编译器默认用-fno-common放进.bss看nm输出是定位undefined reference最快的路子。假设链接时报undefined reference to init_config你直接nm一下那个.o如果看到U init_config说明调用方没问题是定义方没提供如果看到的是T init_config但链接还是报错那就是链接顺序或者库路径的问题。这两种情况处理方式完全不同先分清再说。符号的绑定属性binding也很关键LOCAL只在本文件可见GLOBAL全局可见WEAK是全局但允许被覆盖。还有visibility属性default/hidden/protected这个在动态库场景下直接决定了符号会不会被导出。2.3 重定位表链接器真正要填的那些空重定位是链接器最核心的工作但也是最容易被忽略的概念。原理其实不复杂编译单个.o的时候凡是涉及外部符号地址、跨节区跳转的地方编译器都先填一个占位值通常是 0 或者节内偏移然后在重定位表里记一笔这里的值需要在链接时修正。objdump -r hello.o或者readelf -r hello.o可以看到这些条目。每一条包含三样东西要修正的位置偏移、修正的方式重定位类型、以及修正时依据的符号。重定位类型在不同架构上名字不一样x86-64 上常见的是R_X86_64_PC32相对寻址用于函数调用、R_X86_64_32、R_X86_64_PLT32、R_X86_64_GOTPCREL等等。你不需要记住每个类型的编码格式但要知道它们的区别大致是用什么公式算出最终值。为什么这个知识点有用因为它解释了一类非常隐蔽的现象同一个符号在不同架构上能不能链接成功可能不一样。比如某些 32 位架构下代码段和数据段的距离超过 ±2GB 就没法用相对寻址链接器会直接报relocation truncated to fit。这类错误在交叉编译或者链接超大程序时偶尔会撞上知道重定位原理你就知道问题出在寻址范围而不是代码本身。3. 符号解析的暗规则链接器不主动告诉你的部分链接器有一整套判定规则文档里写了但没人会主动去读。而这些规则恰好解释了大量莫名其妙的链接行为。我把它叫做暗规则是因为它们几乎不会主动报错只是默默按规则办事然后让你在别的地方吃亏。3.1 强符号、弱符号与那条不成文的优先级先说分类。函数名、已初始化的全局变量属于强符号。未初始化的全局变量在老式编译器下是common 符号现在多数默认-fno-common后归到弱符号范畴。显式用__attribute__((weak))标记的也是弱符号。链接器处理多条同名符号定义的规则是不允许出现两个强符号出现就是multiple definition错误一个强符号加若干弱符号选强符号弱符号的定义被丢弃全是弱符号选占用空间最大的那个common 符号的规则。这套规则有一个非常实际的用途提供默认实现的覆盖机制。比如某个模块提供一个弱定义的默认钩子业务方如果想接管就在自己的文件里写一个强定义的同名函数链接器自动选强的那份。这个技巧在嵌入式开发和底层库里很常见好处是不需要改任何配置链接期就完成了替换。但反过来也要警惕如果两个人不小心在同一个工程里各写了一份同名的非 static 全局函数其中一个还带__attribute__((weak))那就会出现代码明明写了却没被执行的诡异现象而且没有任何报错。弱符号是排查噩梦的主要来源之一。一个更隐蔽的坑头文件里写const int MAX_NUM 100;。在 C 语言里const全局变量默认是外部链接跟 C 相反C 里const默认内部链接。如果这个头文件被多个.c包含就会产生多个同名定义。老式编译器下它们是 common 符号能合并-fno-common下就是多个强符号直接报重复定义。同一份代码换个编译器版本就编译不过根源往往在这里。稳妥的写法是static const或者加extern在头文件里只做声明。3.2 静态库的链接顺序为什么能决定项目成败静态库.a本质上就是一堆.o用ar打包起来的归档文件ar rcs libutils.a utils.o helper.o关键在于链接器从左到右扫描命令行上的文件和库且对静态库只扫描一遍。扫描到libutils.a的时候它只挑出当前的未定义符号集合中需要的那些.o拉进来其余的直接跳过而且不会回头再看。这就产生了一个非常经典的失败场景gcc main.o -lutils -lcore -o app假设main.o用到core里的函数utils里的函数又依赖core。链接器处理utils时把需要的.o加进来此时引入了新的未定义符号需要core提供但utils后面才是core看起来没问题。可如果顺序反了写成-lcore -lutils处理core时还没有任何东西需要它整个库被跳过后面处理utils引入新的未定义符号但core已经扫描完了不会回去——于是undefined reference。解决方式有三种从轻到重调整顺序被依赖的放后面重复写-lcore -lutils -lcore粗暴但有效用 group 参数-Wl,--start-group -lcore -lutils -Wl,--end-group让链接器在这组库里反复扫描直到符号不再变化。编译大型 C 项目时这套顺序理论几乎必然会撞上一次。而且它的报错信息跟库没装长得一模一样很多人第一反应是去检查库路径折腾半天才发现是顺序问题。3.3 C 名字修饰为什么声明完全一样还是找不到符号C 支持重载所以编译器会把函数名和参数类型一起编码成符号名这个机制叫 name mangling。int add(int, int)编出来可能是_Z3addiidouble add(double, double)编出来是_Z3adddd——名字不同链接器才能区分。这套机制本身没问题问题出在跨语言调用。你的 C 代码调用一个 C 库的函数如果头文件里没有extern C包裹编译器会按 C 规则去修饰这个名字然后去符号表里找_Z3fooi而 C 库里只有foo——当然找不到。标准做法是在头文件里加#ifdef __cplusplus extern C { #endif int foo(int x); #ifdef __cplusplus } #endif我看到过有人在.cpp里直接extern C int foo(int);也能解决问题但如果头文件被多个文件包含逐个改调用点迟早漏。统一在头文件里处理是正确的做法。还有一种情况C 的static成员变量和模板实例化。模板只有在被实例化时才产生符号如果你在头文件里声明了模板但没在实际使用中触发某个特化那个特化的符号就不存在。这类问题在跨编译单元使用模板时偶有发生特征是报错指向的符号名特别长一眼能认出是模板修饰名。4. 静态链接与动态链接三种代价的权衡这部分是设计决策层面的事情。选静态还是动态不是哪个更先进的问题而是一组取舍。4.1 静态链接的真实收益与代价静态链接把库代码直接复制进最终可执行文件。收益很直接产物是自包含的拷到另一台机器上只要架构兼容就能跑不依赖目标环境装了什么版本的库。代价也直接体积膨胀和升级困难。一个用静态链接的 C 程序光标准库和异常处理机制就能贡献好几 MB。更要命的是如果库里出现安全缺陷你必须重新编译发布整个程序而不能只换一个.so。还有一个容易被忽视的代价同一份库代码在多个进程里各有一份。动态链接的库在物理内存里只需一份被所有进程共享静态链接的程序跑十个进程就有十份。在容器化部署的场景下这个差异会直接影响镜像体积和内存占用。要不要静态链接我的判断标准是看部署环境。如果目标机器完全可控、库版本一致、追求启动速度静态链接合适如果追求可维护性、需要热修复、跑在不确定的环境里动态链接更合理。混着用也没问题——关键库静态链接其余动态。4.2 动态链接的运行时链路从 GOT 到 PLT动态链接的复杂度全在运行时。程序加载时动态链接器要先跑起来读取每个.so的动态段加载依赖做符号解析然后才开始执行你的main。想看清楚这个过程LD_DEBUG是个好工具LD_DEBUGlibs,files ./app 21 | head -50输出会告你它依次找过哪些路径、加载了哪些库、以及最终绑定了哪个符号。符号解析本身有两种时机加载时绑定和延迟绑定。后者是默认行为靠 PLT过程链接表和 GOT全局偏移表配合实现。机制大概是这样你的代码调用一个外部函数编译出来是一条跳到 PLT 表项的指令。PLT 表项第一次被调用时跳回动态链接器的解析例程后者找到真实地址写进对应的 GOT 表项然后再跳过去执行。第二次调用时PLT 表项直接读 GOT一步到位。这就是所谓的 lazy binding。它的收益是启动快不需要在启动时解析所有符号代价是首次调用有额外开销而且在多线程环境下有一点点同步开销。如果想关掉链接时加-Wl,-z,now启动时全部绑定完出问题也能在启动阶段就暴露而不是跑到某个冷门分支才崩。GOT和PLT的存在本质上是位置无关代码的副产品。程序加载到哪个虚拟地址是不确定的受 ASLR 影响所以代码里不能写死绝对地址。所有外部符号的地址都通过 GOT 间接访问GOT 表项在加载时被填好。4.3-fPIC到底解决了什么问题编译共享库时必须加-fPICPosition Independent Code这条规则人人都知道但为什么是必须原因在于共享库的代码段要在多个进程之间共享同一份物理内存。如果代码里含有绝对地址重定位项每个进程加载到不同地址时都得改代码段的内容那代码段就没法共享了还得变成可写安全性下降。-fPIC让所有对外部符号的访问都走 GOT代码段本身不含任何需要修改的绝对地址于是可以放在只读页里被所有进程共享。代价是额外的一次内存访问。每次调用外部函数、每次读外部全局变量都要先读 GOT 再跳转。这就是位置无关代码的性能损耗来源通常很小但确实存在。现在的发行版默认对可执行文件也开启 PIE-fPIE -pie原因主要是安全——ASLR 需要程序可以被加载到任意地址。如果你的程序里有些奇怪的固定地址假设PIE 可能会让它崩-no-pie可以临时绕过但那基本等于承认代码有问题。提醒从源码编译第三方库时如果忘了加-fPIC链接成.so时会报relocation R_X86_64_32 against ... can not be used when making a shared object; recompile with -fPIC。看到这个报错不用怀疑回去把编译选项加上重新编一遍就行。5. 排查实录几类高频故障的定位路径前面讲的都是原理这部分换成实战。我按症状 → 可能原因 → 验证手段的顺序来组织方便你在出问题时对照着走。5.1undefined reference的三种成因与分辨方法这个报错太常见了但它的成因至少有三类处理方式完全不同。第一步永远是分辨属于哪一类。第一类定义根本不存在。你可能忘了把某个.c加进构建脚本或者库根本没链上或者函数名拼错了尤其 C 下修饰名对不上。验证手段是直接查符号表nm -C 目标.o | grep 符号名如果只有U没有T说明确实没有定义。第二类定义存在但链接器没看到。这就是前面说的静态库顺序问题或者库路径-L没给对。特征是nm那个库文件明明能查到T 符号名但链接还是失败。进一步验证可以看链接器的搜索过程gcc ... -Wl,--verbose会打印它实际搜索的路径和库。第三类有定义但不可见。符号被static修饰了、被-fvisibilityhidden藏起来了、或者被符号版本脚本限制住了。动态库场景下这类问题特别多特征是编译期没问题运行时才报错。我给自己的排查顺序是先nm查定义在不在再看链接命令行顺序最后查可见性。三步走下来基本能覆盖九成情况。5.2 重复定义与改了头文件只重编了一部分multiple definition of xxx的成因比较单纯就是同一个强符号被定义了多次。常见来源头文件里定义了非inline的函数或非static的变量然后被多个.c包含。但我要重点说的是另一类没有任何报错、只在运行时发作的问题头文件改了但只有部分目标文件被重新编译。场景是这样的你在config.h里给某个结构体加了一个字段然后只重编了a.cb.c还是用旧版本的目标文件。于是两个编译单元对同一个结构体的sizeof理解不一致——a.c按新布局分配和访问b.c按旧布局读写。链接器完全查不出来因为符号名和大小都不冲突。结果就是内存踩踏症状表现为各种莫名其妙的崩溃、数据错乱而且换个编译顺序可能就不崩了。这类问题的排查难度极高因为它看起来像随机 bug。我的经验是只要出现改了头文件之后才开始崩这个时间线第一反应就该是清理全量重建。如果全量重建后问题消失那就确认了是增量构建的依赖判定漏了头文件。根治办法是把头文件依赖写进构建系统。Makefile 里用-MMD -MP生成.d文件并 include 进来CMake 和 Meson 默认就处理这个。手工维护依赖列表迟早会漏。CFLAGS -MMD -MP -include $(OBJS:.o.d)5.3 静默故障ABI 不一致与符号版本比崩溃更难受的是不崩但结果不对。这类问题通常源于 ABI 不一致——两边对同一个接口的二进制约定理解不同。典型的触发点包括结构体对齐方式不同一端用了#pragma pack另一端没有、枚举大小不同-fshort-enums、bool的大小不同、C 标准库的 ABI 版本不同_GLIBCXX_USE_CXX11_ABI这个在混用不同版本工具链编出来的库时特别容易踩。C 标准库那个坑我印象很深某个第三方库是用旧 ABI 编的你的程序用新 ABI 编两边都编译通过、链接也通过但一传std::string就出问题——因为新旧 ABI 下std::string的内部布局完全不同。报错信息可能出现在任何地方就是不会告诉你根因是 ABI。验证手段是查符号名新 ABI 的std::string相关符号里带__cxx11旧的不带。nm -C 库文件 | grep cxx11一看便知。符号版本是另一个静默来源。动态库可以给符号打版本标签GLIBC_2.34这种程序链接时记录的是带版本的需求。换到一个版本更低的库上运行就会报version GLIBC_2.34 not found。这个相对还好至少报错明确。更麻烦的是同名符号的语义变化——版本号一样但行为改了那只能靠测试兜底。6. 工程化收口把编译链接纳入可控流程前面讲的都是出问题怎么查但更划算的做法是让问题少出。这部分聊几个工程层面的实践。6.1 增量构建的依赖判定要覆盖三类东西一个可靠的增量构建必须正确追踪三类依赖源文件、头文件、以及构建配置本身的变更编译选项、宏定义、链接参数。前两类靠-MMD -MP解决第三类经常被漏掉。举个具体例子你在 CMake 里改了target_compile_definitions加了个-DENABLE_FEATURE_X。如果构建系统没有把编译选项变了当成触发重编的条件那些已经编好的.o不会重新生成新宏在旧目标文件里根本没生效。结果就是你改了配置、跑了构建、看到的还是旧行为然后开始怀疑人生。CMake 在这方面做得比较好它会把编译命令行写进flags.make命令行变了就重编。手工维护 Makefile 的话需要自己把CFLAGS作为依赖加进去或者干脆用一个 stamp 文件记录配置哈希。还有一类容易被忽视的工具链版本变化。换了编译器版本之后最好全量重建一次。不同版本的编译器生成的.o混在一起偶尔会出现链接期或运行期的怪异问题虽然概率不高但真遇上了排查成本极高。6.2 符号可见性与链接期优化动态库默认导出所有全局符号这在大型项目里问题不小符号表巨大导致加载变慢、内部符号污染全局命名空间导致冲突、还给外部提供了不该用的接口。标准做法是编译时加-fvisibilityhidden然后对确实要导出的符号显式加__attribute__((visibility(default)))。这一加一减之间导出符号数量常常能从几万个降到几十个加载速度的提升相当可观。// 导出宏的常见写法 #define API_EXPORT __attribute__((visibility(default))) API_EXPORT int public_func(void);配合strip去掉调试符号、-Wl,--gc-sections配合-ffunction-sections -fdata-sections删掉没被引用的代码产物体积还能再压一截。这几个选项我一般直接写进发布构建的默认配置反正不影响调试构建。链接期优化LTO-flto是另一个可以尝试的方向。它把优化推迟到链接阶段让编译器能看到跨编译单元的调用关系从而做跨文件内联和更激进的优化。代价是链接时间变长而且-flto对工具链版本一致性要求很高——不同版本编出来的 LTO 中间代码可能不兼容混用时链接会失败。我的建议是性能敏感的发布构建可以开日常开发构建别开不然每次增量链接都要等半天。6.3 一套常备的排查工具箱最后把这套命令整理一下出问题时按顺序打一遍八成能定位到方向。目的命令关注点看符号定义与引用nm -C *.oU未定义、T已定义、W弱符号看节区布局和大小readelf -S file.o哪个节异常大看重定位项readelf -r file.o未解析的重定位看动态依赖ldd ./app有没有not found看运行时加载过程LD_DEBUGlibs ./app实际加载了哪个路径的库看 ELF 头与架构readelf -h ./app架构、位数、入口地址反汇编看实际代码objdump -d -C ./app函数是否被内联或删除看动态符号表readelf --dyn-syms ./app导出了哪些符号LD_DEBUG这个工具我想单独强调一下。程序在我机器上能跑、在服务器上不行这类问题九成是运行时找到了不同的库版本。只看ldd有时不够——它显示的可能是缓存里的路径而实际加载的未必一致。LD_DEBUGlibs会把整个搜索过程打出来哪个路径命中、为什么跳过一目了然。还有一点ldd本质上是设置环境变量后运行程序对于不可信的二进制直接ldd存在执行风险。更安全的替代是objdump -p ./app | grep NEEDED只读不跑。我个人在实际操作中的体会是编译链接这块知识的回报周期特别长——你花两天搞明白符号表和重定位可能在接下来三五年里持续地省时间。它不像学一个新框架那样立刻见效但每当遇到那些看起来完全不合理的构建错误时你会发现自己能直接跳到根因而不是在一堆猜测里打转。判断标准也很简单如果你现在遇到链接错误第一反应还是清理一下重新构建试试那说明这块知识还有补的空间当你的第一反应变成先 nm 看看符号在不在基本就入门了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑