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 段 | 加载信息位置 |
|---|---|---|---|
| ELF | Linux、Solaris、BSD | 节表 + 程序头表 | 程序头(program header) |
| Mach-O | macOS、iOS | 节嵌在段内 | 加载命令(LC_SEGMENT) |
| COFF/PE | Windows | 节表 | 可选头(optional header) |
从源码到可执行文件,格式要经历三种形态:
- 可重定位(relocatable):编译器产出,地址从 0 起算,带完整符号表与重定位表,
ET_REL/MH_OBJECT; - 可执行(executable):链接器产出,地址已固定(或 PIE 下仍是相对的),入口点确定,
ET_EXEC/ET_DYN; - 共享对象(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 与可见性:
| 字段 | 取值 | 含义 |
|---|---|---|
| bind | LOCAL | 文件私有(static) |
| bind | GLOBAL | 全局可见(导出) |
| bind | WEAK | 弱符号,可被强符号覆盖 |
| type | FUNC / OBJECT / SECTION / FILE | 函数 / 变量 / 节 / 文件名 |
| visibility | DEFAULT / 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) | 否 |
| PIC | GOT/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 三格式命令对照
同一件事在不同平台上要用不同工具,记住这张对照表能省下大量查文档时间:
| 操作 | ELF | Mach-O | COFF/PE |
|---|---|---|---|
| 查看头 | readelf -h | otool -h / lipo -info | dumpbin /headers |
| 查看段/节 | readelf -l / -S | otool -l / size -m | dumpbin /section |
| 符号表 | readelf -s / nm | nm / otool -Iv | dumpbin /symbols |
| 重定位 | readelf -r | otool -r | dumpbin /relocations |
| 反汇编 | objdump -d | otool -tvV | dumpbin /disasm |
| 剥离符号 | strip / objcopy | strip / dsymutil | editbin |
一句话:目标文件格式是链接器与操作系统之间的契约。理解节/段、符号表与重定位表这三件套,就能读懂 readelf/otool 的输出,也能在 undefined reference 与 relocation truncated 面前不再抓瞎。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。