
1. 链接器“挑”目标模块之前真正的决策起点是什么我最早被“链接器如何精准选择目标模块”这个问题逼到墙角是在一个嵌入式项目上代码明明编译生成了foo.ofoo.c里也定义了foo_init()这个函数可我在另一个文件里调用它链接时却报undefined reference to foo_init。我当时的第一反应是“编译器抽风了”第二反应是“是不是头文件声明和实参不匹配”折腾半天才发现问题是链接器在扫描那一大堆.o和静态库时根本没有把这个模块“选中”进链接过程。从那以后我才明白链接器不是一股脑把所有输入都塞进可执行文件它有一套非常严谨的选择规则这套规则的核心就是符号表。1.1 你交给链接器的不是源码而是一堆节区与符号表很多人从“写代码→编译→链接”这个流程里跳出来时总觉得链接器面对的是.c文件。实际上编译器更准确地说汇编器把每个源文件处理完之后生成的.o目标文件里已经没有源码的影子了。里面是一堆节区section比如.text放代码.data放已初始化全局变量.bss放未初始化全局变量.rodata放只读数据。除了节区的字节内容目标文件里还有两张关键的“清单”符号表symbol table和重定位表relocation table。符号表回答的是“这个文件里有哪些符号它们是定义还是引用”比如你在foo.c里写了一个函数int foo_init(void)在foo.o的符号表里它就是一个“已定义”的全局符号而你在main.c里声明并调用它main.o的符号表里它就是一个“未定义”的符号。重定位表回答的是“这个文件里哪些地方需要回填地址”比如main.o里有一条call指令目标地址目前是 0占位符等待链接器将来把foo_init的真实地址填进去。链接器的核心工作就是拿着这些符号表和重定位表决定哪些输入目标文件被“选中”再决定被选中的文件里哪些节区被合并进输出最后完成地址分配和重定位回填。注意“哪些输入文件被选中”不是一句废话式的前置动作它本身就是决定性的如果一个目标文件压根没被选中它的符号再完整、代码再正确也不会出现在最终产物里你调用它只会得到undefined reference。1.2 未定义符号表链接器手里的“购物清单”链接器选择目标模块的起点是维护一组“当前仍然未解析的符号”可以把它理解成一张购物清单。链接器从左到右扫描输入每遇到一个目标文件就做两件事先看看这个文件里定义的符号能不能把购物清单里的某些项划掉再看看这个文件里未定义的符号往购物清单里新增项目。举个例子。假设链接器先读到main.o发现它未定义foo_init同时定义了main于是购物清单里多了foo_init一项。接着读到foo.o发现它定义了foo_init正好清单里有这一项于是划掉同时foo.o里如果有新的未定义符号bar_init则加入清单。这样一路扫描下去等所有输入都处理完如果清单里还有未划掉的符号链接器就报undefined reference。这个机制里藏着“精准选择”的第一层逻辑一个目标文件只有能为当前购物清单提供“已定义符号”时才会被纳入最终输出如果它提供的符号和清单毫无关系链接器连看都不会多看它一眼。这一点对普通.o文件和静态库成员都是统一的只不过静态库的处理更严格一些我后面会专门讲。1.3 一个链接错误背后往往就是一次错误的选择很多看似千奇百怪的链接报错本质上都是“选择规则”的一些特殊分支。undefined reference to xxx这是最典型的选择失败。要么确实没有任何输入提供这个符号要么提供了但链接器没有去选它比如静态库放在命令行的最前面提前扫描过后面的目标文件又产生了新的符号需求于是“完美错过”。multiple definition of xxx这是选择过头了。两个以上的目标文件都定义了同名全局符号链接器在做“选定”之后发现重复定义不知道该用哪个只好报错。这个报错看起来和“精准选择”是矛盾的但仔细想它恰恰是选择规则在执行“唯一性”约束时的必然结果。relocation truncated to fit: R_X86_64_PC32 against symbol xxx这更像是“选中了模块但没选对布局”。符号找到了地址也分配了但目标地址和引用位置距离太远超出了指令编码能表达的范围。某种意义上说这是链接器在“选择目标模块”之后后续的地址分配阶段出了问题。我在实际排查链接问题时绝不会一上来就逐行读报错文本而是先问自己一个问题链接器当时到底有没有把这个符号所在的模块选进来如果没选进来原因是什么如果选进来了又是在哪个环节出了岔子。这比对着错误信息瞎猜要高效得多。2. 模块选择的两个粒度整个目标文件还是目标文件里的某个节区链接器并非只能以“文件”为单位做选择。对现代链接器来说选择粒度可以细到节区级别这也是很多人在做裁剪体积、做代码复用、做固件优化时容易忽略的关键点。2.1 默认行为一个外部引用就能拉进整个目标文件在默认情况下只要链接器因为某条未定义符号选中了一个普通目标文件那就是“整文件照单全收”。比如foo.o里既有foo_init函数也有一个debug_dump函数还有一堆全局变量哪怕main.o只引用了foo_init链接器也会把foo.o的全部节区搬进输出文件里。之所以会这样是因为节区之间可能存在隐式依赖编译单元内部的相对引用、静态函数、局部符号等都指望大家待在一起才能正确重定位。链接器宁可不做局部分析也要保证“一旦选中整体搬入”。这种整文件搬入在早期链接器里很常见优点是简单可靠缺点是浪费如果你的代码库里有大量“只用了其中一个函数”的模块最终可执行文件里会塞满没用到的代码体积膨胀而且可能触发额外的运行时初始化开销。2.2 节区级裁切-ffunction-sections加--gc-sections的组合为了做到更细粒度的选择主流工具链提供了一条组合路径编译期给每个函数、每组数据单独生成节区链接期再把这些“没有被引用到”的节区回收掉。编译端是 GCC/Clang 的-ffunction-sections和-fdata-sections原本整个.text节区里装着所有函数加上这个选项后每个函数会被放进独立的节区比如foo_init在.text.foo_initdebug_dump在.text.debug_dump。链接端是-Wl,--gc-sections链接器根据节区之间的引用图从入口点比如_start、main或链接脚本里标记的 KEEP 节区出发做一次可达性分析把所有无法从入口触达的节区直接丢弃只选择那些“可达”的节区输出。这样一来链接器对目标模块的选择就不再是“选文件”而是“选节区”。foo.o可能整体被选中但最终输出时只保留了实际用到的.text.foo_init和它依赖的少数数据节区其他函数的代码全部被丢弃。这个行为和“精准选择目标模块”直接相关当你能控制到函数级链接器的选择算法才会有用武之地。不过要注意--gc-sections不是银弹。如果代码里有通过函数指针动态调用、通过汇编引用、通过反射或者运行时注册机制触达的函数它们不会形成静态引用链很容易被错误地“回收”导致运行时调用崩溃。应对办法是给这些入口打上“不可回收”的标记比如__attribute__((used))或者在链接脚本里用 KEEP。2.3 KEEP 与链接脚本中的“强制选择”链接脚本里的 KEEP 是回收机制的“反向保险”。你告诉链接器“这个节区必须保留不许参与可达性分析后被丢掉”本质上就是强制选中目标模块。嵌入式开发中最典型的场景是中断向量表、启动代码、系统调用表它们没有静态的调用者但缺了它们整个程序根本无法启动。写字面写法SECTIONS { .isr_vector : { KEEP(*(.isr_vector)) } FLASH }这就是在链接脚本层面强行“点名”一个节区。KEEP 指令之所以存在也提醒了我们一个重要事实链接器默认的“精准选择”是数据驱动的它只按引用关系来判定生死并不理解业务语义。你作为开发者必须在链接描述里把那些“没有引用但必须有”的模块标记出来否则再聪明可达性分析也白搭。2.4 节区级选择带来的副作用别让调试器找不到函数用了-ffunction-sections --gc-sections之后链接器确实会变得更“抠门”但也不是没有副作用。首先每个函数单独一个节区会增加目标文件的节区头数量编译和链接阶段都会稍慢一些但这在现代项目里通常可以接受。其次被丢弃的函数在调试信息里往往还会残留导致你用 GDB 按函数名打断点时能看到符号列表但断点永远打不上因为它压根不在最终二进制里。第三节区级回收和异常处理.eh_frame、栈回溯信息之间会有微妙的相互作用某些编译器生成的数据虽然表面上没被引用实际上却被运行时库依赖这时需要配合-fno-asynchronous-unwind-tables之类的选项做权衡。我在项目里使用这个组合时会额外在编译参数里附上-ffunction-sections -fdata-sections -Wl,--gc-sections然后写一个 CI 检查比较链接前后最终二进制里某些关键符号是否存在防止哪天改动代码后一个函数被静默裁剪掉。这个习惯帮我避免过好几次线上诡异崩溃。3. 静态库成员拉取规则库的顺序为什么是“规定”不是“建议”静态库是“目标模块选择”最典型的战场。你用ar打包出来的.a文件里面平铺着一堆.o成员但链接器在扫描静态库时并不是把里面所有成员都当成普通.o那样全收。3.1 归档文件里每个成员都是独立待选模块一个.a文件本质上就是一个存档文件里面每个.o成员都保留了独立的符号表和节区。链接器在扫描到.a文件时只会在当前“购物清单”仍然存在的条件下逐成员检查这个成员定义的符号是否能满足当前未定义符号集合中的某几项。如果能就提取该成员把它当作普通目标文件处理也就是说它的所有节区都会被纳入链接过程如果不能这个成员就停留在库里不产生任何影响。这种“按需提取”的机制让静态库能够做到精确供给你的程序用了库里的printf链接器就会从libc.a里把printf所在的那个成员捞出来而不会把整个 C 标准库全部塞进去。这里有一个很容易踩的坑同一个.a文件里的某个成员如果本身又引用了库里另一个成员的符号但链接器扫描顺序已经过了那个成员那么会产生新的未定义符号后续要依赖其他输入来补充。很多循环依赖问题因此产生。3.2 从“只扫一遍”看库的顺序问题GNU ld 等传统链接器对命令行上文件的基本处理方式是从左到右扫描每个静态库“只完整扫描一遍”。这带来一个非常反直觉的后果先被链接的目标文件所产生的未定义符号只会去匹配它之后的输入文件它之前已经扫描过的静态库成员不会被重新拿出来做匹配。最经典的问题就是命令行的顺序gcc main.o -lfoo -o app如果main.o引用了libfoo.a里的符号这个命令能链接成功。但如果你把-lfoo放在main.o之前gcc -lfoo main.o -o app链接器先扫描libfoo.a此时购物清单里还没有main里引入的那些未定义符号所以没有任何成员被提取接着扫描main.o未定义符号出现但libfoo.a已经扫过且不会再被回头匹配最后只能报undefined reference。很多刚接触链接的工程师会觉得很离谱库明明就在那里符号也明明在里面怎么就是找不到原因就是一次扫描的先后顺序。为了照顾这种情况GCC 的gcc命令在调用链接器时通常会采用“按需重复扫描”或者让你用--start-group、--end-group把一组库包起来。3.3--start-group和--end-group让链接器回头再选遇到多个库相互依赖的情况你可以让链接器在这组库之间反复扫描gcc main.o -Wl,--start-group -lfoo -lbar -Wl,--end-group -o app--start-group和--end-group会让链接器在组内重复扫描库直到无法再解析新的未定义符号为止。这时的“选择逻辑”变得更有韧性第一次扫libfoo.a时可能一个成员也没提取但扫完libbar.a后再回到libfoo.a重新检查把之前因为缺符号而不提取的成员捞出来。代价也很明显链接时间变长因为要反复扫描所有组成员而且组内命令顺序不再那么关键但如果你把组外的大型库也卷进来会让链接器的工作量成倍增长。我的经验是组范围越窄越好能通过调整库顺序解决就不要滥用 grouping。3.4 弱符号、公共块与定义强度同为“定义”也有优先级库成员选择还有一个容易被忽略的维度符号定义强度。C 语言里的普通函数、全局变量是强符号多个强符号同时出现会触发多重定义错误。弱符号__attribute__((weak))不会和强符号冲突链接器在已经找到强符号定义时不会再额外选择弱符号所在的库成员但如果没有强符号弱符号定义会被选中。链接器选择目标模块时还会考虑“哪个定义先出现”。一个声称提供的符号如果已经满足了需求那么后续同名的定义可能被丢弃也可能触发冲突取决于强度和属性。这带来的实际影响是你在命令行里把提供同名符号的两个库都写上最终选中哪个取决于扫描顺序和符号强度而不一定是你心里默认的那个。这种“隐形选择”常常在升级第三方库、重构代码时突然爆发你会发现几十处症状诡异的多重定义或 Unexpected 行为最后查下来全是静态库选择顺序惹的祸。4. 动态链接阶段的目标模块选择另一套规则的“动态链接器搜索路径”项目标题里“链接器如何精准选择目标模块”放到动态链接场景下会出现两个完全不同但都叫“选择”的环节编译期链接器选择哪个动态库作为依赖运行期动态链接器根据“动态链接器搜索路径”选择加载哪个.so文件。两个环节分开看都不难合在一起就经常让人头大。4.1 编译期和运行期是两套“选择器”编译期你通过-lfoo或直接给路径/path/to/libfoo.so告诉链接器“我要用这个动态库里的符号”链接器读取它的动态符号表校验符号存在然后在生成的可执行文件里写下一个DT_NEEDED条目比如libfoo.so.1。注意编译期链接器并不会把libfoo.so的代码复制进可执行文件它只是“预约”了一个依赖。运行期程序启动后由动态链接器在 Linux 上是ld.so接手。它打开/proc/self/exe读取DT_NEEDED再根据“动态链接器搜索路径”的优先级去实际查找对应的.so文件加载进进程并完成符号重定位。这里的“选择”更像是在一堆可能的.so文件路径里做匹配同样叫libfoo.so.1的文件在不同目录里可能内容不一样动态链接器最终选中哪一个直接决定程序运行时用的是哪份实现。4.2 编译期链接器怎样把“依赖”写进可执行文件要理解搜索路径先得知道DT_NEEDED是怎么来的。当你这样写gcc main.o -L/opt/mylibs -lfoo -o app编译期链接器会去/opt/mylibs下查找libfoo.so同时也会去系统默认目录里找。如果找到的是动态库它就把某一条依赖写入可执行文件。这里有个细节很多发行版里libfoo.so是一个符号链接指向真正的版本化库比如libfoo.so.1.2.3。链接器写进DT_NEEDED的通常不是libfoo.so而是基于SONAME的libfoo.so.1。这就是为什么你换了库版本可执行文件仍然能找到新的libfoo.so.1。如果编译期链接器发现系统里同时存在/opt/mylibs/libfoo.a和/opt/mylibs/libfoo.so默认会优先选择动态库你用-static或显式给出.a路径才能强制选静态库。这个“优先选动态库”的规则也是目标模块选择的一部分很多构建脚本改一个环境变量就突然从动态变成静态就是因为搜索路径变化导致匹配到的文件类型变了。4.3 动态链接器搜索路径的完整优先级列一张表看清楚glibc 动态链接器查找依赖库时大致遵循下面的顺序不同平台略有差异但思路一致优先级机制说明与常见场景1DT_RPATH已废弃但兼容编译期通过-Wl,-rpath,/path写入仅影响当前目标不传继给子依赖若存在DT_RUNPATH则被忽略2环境变量LD_LIBRARY_PATH运行时临时指定适合开发调试在安全敏感场景下通常会被系统忽略或清空3DT_RUNPATH编译期通过-Wl,-rpath,/path或-Wl,--enable-new-dtags写入只影响当前目标不传递4/etc/ld.so.cache由ldconfig生成的缓存系统安装的共享库通常在这里命中5默认目录/lib、/usr/lib等最后的兜底路径这张表的价值在于它可以帮你快速解释一个经典现象你明明在编译期用-rpath指定了libfoo.so的路径程序跑起来却还是加载了系统目录下的旧版本。原因很可能是你用--enable-new-dtags生成了RUNPATH而运行机器上设置了LD_LIBRARY_PATH环境变量优先级高于RUNPATH于是动态链接器选择了另一个目标模块。与之紧密相关的是-rpath-link这个选项只影响编译期链接器去“间接查找”某个动态库的依赖不会写入最终可执行文件。很多人在交叉编译时混淆-rpath-link和-rpath结果编译能过运行时却提示找不到依赖库其实就是把“编译期寻找”和“运行期寻找”搞混了。4.4 同一个符号出现在多个共享库时动态链接器如何“拍板”如果一个符号在多个已加载的共享库中都有定义动态链接器的选择逻辑和“动态链接器搜索路径”一样值得注意。最常见的情况是可执行文件自身、依赖链上游的库、LD_PRELOAD指定库、以及正常DT_NEEDED里的库都可能提供同名符号。glibc 的规则是维护一个全局符号查找顺序可执行文件里的全局符号通常处于最高优先级默认使用 “全局符号作用域”然后是LD_PRELOAD指定的库再后是按加载顺序扫描的依赖链。链接器在重定位一个引用了foo的指令时会从头按这个顺序找第一个匹配的定义一旦找到就“锁定”后续即使有更高优先级的库也没用除非在特定作用域模式下。这个机制造就了两种常见的“选错模块”你LD_PRELOAD了一个自定义的malloc实现结果整个进程里所有对malloc的引用都指向了它程序崩得莫名其妙。可执行文件里自己定义了一个和某个共享库导出符号同名的全局函数默认情况下这个定义会“压住”库里的同名定义库内部对自身符号的引用也可能被重定位到可执行文件的那个函数上导致库行为大变。这时可以用-Bsymbolic或-Bsymbolic-functions让链接器在库内部优先把自己定义的符号绑死避免外部“抢占”。这本质上也是在干预目标模块的选择。动态链接场景下的“选模块”比静态场景更依赖运行时环境这也解释了为什么同一个可执行文件换一台机器、换一套环境变量行为就完全不一样。5. 链接脚本与重定位如何“点名”目标模块并让选择结果落地大部分项目里链接器自动决策就够了。但在嵌入式、内核、底层系统库场景你往往需要精确到“只链接这目录里的哪个目标文件里的哪个节区”甚至要手工决定多个同名输入模块的去留。这时候链接脚本是避不开的。5.1 输入节区描述链接脚本里的“选择过滤器”链接脚本里最常见的写法是SECTIONS { .text : { *(.text) *(.text.*) } }这里的*(.text)就是“把所有输入文件里的.text节区选中并放入当前输出节区”。星号本身是一个通配符表示匹配所有输入文件。你还可以明确指定某个目标文件SECTIONS { .text : { startup.o(.text) *(.text) } }这表示startup.o的.text节区会被放在最前面其余输入文件的.text放在后面。用这种方式实际上就是在告诉链接器这个输入模块优先选择其他模块排后面。对调试、启动时序敏感的代码这种“点名”非常有用。类似的过滤还可以用EXCLUDE_FILE排除特定目标文件比如.text : { EXCLUDE_FILE (*foo.o) *(.text) }这种逆向选择在某些多实例库聚合场景下特别有用同一个符号定义散落在不同目标文件里你希望排除某个文件以免它参与最终的符号解析和重定位。5.2 链接脚本里的 SELECT 和“全量选择”链接脚本里虽然没有一个叫SELECT的关键命令但KEEP、EXCLUDE_FILE、通配符组合起来已经能实现几乎所有“强制选择”的需求。这里特别要提醒的是普通.o文件在命令行上出现只要链接器遍历到它就默认全量选择除非--gc-sections在节区级回收。而静态库成员只有在符号匹配时才会被提取链接脚本里使用*(.text)时通常也不会把一个.a文件里的所有成员全部拉出来——脚本里的输入描述仍然遵循静态库成员的按需提取规则。如果你真想“全量强制选择”可以把.a解开成.o再传递或者用ar重新组织库的结构再或者在链接脚本里明确写出成员的路径例如.text : { libfoo.a(foo_init.o) /* 这里只表示该成员内有节区时会被匹配 */ *(.text) }链接脚本的精确粒度能解决很多 Makefile 层面解决不了的“谁先谁后、谁要谁不要”问题。它和命令行选项并不冲突反而是更原始、更底层的控制手段。5.3 重定位选择完模块还得把地址填对目标模块被选进来后下一步就是分配虚拟地址并把所有符号引用重定向到实际地址。重定位信息挂靠在每个节区上链接器在完成符号解析后对每一条显式重定位条目做计算。比如R_X86_64_PC32的公式通常是S A - PS是符号目标地址A是加数P是当前位置。如果目标模块选错了S就根本不是你以为的那个符号最终填进指令的值自然全是错的。有意思的是有些链接期错误看起来像是“地址放不下”根子上却是“模块选择错了”。比如链接器从错误的库里选了一个同名弱符号导致一个极小函数被塞进同一段代码区域但真正的强符号定义又不在于是后续引用这个符号的地址距离超过编码范围报出relocation truncated。遇到这种报错别只调堆栈大小先查查是不是模块选错了。6. 排查实操当一个“错误的目标模块”被选中时我依次做什么链接器选择目标模块的知识点再多最终都要落到“怎么排查”。这部分我总结一下自己的实操流程希望能帮你在遇到类似问题时少走弯路。6.1 让链接器把“选择过程”摊开看-t与-M链接器一般都有输出/跟踪选项。GNU ld 的-t--trace会在处理每个输入文件时打印一行记录一眼就能看出来哪个.o和.a成员被实际提取了。配合--trace-symbol foo_init还能打印“这个符号来自哪里、被谁引用”的过程定位静态库成员是否被跳过非常直观。-M--print-map则是把链接映射表完整输出所有输入节区被合并到哪个输出节区、标识和地址分配、符号最终地址全在里面。虽然输出量很大但排查“同一个符号为什么来自这个文件而不是那个文件”这份地图是最权威的。我的习惯是gcc main.o -lfoo -Wl,-M -Wl,-t -o app 21 | tee linkmap.txt然后直接在linkmap.txt里搜符号名和库成员名比反复改代码试链接要快得多。6.2 静态库成员提取的真相--why-extract与符号跟踪LLD 提供了一个非常有针对性的选项--why-extractpath会把每个静态库成员“为什么被提取/为什么没被提取”的原因写到指定文件里。这个选项简直是静态库选择问题的天敌。当你有一堆库、循环依赖、重复符号时看这个输出能迅速找到是哪条未定义符号触发了提取。如果没有 LL D用传统办法也能查先用nm列出目标文件和库成员的符号表再靠-t确认实际提取了哪些成员。具体思路是用一个最小复现命令把可疑的.o逐个用nm查看确认它定义的符号列表。确认当前购物清单就是编译命令里所有输入文件中最先出现未定义符号的那个文件用nm -u查看它的未定义符号。对比库成员符号表判断是否“宽慰”了未定义符号。如果没有就换一个思路看是不是库顺序造成扫描过早、或者是弱符号导致链接器认为已经有定义、不再提取该成员。6.3 动态模块选错的现场readelf -d与ldd的配合遇到动态链接器搜索路径相关的“选错库”我最先干两件事。第一用readelf -d app查看可执行文件的动态段确认里面到底写了哪些DT_NEEDED、DT_RPATH、DT_RUNPATH。这一步能告诉我们编译期链接器当时“选”了哪些依赖以及它留下的搜索路径线索。第二用ldd app或LD_DEBUGlibs ./app查看运行期动态链接器实际加载了哪些.soldd会显示每个依赖的实际路径LD_DEBUGlibs会打印逐条搜索路径的尝试记录能清楚看到“它在哪个目录尝试了哪个文件最后命中/没命中”。经典案例是系统里装了两个版本库ldd显示库来自/lib/x86_64-linux-gnu但你以为已经从/opt/custom加载了。这时先看readelf -d里有没有RUNPATH再检查环境变量LD_LIBRARY_PATH然后看缓存ldconfig -p一条条对照优先级表很快就能找到是哪个环节盖过了你的预期。6.4 几个我踩过的最隐蔽的“选错模块”陷阱说几个实际项目中比较难一眼看穿的坑都是我亲手排查过的。第一个是--as-needed和库顺序叠加。现代 Linux 发行版默认会在 GCC 里启用--as-needed它的意思是动态库只有在“真正被会生成重定位条目的引用”时才会写进DT_NEEDED。加上你命令行顺序再一乱可能出现编译期没有报错但最终二进制根本不含某个动态依赖运行时才在另一个模块里发现缺失。这种情况用readelf -d一看就懂但事前确实很迷惑。第二个是“目标模块被选进但符号被--version-script隐藏了”。你用版本脚本控制了导出符号链接器可能仍然解析了库里的符号但把它标记为本地符号导致下游模块引用不到它。这不算“没有选中”更像是“选完不让你用”。排查时nm -D检查动态符号表基本立刻现形。第三个是“本地目录下的同名.o踩了搜索路径”。Makefile 里用了VPATH或-I路径编译器生成的依赖文件里写的是src/foo.o但链接时命令行里却用了根目录下另一个旧foo.o于是链接器“精准”地选择了错误模块。这种问题很难从代码层面看出来唯一可靠的办法就是用-t检查实际输入文件名我后来直接把它加进了 CI 构建日志防止再被误导。以上这些排查手段本质上都是围绕同一个目标让链接器把“它到底选了哪里的哪个模块、因为什么选”这件事透明化。只要这一步信息出来了绝大多数链接问题都能像剥洋葱一样一层层拆开。