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_typeET_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里的函数,就是通过解析导出表来定位函数地址的。

注意:PE文件的节区名跟ELF不一样,比如代码节区叫 .text,但Windows下通常叫 .textCODE。别被名字迷惑,看节区属性才是正道。

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 知识体系总览

下面这张图是我自己梳理的,把今天讲的内容串起来:

目标文件格式知识体系 ELF PE Mach-O 节区(Section) 段(Segment) 符号表 .text / .data / .bss .rodata / .symtab .strtab / .rel.text 代码段(可读可执行) 数据段(可读可写) 只读数据段(可读) 全局符号(T/D) 局部符号(t) 未定义符号(U) 链接器视角 → 节区合并 + 符号解析 + 重定位

6.9 避坑指南

最后分享几个我踩过的坑:

坑1:符号冲突

我曾经在一个大型项目里,两个不同的库都定义了同名的全局函数。链接时没报错,但运行时调用的函数是错的,导致诡异的内存崩溃。排查了两天才发现——链接器按顺序搜索库,先找到哪个就用哪个。解决方案:用 nm 检查符号,或者给函数加命名空间。

坑2:节区对齐

有一次我手写了一个链接脚本,节区对齐没设对。结果程序加载后,某些变量访问特别慢。后来发现是缓存行对齐问题——变量跨了两个缓存行。嗯,从此我对节区对齐再也不敢马虎。

坑3:Mach-O的Two-Level Namespace

macOS下链接动态库时,默认开启Two-Level Namespace。如果库的路径变了,程序启动就会崩溃。我遇到过升级系统后,某个第三方库路径变了,整个应用打不开。解决方案:用 -flat_namespace 或者检查库安装路径。

目标文件格式这东西,平时用不到的时候觉得没必要学。但一旦出了问题,不懂就是两眼一抹黑。我建议你找个简单的 .o 文件,用 readelfobjdumpnm 这些工具翻一翻,亲手看看里面的结构。比看十篇文章都管用。

公众号:蓝海资料掘金营,微信deep3321