6、目标文件格式:ELF/PE/Mach-O结构解析,节区(section)、段(segment)、符号表详解
说实话,搞C++这么多年,我见过不少同事把编译链接当成黑盒。点一下编译,出来个.o文件,完事。但真正遇到链接错误、符号冲突、或者想优化二进制体积时,不懂目标文件格式,就像修车不看发动机——只能瞎猜。
今天咱们就打开这个黑盒,看看ELF、PE、Mach-O这三种主流目标文件格式到底长什么样。我会结合自己踩过的坑来讲,希望能帮你少走弯路。
6.1 目标文件是什么?
目标文件,说白了就是编译器把源代码翻译成机器码后的中间产物。它还不是最终的可执行文件,但已经包含了足够的信息,等着链接器来收拾残局。
我习惯把目标文件比作「半成品」——菜切好了、调料备齐了,但还没下锅。链接器就是那个大厨,把这些半成品拼成一桌菜。
常见的三种格式:
- ELF(Executable and Linkable Format):Linux/Unix世界的老大
- PE(Portable Executable):Windows的看家本领
- Mach-O:macOS/iOS的专属格式
它们虽然出身不同,但核心思想惊人地相似——都是把二进制数据分成若干块,每块有特定用途,再配个目录索引。
6.2 节区(Section)与段(Segment)
这两个概念容易搞混。我刚开始学的时候也晕过一阵子。
节区(Section)是链接器视角的划分。每个节区存放一类数据,比如代码、只读数据、可读写数据等。链接器根据节区名来合并、重定位。
段(Segment)是加载器视角的划分。操作系统加载程序时,不会关心你分了几个节区,它只关心哪些内存需要可读、哪些需要可写、哪些需要可执行。段就是把这些权限相同的节区打包在一起。
举个例子:
// 一个简单的C++文件
int global_var = 42; // 存放到 .data 节区
const char* msg = "hello"; // 字符串在 .rodata,指针在 .data
static int local_static = 0; // 存放到 .bss 节区(未初始化)
void func() { // 代码在 .text 节区
int stack_var = 1; // 栈上分配,不进目标文件
}
编译后,这些节区会被映射到不同的段:
| 段 | 包含的节区 | 权限 |
|---|---|---|
| 代码段(.text) | .text | 可读、可执行 |
| 数据段(.data) | .data, .bss | 可读、可写 |
| 只读数据段(.rodata) | .rodata | 可读 |
readelf -S 可以查看ELF文件的节区信息,readelf -l 看段信息。我排查链接问题时,这两个命令几乎天天用。
6.3 ELF文件结构详解
ELF是我打交道最多的格式。它的结构其实很清晰:
+------------------+
| ELF Header | ← 文件头,描述文件类型、架构、入口点等
+------------------+
| Program Headers | ← 段表,告诉系统如何加载
+------------------+
| Section Headers | ← 节区表,告诉链接器如何合并
+------------------+
| 实际数据 | ← 各个节区的内容
+------------------+
ELF Header里有个关键字段叫 e_type,它告诉你这是个啥文件:
ET_REL(1):可重定位文件,就是咱们的 .o 文件ET_EXEC(2):可执行文件ET_DYN(3):共享目标文件,比如 .so
我记得有一次,同事死活链接不上一个库,报错说"文件格式不对"。我一看,他把一个可执行文件当库来链接了。嗯,e_type 是 ET_EXEC,不是 ET_DYN,链接器当然不认。
6.4 PE文件结构
PE格式是Windows的嫡系。它的结构跟ELF不太一样,但核心逻辑相通:
+------------------+
| DOS Header | ← 兼容DOS的遗留物
+------------------+
| PE Signature | ← "PE\0\0" 标识
+------------------+
| File Header | ← 类似ELF Header
+------------------+
| Optional Header | ← 名字叫Optional,其实必须存在
+------------------+
| Section Table | ← 节区表
+------------------+
| 各个节区 | ← .text, .data, .rdata 等
+------------------+
PE里有个有意思的东西叫 导入表(Import Table)和导出表(Export Table)。DLL的导出函数就记录在导出表里。我做过一个项目,需要动态加载DLL里的函数,就是通过解析导出表来定位函数地址的。
.text,但Windows下通常叫 .text 或 CODE。别被名字迷惑,看节区属性才是正道。
6.5 Mach-O文件结构
Mach-O是苹果家的格式。它的设计更灵活,支持多种架构打包在一起(Universal Binary)。
+------------------+
| Mach-O Header | ← 魔数、CPU类型、文件类型
+------------------+
| Load Commands | ← 类似Program Headers,描述布局
+------------------+
| 各个段/节区 | ← __TEXT, __DATA, __LINKEDIT 等
+------------------+
Mach-O的段名和节区名都带双下划线前缀,比如 __TEXT.__text 表示代码段里的代码节区。我第一次看到 __DATA.__common 时还愣了一下——原来未初始化的全局变量放这里。
苹果从iOS 13/macOS Catalina开始强推 代码签名 和 链接时优化(LTO),Mach-O文件里多了不少跟签名相关的load command。我有个朋友做逆向工程,天天跟这些结构打交道。
6.6 符号表详解
符号表是链接器的命根子。没有符号表,链接器根本不知道哪个函数在哪、哪个变量是谁的。
每个目标文件都有一张符号表,记录着:
- 全局符号:可以被其他目标文件引用的符号
- 局部符号:只在当前文件内可见的符号
- 外部符号:引用了但定义在其他文件里的符号
用 nm 命令可以查看符号表:
$ nm myprogram.o
0000000000000000 T _func
0000000000000010 D _global_var
U _printf
t _local_func
字母含义:T 表示全局代码符号,D 表示全局数据符号,U 表示未定义(需要链接器去找),t 表示局部代码符号。
我曾经遇到过一个经典问题:链接时提示"multiple definition"。查了半天,发现是头文件里定义了一个全局变量,被多个源文件包含。每个编译单元都生成了一个同名符号,链接器就懵了。解决方案很简单——加 static 或者用 inline。
6.7 三种格式对比
| 特性 | ELF | PE | Mach-O |
|---|---|---|---|
| 主要平台 | Linux, Unix | Windows | macOS, iOS |
| 节区表位置 | 文件尾部 | 文件中部 | Load Commands中 |
| 段/节区关系 | 段包含节区 | 节区直接映射 | 段包含节区 |
| 符号表格式 | .symtab + .strtab | COFF符号表 | nlist结构 |
| 调试信息 | DWARF | CodeView/PDB | DWARF/Stabs |
6.8 知识体系总览
下面这张图是我自己梳理的,把今天讲的内容串起来:
6.9 避坑指南
最后分享几个我踩过的坑:
坑1:符号冲突
我曾经在一个大型项目里,两个不同的库都定义了同名的全局函数。链接时没报错,但运行时调用的函数是错的,导致诡异的内存崩溃。排查了两天才发现——链接器按顺序搜索库,先找到哪个就用哪个。解决方案:用 nm 检查符号,或者给函数加命名空间。
坑2:节区对齐
有一次我手写了一个链接脚本,节区对齐没设对。结果程序加载后,某些变量访问特别慢。后来发现是缓存行对齐问题——变量跨了两个缓存行。嗯,从此我对节区对齐再也不敢马虎。
坑3:Mach-O的Two-Level Namespace
macOS下链接动态库时,默认开启Two-Level Namespace。如果库的路径变了,程序启动就会崩溃。我遇到过升级系统后,某个第三方库路径变了,整个应用打不开。解决方案:用 -flat_namespace 或者检查库安装路径。
目标文件格式这东西,平时用不到的时候觉得没必要学。但一旦出了问题,不懂就是两眼一抹黑。我建议你找个简单的 .o 文件,用 readelf、objdump、nm 这些工具翻一翻,亲手看看里面的结构。比看十篇文章都管用。