目标文件格式:ELF、Mach-O 与 COFF

对比三大目标文件格式:ELF 的节表与程序头表、Mach-O 的加载命令与两段式布局、COFF/PE 的节表与重定位,讲清符号表、重定位记录、链接视图与执行视图的差别,并给出 readelf、otool、dumpbin 等实操命令与常见链接错误排查。

1. 目标文件:编译产物与可执行文件的中间形态

一句话总结: 目标文件是编译器与链接器之间的契约格式,它同时描述「代码与数据长什么样」和「还有哪些地址没定下来」。

编译器为每个翻译单元产出一个可重定位目标文件(relocatable object file),里面装着机器码、只读数据、符号表与重定位表。链接器把这些碎片拼成可执行文件或共享库,同时决定每个符号的最终地址。因此目标文件格式必须回答四个问题:代码放哪、数据放哪、哪些符号导出、哪些地址待填。

# 一份最小 C 程序编译出的目标文件
cc -c hello.c -o hello.o
file hello.o
# hello.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped

三大主流格式各管一摊:ELF 统治 Linux/BSD/嵌入式,Mach-O 是 macOS/iOS 的原生格式,COFF 是 Windows 的 PE 的骨架(.obj)。它们概念相通、细节迥异。

格式使用平台节头 vs 段加载信息位置
ELFLinux、Solaris、BSD节表 + 程序头表程序头(program header)
Mach-OmacOS、iOS节嵌在段内加载命令(LC_SEGMENT)
COFF/PEWindows节表可选头(optional header)

从源码到可执行文件,格式要经历三种形态:

  1. 可重定位(relocatable):编译器产出,地址从 0 起算,带完整符号表与重定位表,ET_REL / MH_OBJECT;
  2. 可执行(executable):链接器产出,地址已固定(或 PIE 下仍是相对的),入口点确定,ET_EXEC / ET_DYN;
  3. 共享对象(shared object):可被动态加载,导出动态符号表 .dynsym 与动态重定位表 .rela.dyn。
# 三种形态一眼可辨
readelf -h a.o  | grep Type    # REL
readelf -h a.out| grep Type    # EXEC 或 DYN(PIE)
readelf -h lib.so | grep Type  # DYN

2. ELF:节表与程序头表

ELF 的核心是两个视角:链接视图看节表(section header table),执行视图看程序头表(program header table)。

ELF 头部 (e_ident, e_type, e_machine, e_entry, ...)
    ├── 程序头表 (Program Header Table)   ← 加载器用,描述「段」
    ├── .text .rodata .data .bss ...      ← 节的内容
    ├── .symtab .strtab                   ← 符号表与字符串表
    ├── .rela.text .rela.data             ← 重定位表
    ├── .debug_*                          ← DWARF 调试信息
    └── 节头表 (Section Header Table)     ← 链接器用,描述「节」

节(section) 是给链接器看的逻辑单位:.text 代码、.rodata 只读数据、.data 已初始化可写数据、.bss 未初始化数据(不占文件空间,只记长度)、.symtab 符号表。段(segment) 是给内核加载器看的物理单位,一段可以包含多个节,关键是权限相同(可读/可写/可执行)。

readelf -h hello.o          # ELF 头
readelf -S hello.o          # 节头表
readelf -l a.out            # 程序头表(段的权限与对齐)
readelf -s hello.o          # 符号表
readelf -r hello.o          # 重定位表

.bss 的设计很讲究:未初始化的全局变量在文件里只占一个「长度」,加载时由内核把整段清零映射进来,因此一个 int buf[1<<20]; 不会让可执行文件变大。与之相对,.data 里全是实打实的字节。

# 看段的权限: 只读+可执行、只读、读写
readelf -l a.out | grep -A1 LOAD
# LOAD ... R E     ← .text
# LOAD ... R       ← .rodata
# LOAD ... RW      ← .data/.bss

2.1 符号表与绑定类型

ELF 符号表(.symtab / .dynsym)里每个符号带 bind、type 与可见性:

字段取值含义
bindLOCAL文件私有(static)
bindGLOBAL全局可见(导出)
bindWEAK弱符号,可被强符号覆盖
typeFUNC / OBJECT / SECTION / FILE函数 / 变量 / 节 / 文件名
visibilityDEFAULT / HIDDEN / PROTECTED决定是否进动态符号表

static 函数与变量会降级为 LOCAL,不进 .dynsym,因此共享库里的私有符号不会污染命名空间——这是符号可见性控制的基础。

2.2 常被忽略的节

除了 .text/.data/.bss,还有一批由编译器与运行时自动生成的节:

节名作用
.init_array / .fini_array构造/析构函数指针数组,__attribute__((constructor)) 落在这里
.eh_frame / .eh_frame_hdr异常展开表,栈回溯与 C++ 异常依赖它
.gcc_except_table异常处理表(landing pad 索引)
.plt / .got / .got.plt动态链接的跳转桩与全局偏移表
.comment编译器版本字符串
.note.gnu.build-id构建指纹,调试符号服务器据此匹配
readelf -x .comment a.out        # 打印编译器版本
readelf -n a.out                 # 查看 note 段(build-id、ABI tag)
objdump -s -j .init_array a.out  # 看构造器指针

.init_array 的存在解释了「C++ 全局对象的构造函数何时运行」:加载器在 main 之前遍历这个数组逐个调用。它也是「静态初始化顺序问题」的物理来源。

3. Mach-O:加载命令驱动的布局

Mach-O 的结构哲学与 ELF 不同:它没有独立的节头表,而是把所有元信息组织成一条条加载命令(load command),节的描述嵌在 LC_SEGMENT / LC_SEGMENT_64 命令里。

Mach-O 头部 (magic, cputype, ncmds, sizeofcmds, ...)
    ├── LC_SEGMENT_64 (__TEXT)   → 节: __text, __stubs, __cstring
    ├── LC_SEGMENT_64 (__DATA)   → 节: __data, __bss, __got
    ├── LC_SYMTAB                → 符号表与字符串表位置
    ├── LC_DYSYMTAB              → 动态符号表
    ├── LC_LOAD_DYLIB            → 依赖的动态库
    ├── LC_UUID                  → 唯一标识
    └── LC_BUILD_VERSION         → 目标平台与 SDK 版本

Mach-O 的命名习惯是双下划线:段叫 __TEXT、__DATA、__LINKEDIT,节叫 __text、__cstring、__data。

otool -h a.out            # Mach-O 头
otool -l a.out            # 全部加载命令
otool -L a.out            # 依赖的动态库
size -m a.out             # 段与节的体积明细

3.1 通用二进制与 LC_BUILD_VERSION

macOS 的 通用二进制(Universal Binary / fat binary) 把多个架构的 Mach-O 打包成一个文件,文件头是 0xcafebabe 的 fat 头,后跟多个架构的偏移与大小:

lipo -info app           # 查看包含哪些架构
lipo -thin arm64 app -output app_arm64
lipo -create app_arm64 app_x86_64 -output app_universal

LC_BUILD_VERSION 记录目标平台(macOS / iOS / tvOS)与最低 SDK,加载器据此决定是否拒绝加载——这是 iOS 上「用旧 SDK 编译的应用被商店拒绝」的技术根因。

3.2 __LINKEDIT 与代码签名

Mach-O 的第三个段 __LINKEDIT 存放链接器产物:符号表、字符串表、间接符号表、重定位、代码签名。代码签名(code signing) 是 macOS/iOS 的强制要求,签名覆盖整个文件的哈希,签名信息本身存在 __LINKEDIT 末尾的 LC_CODE_SIGNATURE 命令所指区域。

codesign -dvvv app             # 查看签名详情
codesign --verify --deep --strict app
otool -l app | grep -A4 LC_CODE_SIGNATURE

这也意味着任何对 Mach-O 的修改都会破坏签名:给二进制打补丁后必须重新签名(codesign -f -s -),否则 macOS 会直接 Killed: 9。

4. COFF / PE:Windows 的节表

COFF(Common Object File Format)是 Unix System V 时代的产物,被微软继承并演化成 PE(Portable Executable)。.obj 文件是 COFF,.exe/.dll 是 PE。

COFF 头 (Machine, NumberOfSections, TimeDateStamp, SizeOfOptionalHeader, ...)
    ├── 节表 (Name, VirtualSize, VirtualAddress, SizeOfRawData, Characteristics)
    ├── 节内容 (.text .data .rdata .bss)
    ├── 符号表
    └── 重定位表 (.reloc / COFF relocation entries)

与 ELF 的最大差别在于重定位与导出的组织方式:PE 用 .reloc 节存放基址重定位(base relocation),用导出表(export table) 与导入表(import table) 描述动态符号,而不是像 ELF 那样把一切都塞进符号表 + 重定位表。

# Windows 上用 dumpbin 观察
dumpbin /headers hello.obj
dumpbin /relocations hello.obj
dumpbin /exports kernel32.dll

COFF 的另一个特色是段合并指令(section directives),MSVC 与 MinGW 用 #pragma section 或 __attribute__((section(".foo"))) 把数据塞进自定义节,再靠链接器脚本(.def 文件或 /SECTION)控制合并顺序。

5. 重定位:链接器要填的那些洞

重定位(relocation) 是目标文件格式里最核心的机制。编译器生成代码时不知道符号最终地址,于是留一个「洞」并附一条重定位记录,告诉链接器「这个位置需要填入符号 X 的地址(加上偏移 A)」。

ELF 的两类重定位:

  • S + A 型(绝对重定位):如 R_X86_64_64,直接把符号地址加偏移写进 8 字节;
  • S + A - P 型(PC 相对重定位):如 R_X86_64_PC32,写入「目标相对当前指令的距离」,用于 call/jmp 与 RIP 相对寻址。
readelf -r hello.o
# Offset          Info    Type            Sym. Value  Symbol's Name + Addend
# 00000000001e  000400000002 R_X86_64_PC32  0000000000000000  puts - 4
# 000000000019  00050000000a R_X86_64_32    0000000000000000  msg

R_X86_64_PC32 的 - 4 是关键细节:x86 的 call 指令本身长 5 字节,PC 在执行时已指向下一条指令,所以要减去「当前偏移到指令末尾的距离」。这类细节写错会导致链接器报 relocation truncated to fit 或运行期崩溃。

5.1 重定位与 PIC / PIE

位置无关代码(PIC)把绝对重定位降到最少:

模式重定位来源是否可 ASLR
非 PIC大量绝对重定位(R_*_32)否
PICGOT/PLT 间接寻址 + PC 相对是
PIE可执行文件也用 PIC 布局是(默认)
# 检查是否编译成 PIE
readelf -h a.out | grep Type    # DYN 表示 PIE, EXEC 表示固定地址
file a.out                      # 现代发行版默认 "pie executable"

-fno-pic 会生成需要重定位的代码,链接成共享库时链接器会报 recompile with -fPIC。

5.2 GOT 与 PLT:PIC 的物理实现

PIC 靠两个表把「绝对地址」变成「间接寻址」:

  • GOT(Global Offset Table):存放外部符号的绝对地址,代码通过 mov rax, [rip + sym@GOTPCREL] 间接取;
  • PLT(Procedure Linkage Table):函数跳转桩,每个外部函数对应一个桩,首次调用时经动态链接器解析并回填 GOT(lazy binding)。
调用 printf:
  call printf@PLT        →  跳到 .plt 桩
  .plt 桩: jmp [printf@GOTPCREL]   → 首次指向 .plt[0],触发解析
           解析后 GOT 填入真实地址,后续直接跳转
objdump -d -j .plt a.out         # 反汇编 PLT
readelf -r a.out | grep GOT      # GOT 重定位项
LD_BIND_NOW=1 ./a.out            # 关闭懒绑定,启动时全部解析

懒绑定能加快启动,但首次调用的开销与「可预测性」较差;安全加固场景(RELRO)常用 -Wl,-z,now 强制立即绑定,让 GOT 在启动后变只读。

6. 调试信息与工具链协作

目标文件还承载调试信息。ELF 用 DWARF(.debug_info、.debug_line、.debug_abbrev),Mach-O 用 __DWARF 段,COFF 用 CodeView(.debug$S、.debug$T)。

# 分离与查看调试信息
objcopy --only-keep-debug app app.debug
objcopy --strip-debug app app.stripped
objcopy --add-gnu-debuglink=app.debug app.stripped
readelf --debug-dump=info app.debug | head -30

交叉工具链的命名规则也藏在格式里:aarch64-linux-gnu- 前缀表示目标三元组是 aarch64 + Linux + GNU ABI,产出的自然是 ELF;x86_64-apple-darwin- 产出 Mach-O。三元组与格式强绑定,这也是「同一份源码在 macOS 上要用 otool、在 Linux 上要用 readelf」的根因。

6.1 实战排查清单

  • undefined reference:符号在 .symtab 里是 UND 且无定义处,检查是否漏链库或 C/C++ 名字修饰不匹配(extern "C")。
  • multiple definition:两个目标文件都导出了同名 GLOBAL 符号,检查是否把定义写进了头文件。
  • relocation R_X86_64_32S against ... can not be used when making a shared object:非 PIC 代码被链接进共享库,加 -fPIC 重编。
  • 段权限异常:readelf -l 里 LOAD 段权限与预期不符,检查链接器脚本的 PHDRS 或 -z noexecstack。
# 快速定位符号来源
nm -C --defined-only *.o | grep my_symbol
ldd a.out            # 动态依赖
objdump -d -M intel a.out | less   # 反汇编

6.2 三格式命令对照

同一件事在不同平台上要用不同工具,记住这张对照表能省下大量查文档时间:

操作ELFMach-OCOFF/PE
查看头readelf -hotool -h / lipo -infodumpbin /headers
查看段/节readelf -l / -Sotool -l / size -mdumpbin /section
符号表readelf -s / nmnm / otool -Ivdumpbin /symbols
重定位readelf -rotool -rdumpbin /relocations
反汇编objdump -dotool -tvVdumpbin /disasm
剥离符号strip / objcopystrip / dsymutileditbin

一句话:目标文件格式是链接器与操作系统之间的契约。理解节/段、符号表与重定位表这三件套,就能读懂 readelf/otool 的输出,也能在 undefined reference 与 relocation truncated 面前不再抓瞎。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「compiler」更多文章

  1. 浮点语义与快速数学优化
  2. 查询式编译器与增量类型检查:Salsa 架构
  3. Sanitizer 与编译期安全加固:ASan、TSan 与 CFI