
搞懂 ELF 之后很多以前觉得玄乎的东西都会变得非常具体。比如程序为什么启动时会崩、链接时为什么报一堆“未定义符号”、动态库里符号互相覆盖是怎么回事追根溯源全都指向这一个文件格式。这篇内容我尽量按我会用的方式去写先讲清楚 ELF 的结构和设计逻辑再拆到链接、加载、重定位这些实操里必然遇到的部分最后给一套实际排查用的命令和思路。不管你是做客户端、做嵌入式、搞安全分析还是底层系统开发只要你跟二进制打过交道这篇都能对得上。1. 为什么看不懂 ELF 就搞不定链接1.1 ELF 绝不是“文件格式”这么简单很多人把 ELF 当成一种“文件格式”来记就像记 PNG 有文件头、JPEG 有 SOI 标记一样。这个理解不算错但远远不够。ELF 不是一个单纯的存储布局它是一套面向“链接器、加载器、调试器、反汇编器”的通用数据交换协议。你写的.c文件经过编译变成.o是 ELF链接成.so动态库是 ELF最后生成可执行文件还是 ELF。甚至 core dump 文件、内核模块.ko、Android 上的.so底层统统都是 ELF。这说明一个事ELF 它要同时服务“编译产物”和“运行产物”两种状态。编译期编译器把符号、节区、重定位信息写进 ELF链接期链接器读出这些信息做符号解析和地址修正运行期加载器又按 ELF 里的段信息把代码和数据映射到进程地址空间。每一种场景对 ELF 的要求是不同的所以 ELF 的设计才显得“绕”——你从不同角度去看它会长得不一样。我自己最早看 ELF 文档时最大感受是每个字段都认识但连起来不知道有什么用。后来才明白ELF 的难点不在于结构本身而在于你必须理解“链接和装载到底需要什么”。结构只是答案问题才是关键。1.2 看待 ELF 的三种视角学 ELF 最忌讳用一种视角套到底。我习惯把它拆成三层来看编译器视角把源码翻译成机器码同时输出符号、调试信息、重定位项。它关心的是“怎么把我的输出组织好让链接器能读懂”。链接器视角把多个.o和库合并成一个可执行文件或动态库做地址分配、符号解析、重定位。它关心的是“符号表、节区信息、重定位表是否完整一致”。加载器视角把 ELF 文件映射到内存建立进程映像处理动态依赖。它关心的是“哪些段要映射、权限是什么、入口点在哪、依赖哪些动态库”。每一层看到的字段优先级完全不一样。编译器懂但不一定关心的东西加载器可能非常敏感。比如e_flags里的处理器特殊标志编译期可能只是顺手记录但到了 Android 平台EI_OSABI和标志位会影响系统要不要额外做架构兼容判断。所以不要试图按“从头到尾的线性顺序”去背 ELF。更好的办法是按工具链处理的阶段来理解。拿到一个 ELF 文件你先问自己一个基本问题——我现在是拿它来做链接、做加载还是做调试答案不同你应该重点看的东西就不同。2. ELF 文件布局从头部到节区的完整拆解2.1 ELF Header最容易被忽略的“说明书”ELF Header 位于文件最前面固定长度。32 位下是 52 字节64 位下是 64 字节。它本身不包含业务逻辑但所有解析工具都要靠它定位后续内容。关键字段拆开说e_ident[EI_NIDENT] : 16 字节的标识数组 0x7f E L F : 魔数硬编码 EI_CLASS : 132位 264位 EI_DATA : 1小端 2大端 EI_VERSION : 当前必须为 1 EI_OSABI : 0System V 3Linux 9FreeBSDAndroid 一般走 0 e_type : 1可重定位文件(.o) 2可执行文件 3动态库(.so) e_machine : 0x3Ex86-64 0x28ARM 0xB7AArch64 e_version : 固定为 1 e_entry : 进程入口点的虚拟地址动态库和 .o 文件通常为 0 e_phoff : Program Header Table 的文件偏移 e_shoff : Section Header Table 的文件偏移 e_flags : 处理器特定标志比如 ARM64 的 ABI 变体 e_ehsize : ELF Header 自身大小 e_phentsize / e_phnum : Program Header 单条大小与条数 e_shentsize / e_shnum : Section Header 单条大小与条数 e_shstrndx : 节区名字符串表在节区表中的索引实操中我看e_entry和e_phoff的次数最多。e_entry告诉你入口点在哪e_phoff决定了解析段视图的起点。e_shoff则是节区视图的起点。可执行文件通常有完整的 Program Header但 Section Header 不一定反过来.o文件通常没有 Program Header。很多人用readelf -h看头部时一脸懵其实只要先确认e_type是哪种文件整个解析思路就清楚了。注意动态库的e_entry是 0因为入口不在库里而在主程序里。但.so的初始化代码是有的只是不通过e_entry找而是通过动态节区里的INIT和FINI数组来定位。2.2 节区表与段视图两套视角必须分清这是新手最容易踩坑的地方。ELF 文件里有两个视图一个是 Section Header Table以“节区”为单位描述文件内容另一个是 Program Header Table以“段”为单位描述加载行为。拿readelf -S看到的.text、.data、.bss是节区视图主要用于链接和调试。拿readelf -l看到的LOAD、DYNAMIC、GNU_STACK是段视图主要用于加载器把文件映射成进程映像。两者关系是多个节区会被合并到一个段里。比如.text、.rodata、.plt常常被合并成同一个只读可执行的LOAD段.data、.bss合并成读写段。这是出于页对齐和权限管理的考虑——加载器映射内存时是按页处理的不可能给每个节区单独设权限那样既浪费空间又增加页表压力。还有个坑objcopy或strip掉节区表之后很多调试工具会失效但程序还是能正常跑。因为运行只需要段视图不依赖节区表。Android 上常见“找不到 xweb ELF”或某些 so 加载失败的问题一部分就出在 ELF 的节区信息被裁剪、而某些校验逻辑又依赖节区视图上。这类问题的排查方向先区分是“加载期”问题还是“链接期”问题不要一上来就看符号表。常用节区的作用我整理一下节区名作用出现场景.text代码段存放机器指令所有文件.plt/.plt.got动态函数跳板延迟绑定的桥梁动态库、可执行文件.data已初始化的可写数据所有文件.bss未初始化的零初始化数据所有文件.rodata只读常量字符串、跳转表几乎所有文件.dynsym动态符号表导出/导入的外部符号动态库、可执行文件.symtab完整符号表含本地符号与调试符号未 strip 的文件.rela.text等重定位表描述地址修正规则静态链接文件、PIE 可执行.dynamic动态链接信息数组依赖、初始化函数动态库、可执行文件.gnu.hashGNU 风格符号哈希表加速符号查找动态库、现代可执行文件.dynsym和.symtab的差异值得展开说。.dynsym是运行时必需的符号表体积小只包含动态链接需要的导入导出符号。.symtab包含全部符号包括局部变量、静态函数、调试辅助符号体积很大。发布到线上的 so 通常会把.symtabstrip 掉但.dynsym必须保留否则动态链接器根本没法解析符号。2.3 符号表链接世界的“通讯录”符号表就是 ELF 里的“通讯录”。每条符号表项记录了符号的名字、所在节区、值地址或偏移、大小、绑定属性、可见性。readelf -s输出里最关键的三列是Type、Bind、Ndx。Bind决定这个符号能不能从外部引用GLOBAL全局可见链接时可以被其他目标文件引用。LOCAL文件私有外部不可见。所有以static修饰的函数和变量都是 LOCAL。WEAK弱符号允许重复定义主定义优先没有主定义时也不会报错。Ndx这列比较阴险。常见的值是数字表示符号定义在第几个节区。但是UND未定义表示这个符号在当前文件里没有定义需要链接期解决。ABS表示绝对值比如常量。COM是 COMMON 块通常在 Fortran 或某些未初始化全局变量场景出现。排查“未定义符号”类报错时第一件事就是看报错符号在对应.o或库里的Ndx是不是UND以及从哪个输入文件引出的。符号表里还有一个容易忽略的概念叫“符号版本”。Symbol Version 是 glibc 特有的扩展用来解决同一个版本库的 ABI 兼容问题。比如memcpy在 glibc 2.14 里引入了新的版本GLIBC_2.14老版本GLIBC_2.2.5也有实现。程序链接时会把版本号写进重定位信息里。在 Android bionic 上这个机制不完全一致好在日常开发一般不直接碰。关于符号可见性readelf -s中Visibility这一列也有讲究。DEFAULT表示按 Bind 属性正常处理HIDDEN表示对外不可见但本文件内可用这对优化动态库导出面非常有用。很多大型 C 项目用-fvisibilityhidden把默认导出关掉再用__attribute__((visibility(default)))显式标记需要导出的接口。这样动态库的符号表会干净很多查找速度快符号覆盖概率也小。3. 核心原理拆解链接器如何处理地址和符号3.1 链接的本质符号解析加地址修正链接器做的事情概括起来就两件事符号解析和重定位。符号解析把当前文件引用的符号跟其他文件定义的符号建立绑定关系。目标是消除所有UND符号。谁都没定义就报“undefined reference”。地址修正把所有引用该符号的指令或数据从“当前还不知道地址”的状态改成“最终虚拟地址/偏移”的状态。这就是重定位。理解这两个步骤之后你再去看链接报错就非常直观。undefined reference to foo本质就是符号解析阶段找不到定义relocation truncated to fit: R_X86_64_PC32 against bar本质就是地址修正阶段发现地址跨度太大放不下了。编译.c文件时编译器不知道foo最终在哪所以在.o文件里生成一个重定位表项目标节区、偏移、重定位类型、符号名。链接器拿到所有.o和库之后按链接脚本分配虚拟地址再遍历每个重定位项做计算把修正值回填到对应位置。整个过程全部完成后才形成一个可执行的 ELF。3.2 重定位类型务必吃透的那几个重定位类型很多不同架构上百种。但大部分场景下你只需要吃透常用的几类。以 x86-64 为例重定位类型计算方式典型场景R_X86_64_PC32S A - P相对寻址的调用和跳转最常见R_X86_64_PLT32与 PC32 类似但走 PLT对外部函数调用默认-fno-plt之外都是它R_X86_64_GOTPCRELG GOT A - P通过 GOT 间接访问数据PIC 代码常用R_X86_64_64S A绝对 64 位地址非 PIC 或地址静态确定的场景R_X86_64_JUMP_SLOT动态重定位GOT 项填入真实地址动态符号的跳转绑定公式里 S 是符号的实际地址A 是加数通常是保存在重定位项或指令里的固定值P 是重定位位置所在地址GOT 是全局偏移表基址G 是符号在 GOT 中的偏移。R_X86_64_PC32拿“PC 相对调用”举例call指令的操作数是目标地址减去下一条指令地址的差值。链接器算一遍目标符号地址 S当前重定位位置 P指令里加数 A 通常是 -4因为call的相对偏移从下一条指令开始算。所以目标是 S A结果 (S A) - P写进指令码。这就是“这个 call 到底跳到哪”的计算全过程。你要是手工改二进制或者做热补丁这类型是百分百要碰的。ARM64 上同样有对应的R_AARCH64_CALL26、R_AARCH64_JUMP26等原理类似但分支指令范围更小只有 ±128MB。跨过这个范围就要靠 veneer 或 trampoline 来兜底。嵌入式链接时经常出现relocation truncated一大半是代码量把分支范围撑爆了。3.3 静态链接时的完整重定位流程以两个目标文件链接成可执行文件为例完整流程是读取所有.o文件的节区信息按链接脚本合并同类节区到输出节区。给所有输出节区分配虚拟地址VMA。建立全局符号表把每个定义的符号绑定到最终虚拟地址。逐个遍历输入的节区对每个重定位项做计算把结果写回指令或数据位置。生成 ELF 文件头和节区表完成输出文件。这里有个关键点容易被忽略重定位项虽然叫“重定位”但它描述的不是“位置”而是“规则”。规则里包含符号、偏移、类型、加数。链接器执行的是遍历加回填而不是重新扫描指令去猜意图。所以链接器不关心机器码本身它只按表格干活。这也是为什么汇编器必须忠实地为每个引用生成重定位项少一个都不行。.o文件里的地址天然是 0 开始的相对偏移链接器分配完 VMA 之后局部符号会直接加上基址全局符号按最终符号表地址处理。凡是跨目标文件引用的符号都必须借助重定位项做修正。同文件内部的跳转在汇编阶段可能就被相对寻址解决了不产生重定位项。静态链接完成后的可执行文件里重定位表通常会被清理掉因为已经用不上了。但如果有--emit-relocs或调试需求也可能保留。这也是为什么 strip 过和没 strip 过的文件二进制体积差异可以很大。4. 动态链接与运行时ELF 的“运行时人格”4.1 动态链接器究竟干了什么静态链接把代码直接黏进可执行文件一了百了。动态链接则把链接过程推迟到运行期由动态链接器完成。动态链接器的身份比较特殊它自己也是一个 ELF但它的使命是加载其他 ELF。加载流程大体分四步读取可执行文件的 Program Header把PT_LOAD段按页对齐映射到内存。解析PT_DYNAMIC拿到依赖库列表DT_NEEDED。递归加载所有依赖的.so并为它们做基地址随机化ASLR 场景下每次加载地址都可能不同。执行重定位把所有符号引用绑定到实际定义地址最后调用初始化函数。因为动态库的加载地址是运行期才确定的所以.so内部所有绝对地址引用都无法在链接期固定。PICPosition Independent Code就是为这件事设计的代码内部不写绝对地址一律通过 GOT/PC 相对方式间接寻址。这样无论库被映射到哪个地址代码都能正确工作。可执行文件是否也做 PIC 取决于编译选项。现代 Linux 发行版默认开 PIEPosition Independent Executable可执行文件本身也是 PIC 的。Android 从 API 21 开始强制 PIE 可执行文件非 PIE 直接拒绝运行。碰到“Bad ELF”或“exec format error”先查是不是非 PIE 的可执行文件在作怪。4.2 PLT 和 GOT延迟绑定的精密配合动态符号的调用在 ELF 内部靠 PLT 和 GOT 协作完成。简单理解GOT.got / .got.plt一张地址表每个动态符号一个槽位槽位里存真实函数地址。PLT.plt一段跳板代码每个动态函数一个条目。调用过程大致是程序call printfplt。PLT 条目里先跳转到 GOT 对应槽位。首次调用时GOT 槽位里存的不是 printf 地址而是 PLT 里下一段解析代码的地址。解析代码调用动态链接器的符号查找函数找到 printf 真实地址写入 GOT 槽位。后续再调用GOT 已经是真实地址直接跳转完成。这就是延迟绑定Lazy Binding。LD_BIND_NOW1可以关闭延迟绑定让动态链接器在启动阶段一次性重定位所有符号。副作用是启动变慢好处是符号错误尽早暴露对安全敏感场景有价值。GOT 还有其他槽位比如GLOB_DAT用于全局数据和函数指针RELATIVE用于相对基址修正。Android 平台上reloc和rela的差异也很重要ELF64_Rela每个重定位项都带显式r_addendELF64_Rel则把加数存在被修改位置里。ARM32 用RELARM64 和 x86-64 用RELA。不要混用。4.3 符号查找顺序和全局符号介入动态链接时最隐蔽的问题几乎都出在“符号查找顺序”上。不同平台策略不同Linux/glibc默认按广度优先遍历依赖图主程序优先。所以同一个符号主程序里定义了动态库里的就会引用主程序的版本这就是“全局符号介入”。Android/bionic符号查找按依赖顺序做“深度优先”行为跟 glibc 有差异实际 bug 往往是在 Android 上跑动态库时符号解析到了错误的实现。macOS默认“两层命名空间”符号必须精确匹配库和符号名开启-flat_namespace才变成类似 Linux 的全局介入模式。全局符号介入有时候是故意用的主程序定义malloc动态库里的malloc调用全部被主程序的覆盖这是典型的拦截做法。但大多数时候它是个坑。遇到诡异崩溃时可以看一眼.gnu.hash和.dynsym的内容再结合LD_DEBUGbindings做诊断。提示在大型项目中强烈建议用-fvisibilityhidden控制动态库导出面从源头减少符号冲突。导出面收窄后不仅符号解析更稳定动态链接器的哈希查找也能更快。5. 实操用命令工具做一次完整 ELF 诊断5.1 总览文件头与程序头诊断 ELF 的第一步永远是先看文件头。使用file命令是最快的file ./libxweb.so实际输出类似ELF 64-bit LSB shared object, ARM aarch64, version 1 (SYSV), dynamically linked, stripped一条输出能确认架构、位宽、端序、文件类型、是否动态链接、是否 strip。如果file显示not stripped符号表和调试节区都还在如果stripped.symtab已移除只能靠.dynsym查动态符号。然后看结构readelf -h ./libxweb.so readelf -l ./libxweb.so readelf -S ./libxweb.so-h确认入口点和头部信息。.so一般入口为 0。-l看 Program Header重点几个PT_LOAD的权限和偏移、PT_DYNAMIC是否存在、PT_INTERP只在可执行文件出现。一个常见问题是 .so 的LOAD段不是页对齐的放到某些定制的加载器里可能触发段错误。假如碰到“微信 找不到 xweb elf”这类问题一个典型排查路径是先确认 so 文件是否存在、路径是否正确。然后再用readelf -h看 ELF 头部是否损坏、架构是否匹配再用readelf -d看动态段里的DT_NEEDED依赖是否齐全。很多时候“找不到 ELF”并不是格式坏了而是文件字节被裁剪、头字段被改动或者架构不匹配导致加载器直接拒绝识别。5.2 查看符号与依赖查看动态符号表readelf -s ./libxweb.so | grep FUNC.*GLOBAL这会列出所有全局函数。排查“导出符号缺失”时重点确认目标符号是否在.dynsym里。如果在.symtab而不在.dynsym说明它没有导出外部引用必然失败。查看依赖库readelf -d ./libxweb.so | grep NEEDED或者更直观objdump -p ./libxweb.so如果某个DT_NEEDED的库缺失加载时会在dlopen阶段报cannot find。此时ldd也能帮忙但ldd在某些环境会真执行加载过程对目标文件可能产生副作用。静态检查尽量用readelf -d配合objdump -p完成。5.3 检查.o文件的重定位与符号问题链接报错发生在.o层面readelf -s和readelf -r是两个最实用的子命令。readelf -r ./module.o输出里每一条重定位项包括偏移、信息、类型、符号名、加数。排查“undefined reference”时拿报错符号在这个.o的重定位表里搜一遍就能确认是哪条代码在引用它。objdump -dr ./module.o | grep -A5 foo-d反汇编-r叠加显示重定位。这条组合非常实用能直接看到哪条指令引用了哪个符号。当年排查一个性能问题就是靠objdump -dr发现目标文件里多了一条本应被优化的 PC 相对引用没有收敛导致运行时多跳了一次 GOT。6. 常见问题与排查技巧实录6.1 报错速查表报错或现象直接原因排查方向undefined reference to xxx链接时符号没有定义确认符号拼写、依赖库顺序、库是否被 strip 了导出cannot open shared object file运行期找不到.so检查DT_NEEDED和实际文件路径、LD_LIBRARY_PATHrelocation truncated to fit地址跨度超出指令编码范围减少代码体积、检查内存布局、关闭过于激进的优化exec format errorELF 格式不被当前内核支持检查架构、位宽、PIE 标记wrong ELF class32 位文件加载到 64 位进程确认 ABI 一致undefined symbol: xxx运行期.so加载时找不到依赖符号检查符号版本、可见性、依赖顺序启动时直接崩溃且无日志动态链接器在重定位阶段失败用DL_DEBUG或LD_DEBUG打开诊断INTERP段缺失导致无法启动可执行文件不是合法动态链接格式确认不是 PIE 且缺少解释器段6.2 一定要记住的几个排查技巧先说符号冲突。遇到“符号明明存在但行为异常”的情况先怀疑同名符号和不正确的导出面。用nm -D ./libs.so看导出符号表看目标符号是否被意外覆盖。再配合LD_DEBUGbindings看实际绑到了哪个地址。再说 strip。发布动态库前 strip 是常态但注意strip --strip-unneeded可能把某些重定位节区也一起清掉导致某些以节区扫描方式工作的系统比如部分加固方案、热补丁框架识别不到结构。做逆向分析时节区表被清空是最常见的“对抗”手段之一。此时需要依赖程序头去推敲“节区信息不完全等于加载信息”。然后是哈希节区。默认.hash是 SYSV 老式哈希表.gnu.hash是 GNU 扩展。两者并存时动态链接器选择哪一个会影响查找顺序。在你用patchelf改动依赖库时需要特别注意.gnu.hash的同步更新。否则会出现“readelf -d能看到依赖运行却找不到符号”的诡异情况。还有一个非常容易踩的坑把.a静态库当成普通库丢进链接命令。如果你的编译命令出现gcc main.o curl.a -o app这种写法链接器只会在当前位置需要某个符号时才从curl.a中抽取目标文件。顺序写反curl.a里的符号根本没机会参与符号解析就会出现“明明库里定义了却报 undefined”。正确做法是把静态库放在所有引用它的.o文件之后或者用--start-group/--end-group包裹多库依赖环。6.3 我日常必用的三个组合命令诊断 ELF 时下面三个组合几乎覆盖九成场景readelf -h -l -S -d -s file打印文件头、程序头、节区表、动态段和符号表。信息量大但全。objdump -p -t -d file打印动态段、符号表和反汇编。适合深挖行为和指令级细节。nm -D --defined-only file只看导出的已定义符号用来快速确认 ABI 导出面。这三个组合可以从小白排查干到逆向分析都够用。真正遇到复杂 cases再加llvm-readelf和llvm-objdump交叉验证因为 GNU 工具和 LLVM 工具在部分边缘字段的解析行为有细微差异两份对照更容易发现问题。7. 从 ELF 文件格式延伸出去后续还能往哪些方向深入我个人建议有两条线可以继续往下挖。一条是ELF 与 ABI的交叉地带。文件格式只是骨架真正决定行为的是 ABI 约定函数调用时参数怎么传、寄存器哪些调用者保存、结构体内存布局怎么对齐、异常如何处理。单独看 ELF 不懂 ABI很多重定位类型、.eh_frame节区、.ARM.exidx就只是“看起来存在但不知道干嘛”。把 ABI 搞明白之后你再去看readelf -s里的符号和重定位项会有一种“原来如此”的通透感。另一条是ELF 与运行时生态的结合。比如dlopen/dlsym的完整调用链从应用程序的调用角度到动态链接器内部符节区查找再到符号版本匹配最后到 GOT 槽位更新这一整条路径跑通了你就不怵任何动态库加载问题。再往上可以延伸到 Android 的linker与 Bionic 的差异、bundle 时代 so 拆分与压缩、V2 签名校验对 so 段的映射约束。它们全都绕着 ELF 转理解了 ELF 就等于拿到了分析一切二进制问题的总钥匙。我自己的体会是第一次翻 ELF 文档时觉得枯燥原因是没把它跟“自己在链接和加载时真正遇到的问题”绑定起来。后来每次踩坑都回来翻对应字段翻一次就牢固一层。这个格式并不复杂但需要你在具体的问题上下文里反复遇见、反复验证。保持一个习惯就好遇到任何二进制相关的问题别急着猜先把readelf -h、readelf -l、readelf -s认认真真跑一遍让文件自己开口说话。多数棘手的现象在字段对齐之后早就把答案写在里面了。