平时写代码,最常见的报错就那么几种:编译不过、链接失败、运行时崩溃。但大多数人遇到undefined reference to xxx或者multiple definition of yyy时,第一反应是把报错丢给搜索引擎,很少有人停下来问一句:链接器到底在干什么?为什么一段代码换个编译参数、换个链接顺序,行为就完全不一样了?
先说个我自己的经历。某次接手一个跨平台项目,代码在某套环境下编译得稳稳当当,换到另一个环境直接爆出几十个undefined reference,而且报错的全是第三方库里的函数。查了很久才发现问题是符号可见性:某个导出宏在目标平台上没有生效,导致一堆符号被隐藏了。从那以后我就明白,不懂 ELF 格式和链接原理,遇到这类问题只能靠猜。这篇文章就把我从 ELF 到动静态链接的整个理解过程写出来,适合那些想真正搞懂“编译完生成的二进制文件里到底有什么”的开发者,也适合正在被各种链接问题折磨的人。
1. 先从 ELF 谈起:二进制文件不是一团乱码
1.1 ELF 是什么,为什么会有好几种类型
ELF 的全称是 Executable and Linkable Format,翻译过来是“可执行与可链接格式”。注意这里有两个关键词:可执行、可链接。一个格式要同时服务于两种完全不同的使用场景,这是理解 ELF 的关键。
链接器读 ELF 文件,是为了把多个目标文件合成一个可执行文件或共享库;加载器读 ELF 文件,是为了把程序装载进内存、解析依赖、完成重定位。同一套格式要兼顾两边需求,所以 ELF 文件被分成了几种类型,按文件头的e_type字段区分:
| 类型 | 名称 | 典型文件 | 作用 |
|---|---|---|---|
| ET_REL | 可重定位文件 | 编译生成的.o目标文件 | 供链接器使用,地址从 0 开始,代码中的引用都还是“待填的空位” |
| ET_EXEC | 可执行文件 | 静态链接后的可执行程序 | 地址已经固定,加载器只需按头部信息映射即可运行 |
| ET_DYN | 共享目标文件 | .so动态库、PIE 可执行程序 | 地址不确定,运行时由加载器决定装载位置 |
| ET_CORE | 核心转储文件 | 崩溃时生成的 core 文件 | 记录进程退出时的内存快照,给调试器分析 |
现代发行版里,普通编译出来的可执行程序也大多是 ET_DYN,这是因为默认开启了 PIE(Position Independent Executable),也就是地址无关可执行文件。PIE 程序可以被加载到任意基地址,对缓解漏洞利用有很大意义。所以现在见到的可执行文件,十有八九是 ET_DYN,而不是传统的 ET_EXEC。
1.2 文件头:一次读懂二进制文件的“户籍信息”
拿到任意一个 ELF 文件,先用标准工具 readelf 看一眼它的头部,比什么都直观。假设手头有一个非常简单的 C 文件编译出的main.o:
$ readelf -h main.o ELF Header: Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 Class: ELF64 Type: REL (Relocatable file) Machine: Advanced Micro Devices X86-64 Start of section headers: 1064 (bytes into file) Number of section headers: 15 Section header string table index: 14开头那 16 个字节里,7f 45 4c 46就是 ELF 的魔数(Magic Number),对应 ASCII 是\x7fELF,内核和加载器靠它判断文件是不是合格的 ELF。第 5 个字节02表示 64 位,第 6 个字节01表示小端字节序。这些字段在写解析工具或做逆向时非常有用,日常开发虽然不用盯着看,但至少要知道它的存在。
文件头里还有几个关键字段值得注意。e_shoff记录了节区表在文件中的偏移,也就是Start of section headers那个 1064。为什么节区表放在文件末尾而不是开头?为了让链接器在合并节、调整大小后,追加或修改节区表时只需要更新文件头里的偏移和数量,各节的偏移位置不会因为节区表本身的大小变化而被挤乱。这是 ELF 设计里一个很务实的取舍。
1.3 节区表:文件内容真正的“骨架”
ELF 文件的内容按“节”(Section)组织,节区表就是这个组织的档案目录。继续用 readelf 看节区:
$ readelf -S main.o Section Headers: [Nr] Name Type Address Offset Size EntSize Flags Link Info Align [ 0] NULL 0000000000000000 00000000 0000000000000000 0000000000000000 0 0 0 [ 1] .text PROGBITS 0000000000000000 00000040 0000000000000025 0000000000000000 AX 0 0 16 [ 2] .rela.text RELA 0000000000000000 00000218 0000000000000030 0000000000000018 18 1 8 8 [ 3] .data PROGBITS 0000000000000000 00000068 0000000000000000 0000000000000000 WA 0 0 4 [ 4] .bss NOBITS 0000000000000000 00000068 0000000000000000 0000000000000000 WA 0 0 4 [ 5] .rodata PROGBITS 0000000000000000 00000068 0000000000000004 0000000000000000 A 0 0 4 [ 6] .comment PROGBITS 0000000000000000 0000006c 0000000000000018 0000000000000001 MS 0 0 1 [ 7] .note.GNU-stack PROGBITS 0000000000000000 00000084 0000000000000000 0000000000000000 0 0 1 [ 8] .symtab SYMTAB 0000000000000000 00000090 00000000000000d8 0000000000000018 9 4 8 [ 9] .strtab STRTAB 0000000000000000 00000168 0000000000000017 0000000000000000 0 0 1 [10] .shstrtab STRTAB 0000000000000000 0000017f 0000000000000046 0000000000000000 0 0 1这些小节里,日常打交道最多的是这几个:
.text:编译出来的机器码,程序的逻辑本体。标志位里有 AX,表示可分配、可执行。.rodata:只读数据,比如字符串常量、const修饰的全局变量。.data:已初始化的全局变量和静态变量。.bss:未初始化或初始化为 0 的全局变量和静态变量。注意它的 Size 是 0,但它会告知加载器需要预留多少空间。说白了就是“欠着的空间”:在文件里不占用具体内容,加载到内存时要给它分配零填充的内存页。.symtab:符号表,记录文件里定义和引用的符号。.rela.text:针对.text节的重定位表,标记哪些位置在链接时需要被修正地址。.strtab和.shstrtab:字符串表,前者保存符号的名字,后者保存节的名字。符号表里只存字符串在字符串表中的下标,而不是直接存名字的拷贝,这样能省大量空间。
这些小节的组合,就是链接器进行后续工作的全部基础。
2. 符号和重定位:链接器手里最重要的两件武器
2.1 符号表:程序内部的“通讯录”
说了半天 ELF 结构,最终要服务于一件事:让链接器能把不同目标文件“缝”在一起。而缝合的针脚,就是符号。
符号表.symtab记录每个符号的名字、类型、所在节、在节内的偏移、占用大小,以及最重要的全局属性。用readelf -s main.o或者nm查看:
$ nm main.o 0000000000000000 T main U putsT main表示main是文本节里定义的一个全局符号,地址是 0x0。U puts表示puts是一个未定义符号(Undefined),在本文件里被引用了,但具体在哪由链接器来安排。这种“定义与引用分离”的模型,是整个链接系统的基础。
符号还有作用域的区分:GLOBAL(全局)、LOCAL(局部于当前目标文件)、WEAK(弱符号)。C 语言里不加static的函数和全局变量默认是 GLOBAL,加了static就变成 LOCAL。LOCAL 符号不会参与跨目标文件的解析,在链接时就像不存在一样,所以一个.c文件里同名的static变量在别的文件里完全无感。
符号表里除了名字,还记录了符号的类型和大小。类型有 FUNC(函数)、OBJECT(数据对象)、FILE(源文件名)等。调试器和链接器会根据这些信息做不同处理。比如nm输出的T、U、D、B分别对应 text 节定义、未定义、data 节定义、bss 节定义,都是浓缩自符号表里的引用信息和节索引。
2.2 强弱符号:全局变量冲突的根源
GLOBAL 符号允许同名,但重名的 GLOBAL 符号不能都被定义成强符号,否则链接器会报multiple definition。编译器把函数初始化、有显式初始值的全局变量视为强符号;把未初始化的全局变量在 GCC 10 之前视为弱符号(通过-fcommon方式处理),在 GCC 10 之后默认按-fno-common处理,视为强符号。
这里有一个经常坑人的规则:
- 强符号和强符号同名:链接报错。
- 强符号和弱符号同名:选择强符号。
- 两个弱符号同名:选择占用空间大的那个,因为小空间的代码可能引用到超出自身大小的偏移。
这个规则本身不难,难在现实中的影响。典型例子:某个库定义了一个int global_counter;,你的程序里也定义了一个int global_counter;,在旧的编译模式下这居然是合法且“共享”的。一旦升级到新的编译器或加上-fno-common,就变成链接错误。很多老项目在升级编译工具链时会突然冒出大量multiple definition报错,往往就是全局变量在头文件里定义惹的祸。
我个人的建议是:头文件里只放声明(extern),定义放在.c文件里。这条老生常谈,但它的理由现在比十年前更加充分,因为编译器默认的链接规则已经变了。弱符号本身是个有用的机制,比如库提供给用户的“默认实现”,但那是库作者才能玩得转的技巧,普通业务代码没必要主动制造弱符号。
2.3 重定位:给地址留的空位怎么补上
编译单个.c文件时,编译器还不知道其他文件里符号的最终地址,所以在生成机器码时,凡是涉及外部符号的引用,先填一个占位值,并在重定位表里记录“这个位置什么时候该被修正”。这就是.rela.text的来历。
用objdump -dr看最直观:
$ objdump -dr main.o Disassembly of section .text: 0000000000000000 <main>: 0: 55 push %rbp 1: 48 89 e5 mov %rsp,%rbp 4: 48 8d 3d 00 00 00 00 lea 0x0(%rip),%rdi b: e8 00 00 00 00 call 0x10 <main+0x10> c: R_X86_64_PC32 puts-0x4注意lea指令引用的字符串常量地址,以及call指令的目标地址,都还是 0,但旁边多了一行:R_X86_64_PC32 puts-0x4。这一行就是重定位条目。
重定位条目里最重要的几个字段是:偏移(在目标节里的位置)、类型(如何计算地址)、符号(引用的是谁)、加数(Addend)。不同类型对应不同的地址计算方式。最常见的两个:
R_X86_64_PC32:PC 相对寻址,计算方式是S + A - P。S 是符号最终地址,A 是加数,P 是引用位置的当前地址。PC 相对寻址的指令在运行时通过 RIP(指令指针寄存器)计算目标,所以需要减法来抵消偏移。R_X86_64_64:绝对寻址,计算方式是S + A。直接写入一个 64 位绝对地址。
不理解S + A - P没关系,类比一下就懂了:假设你的快递柜位置 P 在 1 楼走廊尽头,你需要在当前位置走多少步才能走到符号 S 的位置。走过的距离就是 S 减 P。实际编码时指令用的是偏移量,所以链接器要算出这个偏移写进去。这就是“重定位”的全部含义:把编译器留下的空白,用最终地址算出来的数值填上。
3. 静态链接:从多个目标文件到可执行文件的完整拼接
3.1 链接的第一件事:符号解析
把多个.o文件和静态库链接成一个可执行文件时,链接器第一步做的是把所有输入文件的符号表汇总起来,建立一个大符号表,然后逐一找出“哪些未定义符号可以被哪个目标文件里的定义符号满足”。
这个过程有一个容易被忽略的规则:链接器只会把静态库中“能解决当前未定义符号”的目标文件提取出来,而不是把整个库塞进去。静态库本质是一个.a归档文件,里面收纳了一堆.o。链接器维护一个“当前未解析符号”的队列,扫描到库里的某个.o能解析掉某个未解析符号时,才把那个.o拿进来,同时它所引入的新的未定义符号又加入队列。
这就是经典的“库顺序问题”的根源。如果命令写成gcc main.o -lB -lA,而 main 引用了 A 库的函数,A 又引用了 B 库的函数,扫描完-lB时因为没有符号需要 B 的代码,整个库会被跳过;等扫描-lA时提取了 A 里的目标文件,才暴露出对 B 的需求,但前面已经扫完 B 了,于是链接失败。解决办法是把依赖关系靠后的库写在后面:-lA -lB,或者用链接器的--start-group和--end-group强制循环扫描:
gcc main.o -Wl,--start-group -lA -lB -Wl,--end-group静态库还有一个特性:同名的.a文件如果放在目标文件之前,效果也可能不同,因为第一次扫描时尚未建立起“未定义符号列表”。因此我的习惯是,所有静态库一律放在目标文件后面,不要放到最前面。这个规则在大型项目里能帮你避免一半的莫名链接报错。
3.2 链接的第二件事:重定位与地址分配
符号解析完成后,链接器知道每个符号到底在哪个目标文件里、位于哪个节的哪个偏移处。接下来要做的是:把不同输入文件里的同类型节合并到输出文件的节里,并为它们分配最终的虚拟地址。
举个例子,假设有三个.o文件,分别有.text节,大小各是 0x100、0x200、0x300。链接器会把它们按顺序合并成一个大的输出.text节,然后从某个基础地址开始分配。在非 PIE 的静态链接里,地址一般是固定的;在 PIE 里,则是基于某个基础地址的偏移,运行时再加一个随机基址。
合并完毕后,第一步得到的符号地址就有意义了。比如原来 main 在main.o的.text偏移 0x0,经过合并,它的最终地址变成输出.text的起始地址加上它在合并块里的顺序偏移。然后,链接器依次处理每个目标文件的重定位表,把第 2.3 节说的公式套进去,真正完成地址填入。
特别要注意的是.bss的地址分配。它不占文件空间,但在进程的内存映射里必须有一块空间。链接器会把它安排在.data后面,分配虚拟地址并按页对齐。这也是为什么size命令显示的.bss大小通常比文件里看到的“内容”大得多。
3.3 静态链接的经典翻车现场
静态链接常见的问题里,最典型的是undefined reference和multiple definition,它们的原因在前面已经讲过。还有一类是“符号被链接器丢弃”的问题。
某些编译器优化选项,比如-ffunction-sections和-fdata-sections,会把每个函数和全局变量放进独立的节。配合链接器的--gc-sections,就可以丢弃未被引用的节。好处是二进制体积变小,坏处是如果你通过“地址取函数名”之类的技巧间接引用符号,链接器可能认为它没被用到而直接删除,导致运行时符号丢失。这时候需要加-Wl,--no-gc-sections,或者用__attribute__((used))告诉编译器这个符号必须保留。
还有一个关于弱符号的坑:当一个强符号和一个弱符号同名,链接器选择强符号,但如果强符号所在的整个目标文件因为“没被符号解析流程触发”而没被提取进来,弱符号就会“顶上去”,导致预期被覆盖的库实现没有被覆盖。这个问题容易出现在库作者对覆盖机制的理解不够深入时。
静态链接还有一个特点是:所有符号在最终文件里地址固定,加载的时候不会再有重定位操作。这带来很多好处:启动快、不用担心运行时找不到共享库、符号地址稳定。代价是体积大,公共代码不能共享,安全更新必须重新发布整个程序。这些利弊对比,正好引出动态链接。
4. 动态链接:从 PLT/GOT 看一次函数调用的完整旅程
4.1 为什么需要动态链接,又引入了什么麻烦
动态链接的核心动机是共享:多个进程可以共享内存里的同一份库代码,公共库的升级不用重新链接应用程序。代价是符号解析延迟到了运行时,而且由于每个进程加载共享库的地址可能不同,共享库里的代码不能写死绝对地址,必须做到地址无关(PIC,Position Independent Code)。
地址无关是怎么做到的呢?思路是:把需要访问的外部数据或函数的真实地址,统一放进一个“地址中转站”,也就是 GOT(Global Offset Table,全局偏移表)。代码里不直接引用外部符号的地址,而是通过 GOT 间接取地址。这样无论库被加载到哪个地址,只要 GOT 里的值由加载器初始化正确即可,代码本身不需要改变。
但如果是每个外部函数调用都直接通过 GOT 跳转,那所有库函数调用都会多一次内存访问,而且每个外部调用都要在加载时完成重定位,启动成本很高。为了优化这两种开销,ELF 体系设计了 PLT 和 GOT 配合的延迟绑定机制。
4.2 PLT/GOT 和延迟绑定的四步走
PLT(Procedure Linkage Table)是过程链接表,里面每一行是一个外部函数对应的跳转桩;GOT 里对应每个外部符号有一项用于存放真实地址。以调用puts为例,编译器会把call puts编译成call puts@plt,也就是进入 PLT 里属于puts的那段桩代码。整个调用链是这样的:
第一次调用puts时:
- 进入
puts@plt,第一条指令是jmp *GOT[puts]。由于之前从未调用过,GOT 表项里存的还是 PLT 桩下一条指令的地址,也就是push指令的地址。 - 执行
push $index,这个 index 是puts在重定位表中的序号,作为参数压栈。 - 跳转到 PLT[0],也就是公共桩。公共桩会把 GOT[1](模块句柄)入栈,然后跳转到 GOT[2] 指向的条目。
- 加载器在启动时会把动态链接器的解析函数入口填到 GOT[2] 里。解析函数拿到模块句柄和符号序号,在全局符号表里查出
puts的真实地址,把它写回 GOT[puts],然后跳转到真实的puts。
第二次调用puts时,GOT[puts] 已经保存了真实地址,jmp *GOT[puts]直接命中,后面那套 push、跳转、解析的流程不会再发生。这就是延迟绑定:第一次调用付出解析开销,之后每次调用都只是一次间接跳转。
把这段流程拆成一张表理解会更清晰:
| 环节 | 第一次调用前 | 第一次调用后 |
|---|---|---|
| GOT[puts] | PLT 内 push 指令地址 | 真实 puts 函数地址 |
| PLT 桩的作用 | 引导进入动态链接器解析流程 | 一次性,不再走后续逻辑 |
| 开销 | 一次完整符号解析 | 一次额外间接跳转 |
4.3 动态链接器的查找逻辑和符号冲突
动态链接器(ld.so)在解析未定义符号时,不是从一个文件里找,而是从整个进程的全局符号作用域里找。搜索顺序通常是被加载模块的顺序:可执行文件自身、加载顺序靠前的依赖库、然后依次是后续的依赖库。这带来一个经典的符号冲突问题:两个共享库都导出了同名符号,按照“先加载先得”的原则,后加载的库即使内部有自己的同名函数,调用时也可能被解析到先加载的那个库的实现。
举例来说,程序依赖库 A 和库 B,A 先加载,A 里定义了void helper(),B 内部也有一个helper,并且在 B 内部调用了helper()。按默认搜索规则,B 的内部调用可能命中 A 的helper,不是 B 自己那个。这会导致非常诡异的行为:同样一段代码,单独运行 B 没问题,搭上程序就出岔子。
解决这类问题有几个方向:一是给库的符号加版本控制,利用版本脚本控制导出符号的可见性;二是链接时加上-Bsymbolic,让库内部的符号引用优先绑定到自身定义;三是编写动态库时只导出公开 API,其余用static或-fvisibility=hidden隐藏。第三条是我在所有项目里的默认策略:隐藏一切非必要导出符号,既避免冲突,也减少符号表体积,还能降低符号被外部劫持的风险。
动态链接器拿到一个共享库时,会先读取它的DT_NEEDED条目,也就是它依赖了哪些其他库,然后递归加载这些依赖。用readelf -d可以查看:
$ readelf -d app Dynamic section at offset 0x2dc8 contains 25 entries: 0x0000000000000001 (NEEDED) Shared library: [libc.so.6] 0x000000000000000f (RPATH) Library rpath: [$ORIGIN/../lib]DT_NEEDED列出的库名只包含依赖关系,真正的库搜索路径由/etc/ld.so.conf、环境变量LD_LIBRARY_PATH、二进制里的RUNPATH(或已废弃的RPATH)共同决定。默认不设置时,加载器会在系统标准路径下查找。如果你把一个.so放到非标准目录,没配任何搜索路径,程序启动时就会直接报找不到库。
定制查找路径时,$ORIGIN是一个非常有用的变量,它表示可执行文件自身所在目录。比如把动态库放在可执行文件旁边的lib子目录,可以在链接时加上:
gcc main.o -L./lib -lfoo -Wl,-rpath,'$ORIGIN/lib'这样整个程序目录移动到哪里,都能找到自己的动态库,不会因为绝对路径被写死而出错。
4.4 RELRO 与运行期防护
动态链接带来的安全性问题里,最著名的是 GOT 覆写攻击:因为 GOT 里保存的是外部函数真实地址,如果攻击者能利用内存漏洞改写 GOT 表项,就能把puts劫持成恶意代码入口。
为了缓解这类攻击,现代工具链默认开启 RELRO(RELocation Read-Only)机制。Partial RELRO 会把 GOT 中不需要在运行期写入的部分(比如指向只读数据的条目)在加载后设为只读,但 PLT 对应的 GOT 部分仍然可写,因为延迟绑定需要它。Full RELRO 则会在启动阶段完成所有符号解析,关闭延迟绑定,随后把整个 GOT 设为只读。
开启 Full RELRO 的代价是启动时间变长,因为所有外部符号在加载时就要解析完毕,不能再懒。在安全敏感的系统服务里,这个代价完全值得。查看当前二进制的 RELRO 状态可以用:
$ readelf -l app | grep GNU_RELRO GNU_RELRO 0x00000000000002d8 0x00000000004002d8 0x00000000004002d8 0x0000000000000118 0x0000000000000118 R 0x8如果看不到 GNU_RELRO 段,说明链接时没开或者只开了部分。用-Wl,-z,relro,-z,now可以强制 Full RELRO。我在发布任何对外服务的程序时,都会检查这个字段,这是一道很便宜但有效的防线。
5. 实操排查:让底层原理帮你解决实际问题
5.1 链接期报错速查
把前面几节讲的原理映射到实际报错里,链接期问题基本都能归到这几类:
| 报错信息 | 直接原因 | 排查方向 |
|---|---|---|
undefined reference to 'foo' | 符号没有定义,或定义没有被提取 | 检查是否有对应库、链接顺序、符号名拼写、C++ 名字修饰 |
multiple definition of 'bar' | 强符号重复定义 | 检查全局变量是否定义在头文件、是否用-fno-common |
cannot find -lxxx | 链接器找不到库文件 | 检查库目录路径是否通过-L指定,库文件是否存在 |
relocation truncated to fit: R_X86_64_PC32 | 目标地址超出 PC 相对寻址范围 | 检查是否有超大节、内存模型是否正确 |
C++ 项目里,undefined reference最常见的原因是名字修饰(name mangling)不一致。C++ 编译器会把函数签名编码成奇怪的符号名,比如_Z3foov,而 C 库导出的是foo。如果你的 C++ 代码想调用 C 库函数,却忘记加extern "C",链接器就会去找_Z3foov,当然找不到。这个问题只有理解了符号层才能恍然大悟:链接器根本没有“函数名”的概念,它只认符号表里的字符串。
排查这类问题我的标准流程是先确认符号到底长什么样:
nm -C 需要排查的目标文件或库 readelf -s 库文件 | grep foonm -C会把 C++ 修饰名还原成可读签名,一眼就能看出是foo()还是foo(int),以及它是不是被隐藏了(即不在动态符号表里)。
5.2 运行期符号问题的排查套路
编译链接通过之后,运行期还会有一堆问题,最典型的就是启动时找不到共享库、加载顺序导致的符号冲突、以及符号被恶意或无意劫持。
启动时找不到库,先别急着乱设LD_LIBRARY_PATH。第一步是确认程序到底依赖了哪些库:
readelf -d app | grep NEEDED然后看这些库是否存在、在哪个目录:
ldconfig -p | grep libfoo如果库存在但路径没被搜索到,再决定是加LD_LIBRARY_PATH(临时调试用)、改/etc/ld.so.conf(系统级影响)还是重新链接加-rpath。我推荐用-rpath配合$ORIGIN,因为LD_LIBRARY_PATH优先级太高,其实危机会导致系统其他程序用到错误的库版本,留着在生产环境是个隐患。
符号劫持的排查可以用LD_DEBUG=symbols跑一下:
LD_DEBUG=symbols ./app 2>&1 | grep 'foo'它会打印每个符号的查找过程:哪个模块试图解析foo,最终从哪个模块取得了定义。输出的后半部分通常能看到类似binding file app to libA.so的信息,一目了然。如果绑定的不是预期的库,就能对应到 4.3 节说的冲突场景,然后按“隐藏符号”“调整加载顺序”“符号版本”的思路去修。
5.3 我建议每个人都要做的三个实验
理论和实操缺一不可。如果读完这篇文章想亲自动手验证,我建议做三个实验,每个都很短,但能巩固上述所有概念。
第一个实验:拿一个最简单的 C 文件编译成.o,用readelf -S和objdump -dr分别看节表和重定位,再手动算一下R_X86_64_PC32的公式,看看链接器填进去的值是不是对得上。
printf 'int add(int a, int b) { return a + b; }\n' > add.c gcc -c add.c readelf -S add.o objdump -dr add.o第二个实验:把两个.c文件编译、链接,然后分别在链接前和链接后运行nm,对比同一个符号在两个阶段里的地址变化。比如一个文件定义全局变量,另一个文件引用它;你会看到引用方在.o阶段是U,链接后变成具体的值。
第三个实验:写一个小的动态库,加-fvisibility=hidden和不加各编译一次,对比动态符号表数量。然后在主程序里通过dlopen加载它,观察导出的区别。这个实验能直观理解符号可见性有多么的重要。
这三个实验做完,我对 ELF 和链接的理解才算闭环,之后大部分编译链接问题都不再靠猜了。
老实说,搞懂这些底层原理的过程并不轻松,但回报非常直接。我处理过不少看起来玄学的问题,最终都落在符号可见性、库顺序、加载顺序这些非常“底层的细节”上。希望这篇把整个链路从 ELF 格式、符号、重定位、静态链接一路拆到动态链接的文章,能帮你省下我当年排查那些问题耗费的时间。