2026-01-09

ELF 文件:Section、Segment、重定位和动态链接

用结构、示例与工具把 ELF 的类型、布局、重定位和动态链接串起来。

ELF(Executable and Linkable Format)是类 Unix 系统里最常见的目标文件与可执行文件格式。

很多人第一次“真正需要 ELF”发生在现场,常见于:

  • ldd 看着依赖是对的,但程序一跑就报 not found / wrong ELF class
  • 同一台机上有两份 libssl.so,线上偶发崩溃、符号找不到(undefined symbol)
  • 你拿到一个二进制,只能问:它到底是静态的?动态的?入口在哪?加载了哪些段?

理解 ELF 的结构,你就能把“编译 -> 链接 -> 加载 -> 运行”这一条链路串起来,也能更快定位这些问题。

3 分钟先能用:排查动态链接问题的最短路径

先记住一条经验:ldd 只是“按当前环境模拟一次解析”,不等于真实运行时百分百一致。

当你遇到“依赖库找不到/版本不对”时,按下面顺序走:

  1. 看 ELF 头和目标平台(32/64 位、架构):
file ./a.out
readelf -h ./a.out | head
  1. 看它“声明了要找哪些动态库”(NEEDED):
readelf -d ./a.out | grep NEEDED
  1. 看运行时搜索路径(RPATH/RUNPATH):
readelf -d ./a.out | grep -E 'RPATH|RUNPATH'
  1. 看动态链接器实际怎么找(必要时开调试):
LD_DEBUG=libs,files ./a.out 2>&1 | head -n 80
  1. 再回头查系统缓存与路径:
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,常见原因有三类:

  1. 库在磁盘上,但不在当前搜索链路里:检查 RPATH/RUNPATH、LD_LIBRARY_PATH、ld.so.cache 与系统默认目录。
  2. 存在多份相同 SONAME:当前环境与服务进程环境不同,可能解析到另一份文件。
  3. 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

参考链接