将来的自己,会感谢现在努力的自己!

0%

Mach‑O 文件格式完全解析

Mach‑O 文件格式完全解析

Mach‑O 是 macOS、iOS、watchOS 等苹果平台上的可执行文件、动态库和对象文件的底层格式。理解它的结构是理解应用启动、动态链接、符号绑定乃至包体积优化的基础。下面从整体布局到各个核心机制逐一拆解。


一、整体布局:三块核心区域

一个标准 Mach‑O 文件由三个逻辑区域组成,后面所有概念都建立在这个布局之上。

1
2
3
4
5
6
7
8
+----------------------------------+
| Header | (文件头)
+----------------------------------+
| Load Commands | (加载命令表)
+----------------------------------+
| Data |
| (Segment 数据区) |
+----------------------------------+

1. Header(文件头)

位于文件最开头,固定长度。64 位真机上为 32 字节,32 位为 28 字节。
主要字段包括:

  • magic:标识文件格式和字节序。0xfeedfacf 表示 64 位,0xfeedface 表示 32 位。
  • cputype / cpusubtype:目标 CPU 架构(如 ARM64x86_64)。
  • filetype:文件用途。常见值有:MH_OBJECT(.o 目标文件)、MH_EXECUTABLE(可执行文件)、MH_DYLIB(动态库)、MH_DYLINKER(dyld 自身)。
  • ncmds:后面紧接着的 Load Command 的数量。
  • flags:dyld 加载标志,如 MH_PIE 表示开启 ASLR(地址空间布局随机化),MH_NO_HEAP_EXECUTION 表示堆不可执行。

2. Load Commands(加载命令表)

紧跟在 Header 之后,长度和条目数由 Header 中的 ncmdssizeofcmds 指明。每条命令的结构非常简单:

1
2
3
4
struct load_command {
uint32_t cmd; // 命令类型,如 LC_SEGMENT_64
uint32_t cmdsize; // 命令本身的长度
};

具体功能由 cmd 的值决定。部分核心命令及其作用如下:

命令类型 作用
LC_SEGMENT_64 描述一个段(Segment)的加载方式:文件偏移、虚拟地址、大小、权限等
LC_DYLD_INFO_ONLY 指向 dyld 所需的压缩信息:rebase、binding、export 等
LC_SYMTAB 符号表和字符串表在 __LINKEDIT 段中的位置
LC_DYSYMTAB 动态符号表的详细信息
LC_LOAD_DYLINKER 指定动态链接器的路径(/usr/lib/dyld
LC_UUID 文件的唯一标识,用于符号化时匹配 dSYM
LC_CODE_SIGNATURE 代码签名数据的位置
LC_ENCRYPTION_INFO_64 加密信息(App Store 加密)

Load Commands 本质上是一份 “加载说明书” ,内核和 dyld 不依赖文件名的语义,只需要按这份指令表去映射内存即可。

3. Data(数据区)

真正存放代码、常量、变量以及链接信息的区域。逻辑上被划分为多个 段 (Segment),每个段再细分为 节 (Section)。下面重点介绍最常见的几个段和节。


二、段与节:内存布局的核心单元

2.1 命名规范

  • 段名:大写字母 + 双下划线前缀,例如 __TEXT__DATA
  • 节名:小写字母 + 双下划线前缀,例如 __text__data

常见段及权限如下:

段名 权限 文件占用 作用
__PAGEZERO 无(—) 0 字节(仅占虚拟内存) 捕获空指针解引用(64 位下 4 GB 陷阱页)
__TEXT R-X 可执行代码和只读常量
__DATA RW- 可读写的数据(全局变量、静态变量等)
__DATA_CONST R– 高级裁断可优化归集 iOS13+ 后编译时常量数据,运行时只读
__LINKEDIT R– 符号表、字符串表、重定位信息、代码签名数据等

__PAGEZERO 在文件里不占空间,但占用虚拟地址空间,避免访问低地址导致的权限异常。


三、__TEXT 段与 __DATA 段中的关键节

3.1 __TEXT 段中的常见节

节名 内容说明
__text 程序的主代码(机器指令),编译后所有函数的二进制都落在这里
__stubs 动态链接的“桩代码”,用于懒加载调用外部函数
__stub_helper 桩代码的辅助函数,在第一次调用时跳转到 dyld 做符号绑定
__cstring C 风格的字符串常量(如 @"Hello" 的字面量部分)
__const 编译期确定的常量
__objc_methname Objective‑C 方法名字符串
__objc_classname Objective‑C 类名字符串
__objc_methtype Objective‑C 方法类型编码(Type Encoding)

3.2 __DATA 段中的常见节

节名 内容说明
__data 已初始化的全局变量和静态变量
__bss 未初始化的静态/全局变量,在文件中不占空间,仅分配虚拟内存
__la_symbol_ptr 懒加载符号指针表,初次调用时由 dyld 替换为真实函数地址
__nl_symbol_ptr 非懒加载符号指针表,启动时 dyld 即绑定的符号(如 C++ 静态初始化器)
__got 全局偏移表,用于非懒加载的间接寻址
__objc_classlist 项目中所有的 Objective‑C 类列表
__objc_classrefs 被引用的类列表,可用于查找未使用的类并瘦身
__objc_protolist 协议列表
__objc_catlist Category 列表
__mod_init_func C++ 全局构造器(标记 __attribute__((constructor)))的列表

四、动态链接:Rebase、Bind、Lazy Binding

当 Mach‑O 被加载到内存时,ASLR 会让镜像加载到随机的基址。所有内部指针和外部符号的引用都需要修正,这个过程分为两步:RebaseBind。两者的目标虽然不同,但都通过压缩的 opcode 序列(由 LC_DYLD_INFO_ONLY 命令描述)来描述待修正的位置。

4.1 Rebase(内部指针修正)

Mach‑O 在 __TEXT 段中的地址是按偏移量计算的,由于 ASLR,实际加载地址 = 偏移量 + 随机基址。Rebase 将可执行文件中指向自身内部的指针,统一加上 ASLR 偏移量。

如果开启了 ASLR,Rebase 是必须的。如果关闭(极少见),则可以跳过。

4.2 Bind(外部符号绑定)

处理对外部符号(例如 NSLogprintf)的依赖,这些符号存在于系统动态库(如 Foundation、libSystem)中。dyld 需要在启动时或首次调用时,将真实函数地址写入对应的符号指针(位于 __DATA 段)。

4.3 Lazy Binding(懒加载)

为了加速启动,大部分外部符号采用懒加载机制。其流程如下:

  1. 编译时,对外部函数(如 printf)的调用指向 __TEXT,__stubs 中的一段桩代码。
  2. __stubs 中的代码跳转到 __la_symbol_ptr 表中该函数对应的槽位。
  3. 在启动时,这个槽位初始指向 __stub_helper 中的解析函数。
  4. 首次调用时__stub_helper 调用 dyld_stub_binder,由 dyld 查找真实函数地址,写入 __la_symbol_ptr
  5. 后续调用:直接通过 __la_symbol_ptr 跳转到真实函数,不再经过 dyld。
1
2
3
4
5
6
7
8
9
flowchart TD
A["调用 printf()"] --> B["__TEXT,__stubs (桩)"]
B --> C{"首次调用?"}
C -- 是 --> D["跳转至 __stub_helper"]
D --> E["dyld_stub_binder 查找符号"]
E --> F["将真实地址写入 __la_symbol_ptr"]
F --> G["执行 printf"]
C -- 否 --> H["直接通过 __la_symbol_ptr 跳转"]
H --> G

Non‑Lazy Binding 则相反:dyld 在加载时就完成符号绑定,主要用于那些在启动时必须就绪的符号(例如 C++ 静态对象构造器)。

4.4 压缩信息格式

传统上,Rebase 和 Bind 信息各自由独立的 opcode 流描述,dyld 顺序执行这些操作码来完成修正。从 iOS 13 / macOS 10.15 开始,还引入了 __chain_fixups 压缩格式,将修正信息直接嵌入数据页的指针链中,进一步减少启动时间。


五、代码签名与加密

5.1 签名结构

签名数据存放在 __LINKEDIT 段中,通过 LC_CODE_SIGNATURE 命令定位。签名本身是一个 SuperBlob 结构,包含:

  • Code Directory:包含所有重要内容的哈希值(每个 __TEXT 页单独哈希),以及代码大小、页大小、团队 ID、平台标识等。
  • Entitlements:权限文件,可以是 XML 或 DER 格式。
  • CMS Signature:使用开发者证书对 Code Directory 的哈希值进行签名。
  • Requirements:验证签名的条件表达式(如必须由 Apple 根证书签名)。

__LINKEDIT 段中的签名区域在计算哈希时会被排除,避免自指问题。

5.2 FairPlay 加密

从 App Store 下载的应用,其二进制文件被 FairPlay DRM 加密。加密信息由 LC_ENCRYPTION_INFO_64 命令描述(包括 cryptid 标识和偏移量)。解密发生在内核级,应用运行时看到的是已解密的内存页。这是越狱检测重点关注的区域之一。


六、多架构二进制(Fat Binary / Universal Binary)

为了同时支持多种设备(如 armv7、arm64、x86_64),Mach‑O 允许将多个单架构二进制文件打包成一个文件,格式在 <mach-o/fat.h> 中定义。

1
2
3
4
5
6
7
8
9
10
11
12
13
+------------------+
| fat_header | 包含 magic (0xcafebabe) 和架构数量
+------------------+
| fat_arch_1 | 描述第1个架构(CPU类型、偏移、大小、对齐)
+------------------+
| fat_arch_2 | 描述第2个架构
+------------------+
| ... |
+------------------+
| Mach‑O 数据1 | 实际二进制(偏移由 fat_arch_1 指定)
+------------------+
| Mach‑O 数据2 |
+------------------+

iOS 真机的 macOS 运行 iOS 模拟器 App 时也是在 x86_64/arm64 切片之间根据运行环境自动选择。MachOView 打开一个通用的 .app 可执行文件,可以看到 Fat Binary 下包含多个架构的切片。


七、常见工具与调试方法

工具 用途 命令示例
file 查看文件类型和架构 file MyApp
otool 查看 Load Commands、段/节信息、符号表等 otool -l MyApp(显示所有 Load Commands)
otool -L 查看依赖的动态库列表 otool -L MyApp
otool -v -s __DATA __objc_classlist 查看 OC 类列表
nm 查看符号表 nm -m MyApp
size 查看各段大小 xcrun size -x -l -m MyApp
codesign 查看/验证签名 codesign -dvvv MyApp
MachOView 图形化查看完整结构 开源 GUI 工具,适合初学者

此外,在编译过程中可以生成 Link Map 文件(通过 -Xlinker -map 开启),用于精确分析每个符号在 __TEXT__DATA 段中的位置,是包体积优化的重要手段。


八、优化与瘦身

理解 Mach‑O 结构后,可以采取多种手段减小包体积:

  1. 编译器优化:Release 下使用 -Os(折衷大小和性能)。Swift 可使用 -Osize 模式。
  2. 删除无用代码
    • 对比 __objc_classlist__objc_classrefs,找出未被引用的类(OC 动态特性需注意字符串调用的类)。
    • 利用 __DATA,__objc_catlist 合并冗余的 Category,减少 __DATA 指针数量。
  3. 段迁移(需关闭 Bitcode):将部分只读数据从 __TEXT 迁到 __DATA_CONST,减少 __TEXT 段大小。
  4. 减少动态库数量:内嵌的动态库加载速度较慢,合并内嵌 Framework 可以加快启动。
  5. App Thinning
    • Slicing:App Store 根据目标设备架构(arm64 / armv7)和分辨率(@2x / @3x)生成专用变体。
    • Bitcode:提交 LLVM IR 给 App Store,苹果在未来可针对新硬件重新优化生成最终二进制,无需开发者重新上传。
    • On‑Demand Resources:资源按需下载,不占用初始安装包。

九、启动过程与 dyld 的角色

当一个 Mach‑O 可执行文件被启动时,主要经过以下流程:

  1. 内核解析 Mach‑O Header,找到 LC_LOAD_DYLINKER 命令,定位 dyld 路径并加载它。
  2. dyld 初始化,读取主二进制及所有依赖动态库(递归构建依赖图)。
  3. 加载所有镜像,映射到虚拟内存。
  4. 执行 Rebase & Bind
    • 先对所有内部指针做 Rebase(加上 ASLR 偏移)。
    • 处理 Non‑Lazy 符号的 Bind。
    • Lazy 符号保留,留待首次调用时绑定。
  5. 运行初始化器
    • 执行 C++ 静态构造函数(__mod_init_func)。
    • 调用 +load 方法(已废弃,但仍被理解)。
    • 初始化 Objective‑C Runtime。
  6. 跳转到入口点LC_MAINLC_UNIXTHREAD 指定),最终执行 main() 函数。

每一步的时间都可以通过 Instruments 的 System Tracedyld 的环境变量(如 DYLD_PRINT_STATISTICS)进行分析和调优。


十、总结:结构 — 命令 — 段的层级关系

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
Mach‑O 文件
├── Header (文件属性、类型、架构)
├── Load Commands Table (加载说明书)
│ ├── LC_SEGMENT_64(__PAGEZERO)
│ ├── LC_SEGMENT_64(__TEXT)
│ ├── LC_SEGMENT_64(__DATA)
│ ├── LC_SEGMENT_64(__LINKEDIT)
│ ├── LC_DYLD_INFO_ONLY
│ ├── LC_SYMTAB
│ └── LC_CODE_SIGNATURE
└── Data (Segment 实际数据)
├── __PAGEZERO (虚拟)
├── __TEXT
│ ├── __text
│ ├── __stubs
│ ├── __cstring
│ └── __objc_*
├── __DATA
│ ├── __data
│ ├── __bss
│ ├── __la_symbol_ptr (懒加载符号表)
│ ├── __nl_symbol_ptr (非懒加载符号表)
│ ├── __objc_classlist
│ └── __objc_classrefs
└── __LINKEDIT
├── dyld 压缩信息
├── 符号表 + 字符串表
└── 代码签名数据

Mach‑O 将“描述信息”(Header + Load Commands)与“实际内容”(Segment Data)清晰分离,通过段与节的组织满足只读/可写、运行时/链接时等不同需求。理解这一结构是洞悉 iOS 底层运行机制的必经关口,也是成为 iOS 高级专家的核心能力之一。