Mach‑O 文件格式完全解析
Mach‑O 是 macOS、iOS、watchOS 等苹果平台上的可执行文件、动态库和对象文件的底层格式。理解它的结构是理解应用启动、动态链接、符号绑定乃至包体积优化的基础。下面从整体布局到各个核心机制逐一拆解。
一、整体布局:三块核心区域
一个标准 Mach‑O 文件由三个逻辑区域组成,后面所有概念都建立在这个布局之上。
1 | +----------------------------------+ |
1. Header(文件头)
位于文件最开头,固定长度。64 位真机上为 32 字节,32 位为 28 字节。
主要字段包括:
magic:标识文件格式和字节序。0xfeedfacf表示 64 位,0xfeedface表示 32 位。cputype/cpusubtype:目标 CPU 架构(如ARM64、x86_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 中的 ncmds 和 sizeofcmds 指明。每条命令的结构非常简单:
1 | struct load_command { |
具体功能由 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 会让镜像加载到随机的基址。所有内部指针和外部符号的引用都需要修正,这个过程分为两步:Rebase 和 Bind。两者的目标虽然不同,但都通过压缩的 opcode 序列(由 LC_DYLD_INFO_ONLY 命令描述)来描述待修正的位置。
4.1 Rebase(内部指针修正)
Mach‑O 在 __TEXT 段中的地址是按偏移量计算的,由于 ASLR,实际加载地址 = 偏移量 + 随机基址。Rebase 将可执行文件中指向自身内部的指针,统一加上 ASLR 偏移量。
如果开启了 ASLR,Rebase 是必须的。如果关闭(极少见),则可以跳过。
4.2 Bind(外部符号绑定)
处理对外部符号(例如 NSLog、printf)的依赖,这些符号存在于系统动态库(如 Foundation、libSystem)中。dyld 需要在启动时或首次调用时,将真实函数地址写入对应的符号指针(位于 __DATA 段)。
4.3 Lazy Binding(懒加载)
为了加速启动,大部分外部符号采用懒加载机制。其流程如下:
- 编译时,对外部函数(如
printf)的调用指向__TEXT,__stubs中的一段桩代码。 __stubs中的代码跳转到__la_symbol_ptr表中该函数对应的槽位。- 在启动时,这个槽位初始指向
__stub_helper中的解析函数。 - 首次调用时:
__stub_helper调用dyld_stub_binder,由 dyld 查找真实函数地址,写入__la_symbol_ptr。 - 后续调用:直接通过
__la_symbol_ptr跳转到真实函数,不再经过 dyld。
1 | flowchart TD |
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 | +------------------+ |
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 结构后,可以采取多种手段减小包体积:
- 编译器优化:Release 下使用
-Os(折衷大小和性能)。Swift 可使用-Osize模式。 - 删除无用代码:
- 对比
__objc_classlist和__objc_classrefs,找出未被引用的类(OC 动态特性需注意字符串调用的类)。 - 利用
__DATA,__objc_catlist合并冗余的 Category,减少__DATA指针数量。
- 对比
- 段迁移(需关闭 Bitcode):将部分只读数据从
__TEXT迁到__DATA_CONST,减少__TEXT段大小。 - 减少动态库数量:内嵌的动态库加载速度较慢,合并内嵌 Framework 可以加快启动。
- App Thinning:
- Slicing:App Store 根据目标设备架构(arm64 / armv7)和分辨率(@2x / @3x)生成专用变体。
- Bitcode:提交 LLVM IR 给 App Store,苹果在未来可针对新硬件重新优化生成最终二进制,无需开发者重新上传。
- On‑Demand Resources:资源按需下载,不占用初始安装包。
九、启动过程与 dyld 的角色
当一个 Mach‑O 可执行文件被启动时,主要经过以下流程:
- 内核解析 Mach‑O Header,找到
LC_LOAD_DYLINKER命令,定位 dyld 路径并加载它。 - dyld 初始化,读取主二进制及所有依赖动态库(递归构建依赖图)。
- 加载所有镜像,映射到虚拟内存。
- 执行 Rebase & Bind:
- 先对所有内部指针做 Rebase(加上 ASLR 偏移)。
- 处理 Non‑Lazy 符号的 Bind。
- Lazy 符号保留,留待首次调用时绑定。
- 运行初始化器:
- 执行 C++ 静态构造函数(
__mod_init_func)。 - 调用
+load方法(已废弃,但仍被理解)。 - 初始化 Objective‑C Runtime。
- 执行 C++ 静态构造函数(
- 跳转到入口点(
LC_MAIN或LC_UNIXTHREAD指定),最终执行main()函数。
每一步的时间都可以通过 Instruments 的 System Trace 或 dyld 的环境变量(如 DYLD_PRINT_STATISTICS)进行分析和调优。
十、总结:结构 — 命令 — 段的层级关系
1 | Mach‑O 文件 |
Mach‑O 将“描述信息”(Header + Load Commands)与“实际内容”(Segment Data)清晰分离,通过段与节的组织满足只读/可写、运行时/链接时等不同需求。理解这一结构是洞悉 iOS 底层运行机制的必经关口,也是成为 iOS 高级专家的核心能力之一。