☰
ELF文件格式与链接全过程:从静态链接到动态链接器,彻底解决undefined reference
2026/10/10 7:20:59 网站建设 项目流程

很多朋友一碰到链接错误就发怵:编译期风平浪静,到了链接期照样给你来一个 undefined reference,拦都拦不住。我以前也是靠搜索硬扛,直到认认真真把 ELF 格式、静态链接和动态链接的整套机制啃了一遍,才感觉那些乱七八糟的问题其实共用同一条主线。这篇文章不打算堆砌命令,而是要解答几个最让人困惑的问题:ELF 文件里到底存了哪些信息?链接器凭什么能找到外部符号?程序跑起来的时候,动态链接器又是怎么把那些 .so 接上去的?适合已经能写简单程序、但一遇到二进制和链接问题就头大的开发者。把这条主线捋顺,很多坑都能少踩。

1. ELF 是链接与加载的共同语言

1.1 一个文件格式为什么能同时服务两种场景

ELF 全称 Executable and Linkable Format,从名字就能看出来,它既要给可执行文件用,也要给可链接文件用。你随便打开一个 .o、.so、可执行文件,甚至 core dump,前几个字节都是0x7f 45 4c 46,也就是 DEL 字符加上E L F三个字母。Linux 上的 readelf 就是靠这个魔数判断文件类型。

一个格式要同时服务链接器和内核,显然得准备两套视角。目标文件在链接期需要完整的节级信息,函数、变量、字符串、重定位条目都分门别类地放着;可执行文件在运行期只需要知道哪些区域该映射到哪里、权限是什么、入口在哪。ELF 巧妙地在同一个文件里同时提供节头表(section header table)和程序头表(program header table),链接器读前者,装载器读后者,互不干扰。这也是理解 ELF 最关键的一步:节区是链接期的概念,段是运行期的概念。

不少初学者会好奇,为什么strip之后程序还能正常运行?因为内核只关心程序头表,而节头表是给链接器和调试器用的。把节头表整个抹掉,运行不受影响,只是没法再调试、再链接了。这恰恰说明 ELF 不是单个结构,而是两种视图共存的容器。

1.2 文件头里的关键字段决定了解析起点

用readelf -h看任意 ELF 文件,会先看到一个 64 字节左右的 ELF Header。它会告诉你这个文件是 32 位还是 64 位、大端还是小端、是什么类型的文件、目标平台是什么、入口地址在哪、节头表和程序头表分别在文件的哪个位置。这些字段里面,有几个直接决定后续解析策略。

e_type是最容易出问题的地方。它标识文件身份:1 是可重定位目标文件(.o),2 是可执行文件,3 是共享对象文件(.so),4 是 core dump。如果你把一个 .o 文件硬当程序执行,或者把内核模块当普通库加载,系统第一眼就会拒绝。e_machine用于判断目标 CPU 架构,常见值里 0x3e 对应 x86_64,0x28 对应 ARM。这也解释了为什么在 x86 机器上跑 ARM 交叉编译出来的程序,内核会直接报Exec format error。

e_entry是程序入口的虚拟地址。静态链接程序通常直接指向_start;动态链接程序则要等动态链接器接管之后,最终跳到这个地址。e_shoff和e_phoff分别是节头表和程序头表在文件中的偏移,e_shnum和e_phnum是它们的条目数量。这些字段一旦被破坏,readelf 和装载器都会迷路。所以我排查二进制问题时,第一件事永远是readelf -h,确认文件头没有被 strip 工具改动、没有传输损坏。

1.3 节区与程序段之间的对应关系

用readelf -S看目标文件,你会看到一堆节区。.text是代码,.data是已初始化全局变量,.bss是未初始化全局变量,.rodata是只读常量,.symtab是符号表,.strtab是字符串表,.rela.text是代码段的重定位表。每个节条目都记录了自己的类型、在文件中的偏移、大小、对齐方式、是否需要在运行时占用内存。

再看readelf -l,看到的则是另一套东西。常见的程序段入口有LOAD、DYNAMIC、INTERP。每个LOAD段都带有读、写、执行权限标志,以及文件偏移、虚拟地址、文件大小、内存大小。这些信息才是内核加载程序时真正要用的。链接器在生成可执行文件时,会把多个节按照运行权限合并成若干个LOAD段。比如.text和.rodata都是只读,通常合到同一个LOAD段;.data和.bss都是可读写,合到另一个LOAD段。

.bss是一个很特殊的存在。它在文件里不占实际空间,文件大小是 0,但内存里需要按变量大小分配空间。因为未初始化数据默认是 0,没必要在磁盘里存一堆没有意义的零。加载器看到p_filesz小于p_memsz时,就会把映射的内存区域补差清零。这个概念从链接期到运行期一路都在用,后面理解段映射时还会碰到。

2. 静态链接背后的两次关键动作

2.1 链接器到底做了什么

一个 C 文件从源码变成可执行文件,要经过预处理、编译、汇编、链接四步。很多人以为gcc -o demo demo.c是一气呵成的,实际 gcc 只是驱动,它先生成 .o,再调用链接器ld完成最终工作。

链接器的主要工作可以用两个词概括:符号解析和重定位。编译阶段,每个源文件独立编译,编译器能看到的本文件符号都知道,但遇到外部函数调用时,它不知道最终地址,只能在机器码里留一个空的地址位,同时在重定位表里记一笔“这里需要填某个符号的地址”。等到链接时,链接器把所有 .o 的节合并,把所有符号汇总成一张全局符号表,然后逐个处理这些空位。

这个过程听起来机械,却最容易出问题。如果某个符号在所有输入文件里都没有定义,链接器就报undefined reference。如果多个文件定义了同一个全局符号,链接器要按照规则决定保留哪个。稍有不慎还有各种冲突。所以链接期报错不是“编译器不行”,而是符号解析和重定位这两个动作没走完。

2.2 符号解析的潜规则

目标文件里的符号表可以通过nm查看。全局定义的函数通常显示为T,已初始化全局变量是D,未初始化全局变量是B,未定义符号是U。什么叫未定义符号?就是这个 .o 引用了某个名字,但自己的代码里没有定义,期待链接时由别的 .o 或库提供。

链接器处理多个强符号的逻辑有硬性规定:如果有两个强符号重名,直接报multiple definition;如果一个强符号和一个弱符号重名,选强符号;如果有多个弱符号重名,链接器任选一个。C 语言里一般没有弱符号这个概念,但 GCC 支持__attribute__((weak))定义弱符号,嵌入式开发里常用来做默认实现和用户实现共存。搞懂符号强弱的区别,也能解释为什么某些库函数可以被用户重写覆盖。

静态库的处理更是经典坑。一个.a文件本质上是多个 .o 的打包集合,链接器不会像处理普通 .o 那样全量引入,而是先扫一遍当前已有的未定义符号表。遇到一个成员 .o 能提供未定义符号时,才把这个成员提取出来;如果一个 .o 没被任何需要的符号引用,就跳过。这种“按需提取”是静态库减少体积的根本机制,也是链接顺序敏感的根源。

2.3 重定位公式:链接器填地址的标准方式

编译生成的 .o 里,很多指令字段还是 0。链接器拿到最终虚拟地址后,根据重定位类型决定怎么计算。

在 x86-64 上最常见的是R_X86_64_PC32和R_X86_64_PLT32,它们都用于计算相对偏移。公式是:

S + A - P

其中S是目标符号最终地址,A是重定位条目里的 addend,P是重定位位置对应的地址。很多人第一次会困惑-P到底是重定位位置本身还是下一条指令。以call指令为例,x86 的call rel32格式是操作码E8后面跟 4 字节相对偏移,这个偏移基于下一条指令地址计算。于是重定位的P实际上指向下一条指令的位置,而 addend 往往设为负数来修正偏移量。

举个具体例子。假设最终bar函数被放在0x401240,某条call bar位于0x401200,指令长度 5 字节,下一条指令在0x401205。那么需要写入的偏移就是0x401240 - 0x401205 = 0x3B,机器码是3b 00 00 00。如果你直接用0x401240 - 0x401200去算,就会差 4 个字节,运行时直接跳到错误位置。这正是objdump -dr里重定位条目后面经常跟着-0x4的原因,addend 被用来抵消这种长度误差。

还有一类绝对地址重定位,比如R_X86_64_64,公式更简单,直接把S + A写入 8 字节。它通常出现在访问全局变量地址的场景,GOT 表项的初始化也大量依赖这类重定位。理解了这些公式,再看ld报的relocation truncated to fit,就明白是目标地址和当前位置的差值太大,超出 32 位有符号数范围。

2.4 静态链接后的体积与启动代码影响

gcc -static编译出来的程序体积会明显膨胀,为什么?因为 glibc 的库代码被完整地链接进了可执行文件,而不是运行时动态加载。对大多数桌面程序来说这不是好选择,但在某些容器、嵌入式或固定环境下,静态链接可以省去动态库依赖的麻烦,部署起来更省心。

静态可执行文件也有启动代码。入口不是main,而是_start。_start通常由crt1.o提供,它会设置栈指针、准备好参数,然后调用__libc_start_main。__libc_start_main负责初始化 libc、调用全局构造函数、最终调用main,并在main返回后执行退出处理。这就是为什么链接器要把一堆crt开头、以.o结尾的目标文件默认链接进去,没有它们,程序连入口都进不去。

链接器具体如何排布各节,由链接脚本(linker script)控制。普通程序员很少直接接触,但做嵌入式裸机开发时经常要改ld -T的脚本,把代码放在首地址、把数据放在 RAM 地址。理解 ELF 的节区概念后,再读链接脚本会轻松得多,因为你终于知道.text .data .bss这些名字代表什么,以及它们为什么必须按照地址和权限分开。

3. 动态链接:把解析延期到运行时

3.1 静态链接的短板与共享库的出现

静态链接代码一旦复制到每个可执行文件里,磁盘和内存都浪费得厉害。两个程序都用了printf,每个程序里都有一份完整的printf实现,物理内存里也加载两份。除此之外,某个公共库一旦发现安全漏洞,所有静态链接它的程序都要重新编译发布。

共享库的解决思路是:把公共代码放到一个独立的.so文件里,运行时由动态链接器加载到进程,多个进程共享同一个只读代码段。不过这带来一个核心问题:动态库被加载到哪个虚拟地址,事先并不知道。编译成.so时如果写死绝对地址,加载到别的地址就是错的。

解决办法是位置无关代码(PIC)。编译器在动态库里不生成绝对地址引用,而是通过相对跳转、相对寻址,或者通过 GOT(全局偏移表)间接访问数据。.so的代码段因此可以保持只读,多个进程共享一页物理内存,真正需要修改的地址集中在可写的 GOT 里,加载时再填真实值。-fPIC编译选项就是为此设计的。

3.2 PLT/GOT 与延迟绑定:第一次调用慢,后面都快

动态链接最经典的设计是 PLT 与 GOT 配合的延迟绑定。为什么需要延迟绑定?因为一个大型程序的动态符号可能很多,如果启动时把所有外部函数全部解析一遍,启动会非常慢。大部分函数也许一次都没被调用,解析它们是白费功夫。于是系统 V ABI 设计了一套懒绑定机制。

以 x86-64 为例,每个需要动态解析的外部函数在 PLT(过程链接表)里有一个小桩代码,在 GOT 里有一个对应表项。第一次调用foo时,实际跳到foo@plt:

  • PLT 桩先执行jmp *GOT[foo],此时 GOT[foo] 还未解析,初始值指向 PLT 桩里的下一条指令。
  • 接着push $index,把当前函数在重定位表中的索引压栈,再跳转到 PLT 的公共入口。
  • 公共入口会把 GOT[1](link_map 指针)和 GOT[2](动态链接器解析函数指针)依次压栈或跳转,调用_dl_runtime_resolve。
  • 动态链接器根据索引找到符号名,在共享库的符号表里查到真实地址,写入 GOT[foo],然后跳转到真实函数执行。
  • 第二次调用同一函数时,jmp *GOT[foo]直接命中真实地址,PLT 后续代码不再执行。

这里 GOT 的前三项是专门留给动态链接器使用的,所以不是任意偏移都能用。readelf -r里看到的JUMP_SLOT类型重定位,就是这些 PLT 对应 GOT 项在启动时被填写的证明。如果程序开启了LD_BIND_NOW,或在链接时用了-Wl,-z,now,懒绑定被取消,所有 PLT 项会在启动阶段一次解析完。安全性更好,但启动会变慢。

3.3 soname、符号可见性与符号版本

动态库还有一个容易混淆的三层名字机制。假设一个库的最终文件叫libfoo.so.1.2.3,它的 soname 是libfoo.so.1,链接器名是libfoo.so。编译时-lfoo找到的是libfoo.so,这个文件通常是指向libfoo.so.1的软链接。链接器把 sonamelibfoo.so.1写入可执行文件的DT_NEEDED。运行期动态链接器去找的是libfoo.so.1,而不是libfoo.so.1.2.3或libfoo.so。

这套机制的价值在升级。当库从 1.2.3 升到 1.2.9,只需要保留 soname 仍为libfoo.so.1,所有旧的可执行文件不需要重新编译。如果有一天接口不再兼容,库作者才需要把 soname 改成libfoo.so.2。这也正是编译时报找不到库、运行时报找不到库的原因往往不同的关键所在。

符号可见性方面,默认情况下.so导出的全局符号是公开的,会被写入动态符号表。导出太多符号会拖慢链接器解析,还容易产生命名冲突。很多项目用-fvisibility=hidden把默认可见性改成隐藏,只对显式标记的 API 导出,这样外部看到的就是一个干净的小符号表。符号版本机制则解决“同一个函数名,不同版本行为不同”的问题,动态链接器通过版本标签判断兼容性,比如某些库符号会带有固定版本号,新程序依赖旧系统上的版本时就会报version not found。

3.4 绕过 PLT 的优化思路与 RELRO 加固

PLT 的代价是每次库调用都多一次间接跳转,现代 CPU 的预测和缓存会受一点影响。有的项目会启用-fno-plt,编译时不再生成call foo@plt,而是先通过 GOT 直接加载函数指针,然后call *%rax。这样函数调用省掉了 PLT 桩,运行时代码更紧凑。代价是动态链接器必须在启动阶段解析完这些 GOT 项,懒绑定不再适用。

RELRO 是很实用的安全机制。-Wl,-z,relro会把 GOT 中哪些已经绑定完成、不再需要运行时修改的部分改成只读;如果再配合-z now,所有 GOT 项都被解析并只读化,攻击者更难通过改写 GOT 劫持流程。代价同样是一部分启动时间。对大多数服务端程序来说,启动时间本来就短,换成 full RELRO 是划算的;对交互式程序,可以按启动敏感度权衡。

4. ELF 如何被装载进进程

4.1 LOAD 段不是简单套进内存就完事

程序头表里的LOAD段是内核真正关心的内容。每个LOAD段包含p_offset、p_vaddr、p_filesz、p_memsz和权限标志。p_offset是内容在文件中的偏移,p_vaddr是内容应该在进程虚拟内存中出现的位置,p_filesz是文件里占用的大小,p_memsz是内存里占用的大小。

这里有个隐藏约束:p_vaddr和p_offset对页面大小取模后必须相同。为什么?因为内核映射文件时,mmap 的偏移只能按页对齐,虚拟地址也得按页对齐。如果一个段在文件里的偏移是0x210,它在内存里的虚拟地址内偏移也得是0x210,否则页面对齐后会出现错位。ELF 规范要求链接器保证这一点,所以你会发现常规可执行文件的LOAD段偏移和地址低 12 位通常一致。

.bss的处理在这一阶段变得很直白:p_filesz小于p_memsz,说明文件里没有为 bss 准备内容。内核映射完文件区域后,还需要把多出来的那段内存清零。因为这段内存在文件中不存在,所以映射完成后必须用零填充,这也就是未初始化全局变量自动为 0 的底层来源。

4.2 从 execve 到动态链接器接管

当你执行一个程序,内核的 execve 路径会做这些事:检查 ELF 魔数和文件类型,解析程序头表,把所有LOAD段按权限映射到进程地址空间;如果程序是动态链接的,还会根据INTERP段把动态链接器本身映射进来;随后在用户栈上布置argc、argv、环境变量,以及一组辅助向量 auxv。

在动态可执行文件里,e_entry虽然存在,但内核不会直接跳过去,而是先跳到动态链接器的入口。动态链接器自举之后,会读取程序的动态段,根据DT_NEEDED列表递归加载依赖的共享库,处理重定位,执行各库的初始化函数,最后通过辅助向量里的AT_ENTRY找到程序真正的入口地址并跳转过去。所以动态程序真正启动流程是:内核 -> 动态链接器 -> 程序的_start-> libc 初始化 -> main。

这也是为什么动态链接可执行文件不能随手改e_entry,因为内核可能根本不直接使用它。要分析程序实际入口,看info proc mappings里动态链接器的加载位置,或者用调试器断在ld.so入口,都比看 ELF 头更准确。

4.3 动态链接器如何找到需要的库

动态链接器找库的路径并不是简单去默认目录翻一圈。它有一个确定的搜索顺序:首先考虑LD_LIBRARY_PATH环境变量;然后看 ELF 动态段里的DT_RPATH或DT_RUNPATH;随后是缓存文件里的记录;最后是系统默认路径。LD_PRELOAD可以强制提前加载某些库,通常用来做调试、替换库实现,但 setuid 程序会主动忽略这类环境变量,防止提权漏洞。

排查运行期“找不到库”很好用的手段是LD_DEBUG=libs ./demo。它会输出动态链接器每一步的搜索过程,能看到它尝试了哪些路径、最终为什么失败。平时怀疑某个库被解析成别的版本,也可以LD_DEBUG=reloc ./demo看具体重定位。这些输出比ldd更底层,尤其当ldd展示的结果和实际运行环境不一致时,LD_DEBUG才是最终答案。

5. 基于底层原理的排障实录

5.1 链接期 undefined reference 的几种典型原因

第一类是拼写和语言链接问题。C 代码里声明了外部函数,但实现是 C++ 编译器编译的,C++ 符号会被名字修饰,导致 C 侧找不到foo,而 C++ 侧导出的是_Z3foov。解决办法是在 C++ 头文件里包extern "C"。如果混用 C 和 C++,这类报错几乎天天见。

第二类是静态库顺序问题。链接器按命令行从左到右扫描文件,遇到静态库时只会按需提取成员。如果 A.a 里的目标文件依赖了 B.a 的符号,而命令顺序是main.o -lA -lB,那么 B 在命令行中位于 A 之后,能解决 A 的未定义符号,通常没问题;反过来写成main.o -lB -lA,B 里的符号先没被需要,不会被提取,等到 A 需要时 B 已经扫描完了,就报 undefined reference。破解方式是把库放在目标文件之后,或者用-Wl,--start-group -Wl,--end-group让链接器反复扫描。

第三类是缺少必要库。比如程序用了数学函数但忘了-lm,或者用到了某些特性需要额外链接对应库。排查时可以nm -C看目标文件里的未定义符号,再用nm -D看候选库里的动态符号,两边一对就知道缺哪个库。

5.2 编译能过、运行却找不到动态库

典型现象:程序编译链接都成功,一运行就报error while loading shared libraries: libfoo.so.2: cannot open shared object file。这时候用readelf -d看一眼NEEDED条目,确认程序实际需要的是哪个 soname。如果系统里只有libfoo.so.1或libfoo.so.3,说明库 ABI 版本已经变了,可执行文件是用另一套版本编译的。

临时手段是设置LD_LIBRARY_PATH指向新库所在目录,但这不是长久之计。更稳的办法是在链接时写入rpath,比如-Wl,-rpath,'$ORIGIN/lib',让程序优先从自己安装目录下的 lib 子目录找动态库。注意$ORIGIN在 Makefile 里要写好转义,否则会被 shell 展开成空字符串。对发布到多台机器的程序,相对路径 rpath 比绝对路径健壮得多。

5.3 符号版本错误与构建环境差异

跨系统迁移程序时,经常碰到类似GLIBC_2.34 not found的输出。这是因为可执行文件里记录了本身依赖的每个符号版本。如果可执行文件在新系统上编译,其中某些符号要求 glibc 提供比较新的版本,拿到旧系统上运行,旧版 glibc 没有对应符号版本,动态链接器就报错。

查看依赖的版本信息可以readelf -V,它会展示 Verneed 部分,也就是这个程序需要哪些版本标签。通常根治方法是在足够旧的构建环境上编译,保证生成的二进制对运行环境的版本要求尽量低。也可以使用静态链接或者其他对版本不敏感的运行环境。这不只是 glibc 的问题,任何带符号版本机制的库都适用。

5.4 体积、启动速度与调试的三件套技巧

静态库顺序之外,最常见的优化动作是 strip。链接器默认生成的 ELF 会包含符号表和字符串表,这些对运行没有作用。用strip --strip-all可以显著减小体积。但注意 strip 后的程序如果还想用 gdb 看函数名,就得保留符号表,或者另行保存一份未 strip 的副本调试用。

启动速度上,如果程序加载了非常多动态库,可以先LD_DEBUG=statistics ./demo看动态链接器中途花了多少时间。库多了之后,符号解析和重定位开销线性上升。启用-Wl,-z,now会关闭懒绑定,通常启动更慢,但配合-fno-plt可能在调用热点上更快,两者要实测权衡。最能说明问题的是readelf -r里的重定位条目数量,动态库依赖越少、导出符号越少,重定位量越低。

调试层面,我会固定用三个命令开场:readelf -h看文件身份,readelf -d看动态依赖,readelf -r看重定位类型。遇到内存布局问题再用readelf -l看 LOAD 段权限,用 gdb 的info proc mappings对比虚拟地址区间。很多诡异段错误,其实就是某个 section 被放进了不可执行段,或者 GOT 被错误修改,沿着这段映射关系找,往往比瞎猜更高效。我个人感受是,把 ELF、链接、装载这三件事串在一个思维模型里之后,读任何二进制工具的输出都不再是零散知识点,而是同一套底层逻辑的不同侧面。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询