1 minute read

这篇文章我想讲的,不只是 Mach-O 长什么样,而是为什么我现在更愿意把它理解成一套跨阶段协议:编译器、链接器、dyld,其实都在围绕同一种文件组织方式协作。

在进入 App 的过程中,系统要先读取可执行文件,而这些文件本身必须符合某种格式,才能被内核和 dyld 高效地识别与装载。

如果目标文件本身是通用二进制(Universal Binary / Fat Binary),系统首先要做的事情,是从里面挑出当前架构对应的那一份 Mach-O

通用二进制文件是一种“容器”格式,旨在解决跨架构兼容性问题(如同时支持 IntelApple Silicon)。它的格式如下:

struct fat_header {
    uint32_t	magic;		/* FAT_MAGIC (0xcafebabe) */
    uint32_t	nfat_arch;	/* 包含的架构数量 */
};
struct fat_arch {
    int32_t		cputype;	/* CPU 类型 (如 ARM64, x86_64) */
    int32_t		cpusubtype;	/* CPU 子类型 */
    uint32_t	offset;		/* 该架构 Mach-O 在文件中的偏移量 */
    uint32_t	size;		/* 该架构 Mach-O 的大小 */
    uint32_t	align;		/* 内存对齐 */
};

内核一开始会先读取 fat_header:发现 0xcafebabe,确认这是 Fat Binary;然后遍历后面的 fat_arch 数组;接着通过 cputypecpusubtype 与当前运行的 CPU 架构做匹配;找到对应架构后,再根据 offsetsize 定位到那一份真正要加载的 Mach-O 数据。

这样做的好处是:运行时只需要处理当前架构所对应的那一份 Mach-O,其余架构的数据虽然还在文件里,但不会进入当前这条装载路径。

这是一个很典型的设计:用更多磁盘空间去换多架构兼容性,以及运行时更清晰的装载路径。

Mach-O 格式

Mach-O(Mach Object)是 macOSiOS 上使用的可执行文件格式。粗略来看,它主要可以拆成三部分:HeaderLoad CommandsData

  1. Header 包含 Magic NumberCPU 架构、文件类型(如 MH_EXECUTE)、Load Commands 的数量和总大小。它本身体量不大,但承担的是入口级元信息描述。

  2. Load Commands(加载命令) 告诉内核和 dyld 该如何装载这个文件。
    • LC_SEGMENT_64:定义一个 segment
    • LC_LOAD_DYLIB:声明依赖的动态库
    • 其他命令则描述入口、链接信息、动态加载器等内容
  3. Data 这是内容本体,真正的代码、常量、可写数据、符号表字符串、链接编辑相关内容,最终都在这一层。
    • __TEXT:通常放代码和只读常量
    • __DATA*:通常放可写数据以及部分运行时元数据
    • __LINKEDIT:通常放符号表、字符串表、重定位与签名相关内容

Header 这里先不展开,下面重点看 Load CommandsData

Load Commands 和 Data

Mach-O 里,最容易混淆但也最关键的一对概念,就是 Load CommandsData。简单说,Load Commands 负责“描述”,Data 负责“承载”。

系统真正加载一个 Mach-O 时,并不是先把整个文件内容都读一遍,而是先读取 HeaderLoad Commands,理解这个文件应该怎样被装入内存:有哪些 segment、每个 segment 在文件中的偏移是多少、映射到虚拟内存的地址范围是什么、权限该怎么设置、依赖了哪些动态库、链接信息放在哪里。dyld 也是通过遍历这些 load command 来决定后续处理路径,这一点在 Header.cppforEachLoadCommand 实现里体现得很直接。

当我们说“每出现一次 LC_SEGMENT / LC_SEGMENT_64 就定义一个 segment”时,本质上是在说:Load Commands 把整个文件拆成了多个可装载单元。常见的 __TEXT__DATA__LINKEDIT__DATA_CONST 等,并不是随意命名,而是为了把不同访问模式和权限需求的数据分开管理。比如代码通常在 __TEXT,可写数据在 __DATA*,链接编辑相关内容集中在 __LINKEDIT。这些 segment 的描述信息通常会被抽象成统一结构(如 vmaddr/vmsize/fileOffset/fileSize/initProt/maxProt)。

接下来就是 DataData 不是“定义”,而是“真实字节”。机器码、字符串常量、全局变量、符号字符串表、重定位相关内容等,都属于 Data 这一层。Load Commands 告诉系统“去哪里拿这些字节、用什么方式映射”,而 Data 才是最终会被 CPU 执行、被程序访问的内容本体。

也正因为如此,section 的元数据虽然是在命令区里描述的,但 section 真正承载的内容仍然在数据区中。

这里还有一个很重要的细节:fileSizevmSize 不一定相等。fileSize 是文件里实际存在的字节数,vmSize 是运行时要占用的虚拟内存范围。很多时候会出现 fileSize < vmSize,这通常意味着有一部分空间不需要在磁盘里真实存储,而是在运行时按零填充扩展。

也就是说,Mach-O 同时考虑了磁盘存储效率和内存映射语义。这种检查在 dyld 里也有对应实现,比如 MachOFile.cppvalidSegments

总结地讲:Load Commands装载协议Data实际货物;前者决定“如何加载”,后者决定“加载什么”。这两层分离,是 Mach-O 在工程上非常优雅、也非常实用的设计。

从链接视角看,Mach-O 由多个 section 组成并被归入不同 segment;从装载视角看,内核/dyldsegment 为映射与权限控制单位。segment 同时有 filesizevmsize (用于表示文件内容与内存映射范围差异,如 zerofill),而 section 主要用 addr/size 描述其在 segment 内的位置与大小。

静态链接 & 动态链接

跳出 Mach-O 的结构本身,我们再从链接的角度重新看一遍。

很多时候我们会把静态链接动态链接分开学,但真正落到 Mach-O 上,它们其实是同一条链路上的两个阶段。编译器先把每个源文件编译成目标文件,目标文件里已经有代码和数据,但符号地址通常还不完整;接着链接器一方面把多个目标文件中同类型的内容合并到统一布局里,另一方面根据符号表和重定位信息修正引用关系,让输出文件在结构上变得“可装载”。

这一步完成后,我们拿到的是一个可以被系统装载的 Mach-O,但并不意味着运行时工作已经结束。

真正启动进程时,动态链接器才会接手后半段。可执行文件会在 Load Commands 里声明它依赖哪些动态库,以及由哪个动态链接器来处理这些依赖。dyld 会读取这些命令,按规则加载所需镜像,并在运行期完成剩余的符号绑定和地址修正。

也就是说,静态链接解决的是“把文件组织好”,动态链接解决的是“把它跑起来”。这两者分工明确,但又天然是衔接在一起的。

从文件结构上看,Mach-O 的设计非常工程化:Load Commands 负责描述,Data 负责承载。前者告诉系统“内容在哪里、如何映射、需要什么权限、依赖关系是什么”;后者则保存真正会被执行和访问的字节,比如代码、常量、可写数据以及链接编辑相关内容。

理解这一点之后,很多字段就不再抽象:fileSize 代表磁盘里实际存在的字节数,vmSize 代表运行时要占用的虚拟内存范围,它们不一定相等,出现差值通常就意味着运行期还会有零填充扩展。

这也是为什么我更愿意把 Mach-O 看成“描述层 + 内容层”的组合,而不是单纯的二进制打包格式。

再回到 sectionsegment 的关系:section 更像内容组织单元,segment 更像装载与权限管理单元。链接阶段的大量操作发生在 section 级别,比如合并、重定位;运行时的映射和保护策略则更多落在 segment 级别。

把这个边界理顺之后,很多常见疑惑会自然消失,比如为什么 section 更关注 offset/reloc 这类字段,而 segment 同时强调 file/vm 两套尺寸和权限位。

从实践角度看,这套机制的价值非常现实。静态链接让模块化开发成为可能:不同源码单元独立编译、最后统一拼装。动态链接让系统库共享成为可能:同一份库可以被多个进程复用,升级修复也集中在系统侧完成,不需要每个应用都重复打包一份。二者叠加之后,既保证了构建阶段的确定性,也保留了运行阶段的灵活性,这就是现代平台在体积、性能、可维护性之间找到的平衡点。

所以我现在理解 Mach-O,已经不太把它当成“文件格式细节集合”来看了,而更愿意把它看作一套跨阶段协议:编译器、链接器、动态链接器都围绕这套协议协作

链接器负责把碎片整理成结构化映像,dyld 负责把映像转换为可执行进程状态。Load CommandsData 只是表面上的两块区域,背后对应的是一整条从离线构建到在线运行的系统路径。