ELF 文件:Section、Segment、重定位和动态链接
用结构、示例与工具把 ELF 的类型、布局、重定位和动态链接串起来。
ELF(Executable and Linkable Format)是类 Unix 系统里最常见的目标文件与可执行文件格式。
很多人第一次“真正需要 ELF”发生在现场,常见于:
ldd看着依赖是对的,但程序一跑就报not found/wrong ELF class- 同一台机上有两份
libssl.so,线上偶发崩溃、符号找不到(undefined symbol) - 你拿到一个二进制,只能问:它到底是静态的?动态的?入口在哪?加载了哪些段?
理解 ELF 的结构,你就能把“编译 -> 链接 -> 加载 -> 运行”这一条链路串起来,也能更快定位这些问题。
3 分钟先能用:排查动态链接问题的最短路径
先记住一条经验:ldd 只是“按当前环境模拟一次解析”,不等于真实运行时百分百一致。
当你遇到“依赖库找不到/版本不对”时,按下面顺序走:
- 看 ELF 头和目标平台(32/64 位、架构):
file ./a.out
readelf -h ./a.out | head
- 看它“声明了要找哪些动态库”(NEEDED):
readelf -d ./a.out | grep NEEDED
- 看运行时搜索路径(RPATH/RUNPATH):
readelf -d ./a.out | grep -E 'RPATH|RUNPATH'
- 看动态链接器实际怎么找(必要时开调试):
LD_DEBUG=libs,files ./a.out 2>&1 | head -n 80
- 再回头查系统缓存与路径:
ldconfig -p | grep <libname>
echo $LD_LIBRARY_PATH
后面的内容会把这些命令背后的“为什么”讲清楚。
ELF 的两套视角
ELF 包含两套对同一份二进制的描述:
- Section(链接器视角):编译和静态链接时使用的信息——代码、数据、符号、重定位表。
- Segment(程序头/加载器视角):内核加载器实际映射到内存的区域,带权限(R/W/X)。
简化映射:
Section(链接器) Segment(加载器)
.text LOAD (R-X)
.data + .bss LOAD (RW-)
.symtab / .strtab / .rel.* 不加载(仅用于链接器/调试器)
常见 ELF 类型包括 REL(可重定位目标文件)、EXEC(可执行文件)和 DYN(共享对象)。不要只看类型判断是否动态链接:传统非 PIE 动态可执行文件通常是 EXEC,PIE 与共享库通常都是 DYN。
判断启动链路时同时看三个位置:
readelf -hW ./app | grep 'Type:'
readelf -lW ./app | grep INTERP
readelf -dW ./app | grep NEEDED
PT_INTERP表示内核会启动指定的程序解释器,例如ld-linux。DT_NEEDED列出运行时需要加载的共享对象。- 没有
PT_INTERP的DYN也可能是共享库或静态 PIE,仍需结合 Program Header 与 Dynamic Section 判断。
目标文件示例:ELF Header + Section 表
下面是一个 目标文件(.o) 的 readelf -h 输出节选:
ELF Header:
Class: ELF64
Data: 2's complement, little endian
Type: REL (Relocatable file)
Machine: Advanced Micro Devices X86-64
Entry point address: 0x0
Start of section headers: 0x2c0 (bytes into file)
Number of section headers: 12
readelf -S 的 Section 表节选(仅示意关键列):
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
[ 1] .text PROGBITS 0000 0040 0038 00 AX 0 0 16
[ 2] .rela.text RELA 0000 0210 0018 18 6 1 8
[ 3] .data PROGBITS 0000 0080 0020 00 WA 0 0 8
[ 4] .bss NOBITS 0000 00a0 0010 00 WA 0 0 8
[ 5] .symtab SYMTAB 0000 0240 00f0 18 6 8 8
[ 6] .strtab STRTAB 0000 0330 0048 00 0 0 1
字段要点:
- Addr:加载地址(目标文件里常为 0,待链接修正)。
- Off/Size:在文件中的偏移与大小。
- Flg:权限标记(A=可分配,X=可执行,W=可写)。
目标文件布局示意
0x0000 ELF Header
0x0040 .text
0x0080 .data
0x00a0 .bss(文件中不占空间)
0x0210 .rela.text
0x0240 .symtab
0x0330 .strtab
0x02c0 Section Header Table
可执行文件示例:Program Header 与 Segment
链接完成后,可执行文件会出现 Program Header(Segment 表):
Program Headers:
Type Offset VirtAddr FileSiz MemSiz Flg Align
LOAD 0x0000 0x400000 0x0800 0x0800 R E 0x1000
LOAD 0x1000 0x601000 0x0200 0x0300 RW 0x1000
解释:
- 第一段 LOAD:包含
.text,权限 R-X。 - 第二段 LOAD:包含
.data + .bss,权限 RW-。 MemSiz > FileSiz通常说明.bss仅占内存而不占文件空间。
可用 readelf -l 查看 Section to Segment mapping,理解“Section 合并成 Segment”的过程。
重定位:用一个 x86-64 例子看清链接器改了什么
先准备两个文件。reader.c 只声明变量,不知道它最终放在哪里:
extern int shared_value;
int read_value(void) {
return shared_value + 1;
}
main.c 提供变量定义并调用函数:
int shared_value = 41;
int read_value(void);
int main(void) {
return read_value() != 42;
}
只编译 reader.c,再检查重定位表与反汇编:
gcc -O0 -fno-pic -c reader.c -o reader.o
readelf -rW reader.o
objdump -dr reader.o
在常见 x86-64 GNU 工具链中,可以看到针对 shared_value 的 R_X86_64_PC32 重定位。反汇编中的 RIP 相对位移还是占位值,objdump -dr 会把对应的重定位条目标在指令下面。
再完成链接:
gcc -O0 -fno-pie -no-pie reader.o main.c -o reloc-demo
objdump -d reloc-demo | sed -n '/<read_value>/,+8p'
./reloc-demo
echo $?
链接器根据符号最终地址写入位移,程序退出码应为 0。本地、同一 Section 内的分支通常能由汇编器直接算出位移;跨 Section 或引用外部符号时,即使指令使用相对寻址,也仍可能需要重定位。
共享库与 PIC / GOT / PLT
共享对象通常使用 PIC,避免把绝对地址写死在代码段中:
- GOT(Global Offset Table) 保存变量或函数地址。
- PLT(Procedure Linkage Table) 提供外部函数调用跳板。
- 延迟绑定允许第一次调用时解析符号;
-z now或LD_BIND_NOW=1会改为启动时解析。
下面用 puts 验证,而不是记一组会随编译器变化的地址:
#include <stdio.h>
int main(void) {
puts("first call");
puts("second call");
return 0;
}
gcc -O0 -fno-pie -no-pie -Wl,-z,lazy lazy.c -o lazy-demo
readelf -dW lazy-demo | grep -E 'BIND_NOW|FLAGS'
readelf -rW lazy-demo | grep -E 'JUMP_SLOT|puts'
objdump -d -j .plt lazy-demo
LD_DEBUG=bindings ./lazy-demo 2>&1 | grep -E 'binding.*puts'
readelf -dW 没有出现 BIND_NOW,表示该程序允许延迟绑定;LD_DEBUG=bindings 则给出运行时实际发生的符号绑定。
readelf -rW 给出 puts 的 GOT Slot。需要观察第一次、第二次调用之间的变化时,可以在 GDB 中使用这个地址:
(gdb) break 'puts@plt'
(gdb) run
(gdb) disassemble /r 'puts@plt'
(gdb) x/gx <GOT_SLOT_ADDRESS>
(gdb) continue
(gdb) x/gx <GOT_SLOT_ADDRESS>
第二次停在 puts@plt 时,第一次调用已经完成,GOT Slot 通常已从解析跳板更新为 libc 中的实际函数地址。具体地址和 PLT 指令序列取决于架构、链接器与 CET/PLT 配置,不应复制示例地址做断言。
动态链接再补一刀:为什么我明明装了库,程序还是找不到?
安装了库文件,程序仍报 not found 或 undefined symbol,常见原因有三类:
- 库在磁盘上,但不在当前搜索链路里:检查
RPATH/RUNPATH、LD_LIBRARY_PATH、ld.so.cache与系统默认目录。 - 存在多份相同 SONAME:当前环境与服务进程环境不同,可能解析到另一份文件。
- ABI/架构不匹配:比如 64 位程序去加载 32 位库(
wrong ELF class)。
对不可信二进制,不要先运行 ldd;使用 readelf -dW 或 objdump -p 静态查看 NEEDED。需要验证实际搜索和绑定过程时,再在隔离环境运行 LD_DEBUG=libs,bindings。
常用工具清单
# ELF 头、节、段
readelf -h a.out
readelf -S a.out
readelf -l a.out
# 反汇编、符号
objdump -d a.out
objdump -t a.out
nm -n a.out
# 体积、依赖、字符串
size a.out
ldd a.out
strings a.out