搞懂 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 : 1=32位 2=64位 EI_DATA : 1=小端 2=大端 EI_VERSION : 当前必须为 1 EI_OSABI : 0=System V 3=Linux 9=FreeBSD(Android 一般走 0) e_type : 1=可重定位文件(.o) 2=可执行文件 3=动态库(.so) e_machine : 0x3E=x86-64 0x28=ARM 0xB7=AArch64 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.hash | GNU 风格符号哈希表,加速符号查找 | 动态库、现代可执行文件 |
.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++ 项目用-fvisibility=hidden把默认导出关掉,再用__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_PC32 | S + A - P | 相对寻址的调用和跳转,最常见 |
R_X86_64_PLT32 | 与 PC32 类似,但走 PLT | 对外部函数调用,默认-fno-plt之外都是它 |
R_X86_64_GOTPCREL | G + GOT + A - P | 通过 GOT 间接访问数据,PIC 代码常用 |
R_X86_64_64 | S + 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内部所有绝对地址引用都无法在链接期固定。PIC(Position Independent Code)就是为这件事设计的:代码内部不写绝对地址,一律通过 GOT/PC 相对方式间接寻址。这样无论库被映射到哪个地址,代码都能正确工作。
可执行文件是否也做 PIC 取决于编译选项。现代 Linux 发行版默认开 PIE(Position 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 printf@plt。 - PLT 条目里先跳转到 GOT 对应槽位。
- 首次调用时,GOT 槽位里存的不是 printf 地址,而是 PLT 里下一段解析代码的地址。
- 解析代码调用动态链接器的符号查找函数,找到 printf 真实地址,写入 GOT 槽位。
- 后续再调用,GOT 已经是真实地址,直接跳转完成。
这就是延迟绑定(Lazy Binding)。LD_BIND_NOW=1可以关闭延迟绑定,让动态链接器在启动阶段一次性重定位所有符号。副作用是启动变慢,好处是符号错误尽早暴露,对安全敏感场景有价值。
GOT 还有其他槽位,比如GLOB_DAT用于全局数据和函数指针,RELATIVE用于相对基址修正。Android 平台上reloc和rela的差异也很重要:ELF64_Rela每个重定位项都带显式r_addend;ELF64_Rel则把加数存在被修改位置里。ARM32 用REL,ARM64 和 x86-64 用RELA。不要混用。
4.3 符号查找顺序和全局符号介入
动态链接时最隐蔽的问题几乎都出在“符号查找顺序”上。不同平台策略不同:
- Linux/glibc:默认按广度优先遍历依赖图,主程序优先。所以同一个符号,主程序里定义了,动态库里的就会引用主程序的版本,这就是“全局符号介入”。
- Android/bionic:符号查找按依赖顺序做“深度优先”,行为跟 glibc 有差异,实际 bug 往往是在 Android 上跑动态库时符号解析到了错误的实现。
- macOS:默认“两层命名空间”,符号必须精确匹配库和符号名;开启
-flat_namespace才变成类似 Linux 的全局介入模式。
全局符号介入有时候是故意用的:主程序定义malloc,动态库里的malloc调用全部被主程序的覆盖,这是典型的拦截做法。但大多数时候它是个坑。遇到诡异崩溃时,可以看一眼.gnu.hash和.dynsym的内容,再结合LD_DEBUG=bindings做诊断。
提示:在大型项目中,强烈建议用
-fvisibility=hidden控制动态库导出面,从源头减少符号冲突。导出面收窄后,不仅符号解析更稳定,动态链接器的哈希查找也能更快。
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_PATH |
relocation truncated to fit | 地址跨度超出指令编码范围 | 减少代码体积、检查内存布局、关闭过于激进的优化 |
exec format error | ELF 格式不被当前内核支持 | 检查架构、位宽、PIE 标记 |
wrong ELF class | 32 位文件加载到 64 位进程 | 确认 ABI 一致 |
undefined symbol: xxx(运行期) | .so加载时找不到依赖符号 | 检查符号版本、可见性、依赖顺序 |
| 启动时直接崩溃且无日志 | 动态链接器在重定位阶段失败 | 用DL_DEBUG或LD_DEBUG打开诊断 |
INTERP段缺失导致无法启动 | 可执行文件不是合法动态链接格式 | 确认不是 PIE 且缺少解释器段 |
6.2 一定要记住的几个排查技巧
先说符号冲突。遇到“符号明明存在但行为异常”的情况,先怀疑同名符号和不正确的导出面。用nm -D ./libs.so看导出符号表,看目标符号是否被意外覆盖。再配合LD_DEBUG=bindings看实际绑到了哪个地址。
再说 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认认真真跑一遍,让文件自己开口说话。多数棘手的现象,在字段对齐之后,早就把答案写在里面了。