(二) Mach-O 文件结构

# 进程与二进制格式
# 相关工具
# Mach-O 文件格式
  ## 示例
  ## Mach-O 头
  ## Data
    ### Segment(段)
    ### Section(节)
    ### 两个Section:__TEXT.__stubs、__TEXT.__stub_helper
  ## Load Command
    ### LC_CODE_SIGNATURE(数字签名)
    ### LC_SEGMENT(进程虚拟内存设置)
    ### LC_MAIN(设置主线程入口地址)
# 通用二进制格式(Universal Binary)
# 参考链接

上一篇说到源码经过预处理、编译、汇编之后生成目标文件,这一章介绍一下iOS、Mac OS中目标文件的格式Mach-O的结构,方便了解之后的链接生成可执行文件的过程。

先附上相关源码地址:与Mach-O 文件格式有关的结构体定义都可以从 /usr/include/mach-o/loader.h 中找到(直接在xcode项目中找到loader.h,然后Show In Finder即可)。

# 进程与二进制格式

进程在众多操作系统中都有提及,它是作为一个正在执行的程序的实例,这是 UNIX 的一个基本概念。而进程的出现是特殊文件在内从中加载得到的结果,这种文件必须使用操作系统可以认知的格式,这样才对该文件引入依赖库,初始化运行环境以及顺利地执行创造条件。

Mach-O(Mach Object File Format)是 macOS 上的可执行文件格式,类似于 Linux 和大部分 UNIX 的原生格式 ELF(Extensible Firmware Interface)。macOS 支持三种可执行格式:解释器脚本格式、通用二进制格式和 Mach-O 格式(关于三者区别,在下面说到Mach-O Header的时候介绍)。

# 相关工具

命令行工具

  • file 命令,查看Mach-O文件的基本信息:file 文件路径

  • otool 命令,查看Mach-O特定部分和段的内容

#查看Mach-O文件的header信息
otool -h 文件路径

#查看Mach-O文件的load commands信息
otool -l 文件路径

# 更多使用方法,终端输入otool -help查看
  • lipo 命令,来处理多架构Mach-O文件,常用命令如下
#查看架构信息
lipo -info 文件路径

#导出某种类型的架构
lipo 文件路径 -thin 架构类型 -output 输出文件路径

#合并多种架构类型
lipo 文件路径1 文件路径2 -output 输出文件路径

GUI工具

# Mach-O 文件格式

Mach-O 文件格式在官方文档中有一个描述图,很多教程中都引用到。官网文档

可以看的出 Mach-O 主要由 3 部分组成,下面一一讲述。Load Command的作用是指导内核加载器、动态链接器怎么将可执行文件装载到内存进行执行。所以Load Command放到最后一部分。

## 示例

用 helloworld 来做个试验:

/// main.cpp
#import <stdio.h>

int main() {
    printf("hello");
    return 0;
}

使用 clang -g main.cpp -o main 生成执行文件。然后拖入到 MachOView 中来查看一下加载 Segment 的结构(当然使用 Synalyze It! 也能捕捉到这些信息的,但是 MachOView 更对结构的分层更加一目了然):

## Mach-O 头

Mach-O 头(Mach Header)描述了 Mach-O 的 CPU 架构、大小端、文件类型以及加载命令等信息。它的作用是让内核在读取该文件创建虚拟进程空间的时候,检查文件的合法性以及当前硬件的特性是否能支持程序的运行。

以下只给出 64 位定义的代码,因为 32 位的区别是缺少了一个预留字段:

#define MH_MAGIC    0xfeedface    /* the mach magic number */
#define MH_CIGAM    0xcefaedfe    /* NXSwapInt(MH_MAGIC) */

struct mach_header_64 {
    uint32_t    magic;            / magic(魔数):用来确认文件的格式,操作系统在加载可执行文件的时候会确认魔数是否正确,如果不正确会拒绝加载。 /
    cpu_type_t    cputype;        / CPU架构 /
    cpu_subtype_t    cpusubtype;  / CPU子版本 /
    uint32_t    filetype;         / 文件类型,常见的Mach-O文件有:MH_OBJECT(目标文件)、MH_EXECUTABLE(可执行二进制文件)、MH_DYLIB(动态库)等等。这些文件类型定义在 loader.h 文件中同样可以找到 /
    uint32_t    ncmds;            / 加载器中加载命令的数量 /
    uint32_t    sizeofcmds;       / 加载器中所有加载命令的总大小 /
    uint32_t    flags;            / dyld 加载需要的一些标志,其中MH_PIE表示启用地址空间布局随机化(ASLR)。其他的值在loader.h文件中同样可以找到 /
    uint32_t    reserved;         / 64位的保留字段 /
};

魔数会表明文件的格式。filetype会表明具体是什么文件类型(都是猫,也分黑猫、白猫)。

// magic:常见的魔数(Mac是小端模式)
Mach-O文件。用途:macOS 的原生二进制格式
  #define   MH_MAGIC    0xfeedface  / 32位设备上的魔数,大端模式(符合人类阅读习惯,高位数据在前) /
  #define   MH_CIGAM    0xcefaedfe  / 32位、小端(高位地址在后),CIGAM就是MAGIC反过来写,从命名上也可以看出端倪 /
  #define   MH_MAGIC_64 0xfeedfacf  / 64位、大端 /
  #define   MH_CIGAM_64 0xcffaedfe  / 64位、小端 /

通用二进制格式FAT。用途:包含多种架构支持的二进制格式,只在 macOS 上支持。(在文章末尾简单介绍一下,有兴趣可以瞜一眼)
  #define FAT_MAGIC     0xcafebabe
  #define FAT_CIGAM     0xbebafeca  /* NXSwapLong(FAT_MAGIC) */
  #define FAT_MAGIC_64  0xcafebabf
  #define FAT_CIGAM_64  0xbfbafeca  /* NXSwapLong(FAT_MAGIC_64) */

脚本格式。用途:主要用于 shell 脚本,但是也常用语其他解释器,如 Perl, AWK 等。也就是我们常见的脚本文件中在 `#!` 标记后的字符串,即为执行命令的指令方式,以文件的 stdin 来传递命令。
  魔数为 \x7FELF

// filetype:常见的Mach-O格式的文件类型
#define MH_OBJECT   0x1     / 可重定位的目标文件 /
#define MH_EXECUTE  0x2     / 可执行二进制文件 /
#define MH_DYLIB    0x6     / 动态绑定共享库 /
#define MH_DYLINKER 0x7     / 动态链接编辑器,如dyld /
#define MH_BUNDLE   0x8     / 动态绑定bundle(包)文件 /
#define MH_DSYM     0xa     / 调试所用的符号文件 /

举例:利用otool工具查看Mach-o文件的头部

$ otool -hv bibi.decrypted 
Mach header
      magic cputype cpusubtype  caps    filetype ncmds sizeofcmds      flags
   MH_MAGIC     ARM         V7  0x00     EXECUTE    59       6016   NOUNDEFS DYLDLINK TWOLEVEL WEAK_DEFINES BINDS_TO_WEAK PIE

Mach header
      magic cputype cpusubtype  caps    filetype ncmds sizeofcmds      flags
MH_MAGIC_64   ARM64        ALL  0x00     EXECUTE    59       6744   NOUNDEFS DYLDLINK TWOLEVEL WEAK_DEFINES BINDS_TO_WEAK PIE

## Data

数据区(Data):Data 中每一个段(Segment)的数据都保存在此,段的概念和 ELF 文件中段的概念类似,都拥有一个或多个 Section ,用来存放数据和代码。

Raw segment data存放了所有的原始数据,而Load commands相当于Raw segment data的索引目录

### Segment(段)

其中,LC_SEGMENT_64定义了一个64位的段,当文件加载后映射到地址空间(包括段里面节的定义)。64位段的定义如下:

struct segment_command_64 { /* for 64-bit architectures */
    uint32_t    cmd;          / Load Command类型,这里LC_SEGMENT_64代表将文件中64位的段映射到进程的地址空间。LC_SEGMENT_64和LC_SEGMENT的结构差别不大 /
    uint32_t    cmdsize;      / 代表Load commands的大小 /
    char        segname[16];  / 16字节的段名称 /
    uint64_t    vmaddr;       / 段映射到虚拟地址中的内存起始地址 /
    uint64_t    vmsize;       / 段映射到虚拟地址中的内存大小 /
    uint64_t    fileoff;      / 段在当前架构(MachO)文件中的偏移量,如果是胖二进制文件,也指的是相对于当前MachO文件的偏移 /
    uint64_t    filesize;     / 段在文件中的大小 /
    vm_prot_t   maxprot;     / 段页面的最高内存保护,用八进制表示(4=r(read),2=w(write),1=x(execute执行权限)) /
    vm_prot_t   initprot;    / 段页面最初始的内存保护 /
    uint32_t    nsects;       / 段(segment)包含的区(section)的个数(如果存在的话) /
    uint32_t    flags;        / 段页面标志 /
};

系统将 fileoff 偏移处 filesize 大小的内容加载到虚拟内存的 vmaddr 处,大小为vmsize,段页面的权限由initprot进行初始化。它的权限可以动态改变,但是不能超过maxprot的值,例如 _TEXT 初始化和最大权限都是可读/可执行/不可写。

常见的LC_SEGMENT Segment (cmd为LC_SEGMET),其segname[16]有以下几种值:

  • __PAGEZERO:空指针陷阱段,映射到虚拟内存空间的第1页,用于捕捉对 NULL 指针的引用。
  • __TEXT:代码段/只读数据段。
  • __DATA:读取和写入数据的段。
  • __LINKEDIT:动态链接器需要使用的信息,包括符号表、重定位表、绑定信息、懒加载信息等。
  • __OBJC:包含会被Objective Runtime使用到的一些数据。(从Macho文档上看,他包含了一些编译器私有的节。没有任何公开的资料描述)

### Section(节)

从示例图中可以看到,部分的 Segment (__TEXT__DATA) 可以进一步分解为 Section。

之所以按照 Segment(段) -> Section(节) 的结构组织方式,是因为在同一个 Segment 下的 Section,在内存中的权限相同(编译时,编译器把相同权限的section放在一起,成为segment),可以不完全按照 Page 的大小进行内存对齐,节省内存的空间。而 Segment 对外整体暴露,在装载程序时,完整映射成一个vma(Virtual Memory Address),更好的做到内存对齐,减少内存碎片(可以参考《OS X & iOS Kernel Programming》第一章内容)。

Section 具体的数据结构如下:

struct section_64 { 
    char        sectname[16];   / Section 的名字 /
    char        segname[16];    / Section 所在的 Segment 名称 /
    uint64_t    addr;           / Section 映射到虚拟地址的偏移(所在的内存地址) /
    uint64_t    size;           / Section 的大小 /
    uint32_t    offset;         / Section 在当前架构文件中的偏移 /
    uint32_t    align;          / Section 的内存对齐边界 (2 的次幂) /
    uint32_t    reloff;         / 重定位入口的文件偏移 /
    uint32_t    nreloc;         / 重定位入口的数目 /
    uint32_t    flags;          / Section标志属性 /
    uint32_t    reserved1;      / 保留字段1 (for offset or index) /
    uint32_t    reserved2;      / 保留字段2 (for count or sizeof) /
    uint32_t    reserved3;      / 保留字段3 /
};

结合示例图,下面列举一些常见(并非全部)的 Section:

__TEXT Segment(段)下面的节:
  __text              程序可执行的代码区域
  __stubs             间接符号存根。本质上是一小段代码,跳转到懒加载/延迟绑定(lazybinding)指针表(即__DATA.la_symbol_ptr)。找到对应项指针指向的地址。
  __sub_helper        辅助函数。帮助解决懒加载符号加载,上述提到的lazybinding的表(__DATA.la_symbol_ptr)中对应项的指针在没有找到真正的符号地址的时候,都指向这。
  __objc_methname     方法名
  __objc_classname    类名
  __objc_methtype     方法签名
  __cstring           去重后的只读的C风格字符串,包含OC的部分字符串和属性名
  __const             初始化过的常量
  __unwind_info       用户存储处理异常情况信息
  __eh_frame          调试辅助信息

__DATA Segment(段)下面的节:
  __data              初始化过的可变的数据
  __const             没有初始化过的常量
  __bss               没有初始化的静态变量
  __common            没有初始化过的符号声明
  __nl_symbol_ptr     非延迟导入/非懒加载(lazy-binding)符号指针表,每个表项中的指针都指向一个在dyld加载过程中,搜索完成的符号。即在dyld加载时会立即绑定值。
  __la_symbol_ptr     延迟导入/懒加载(lazy-binding)符号指针表,每个表项中的指针一开始指向stub_helper。在第 1 次调用时才会绑定值。
  __got               非懒加载全局指针表
  __mod_init_func     初始化/constructor(构造)函数
  __mod_term_func     destructor(析构)函数
  __cfstring          OC字符串
  __objc_classlist    程序中的类列表
  __objc_nlclslist    程序中自己实现了+load方法的类
  __objc_protolist    协议的列表
  __objc_classrefs    被引用的类列表
  __objc_ivar         成员变量

## 两个section:__TEXT.__stubs、__TEXT.__stub_helper

在 wikipedia 有一个关于 Method stub 的词条,大意就是:Stub 是指用来替换一部分功能的程序段。桩程序可以用来模拟已有程序的行为(比如一个远端机器的过程)或是对将要开发的代码的一种临时替代。

总结来说:

  • stub就是一段代码,功能为:跳转到 __DATA.__la_symbol_ptr( __DATA Segment 中的 __la_symbol_ptr Section) 对应表项的数据,所指向的地址。
  • __la_symbol_ptr 里面的所有表项的数据在初始时都会被 binding 成 __stub_helper
  • 当懒加载符号第一次使用到的时候,按照上面的结构,会跳转到__stub_helper这个section的代码,然后代码中会调用dyld_stub_binder来执行真正的bind。 bind结束后,就将__la_symbol_ptr中该懒加载符号 原本对应的指向__stub_helper的地址 修改为 符号的真实地址。
  • 之后的调用中,虽然依旧会跳到 __stub 区域,但是 __la_symbol_ptr表由于在之前的调用中获取到了符号的真实地址而已经修正完成,所以无需在进入 dyld_stub_binder 阶段,可以直接使用符号。

这样就完成了LazyBind的过程。Stub 机制 其实和 wikipedia 上的说法一致,设置一个桩函数(模拟、占位函数)并采用 lazy 思想做成延迟 binding 的流程。

在《深入解析 Mac OS X & iOS操作系统》中有详细的验证,也可以参考深入剖析Macho (1) 自己动手验证一下。

## Load Command

Mach-O文件头中包含了非常详细的指令,这些指令在被调用时清晰地指导了如何设置并加载二进制数据。这些指令,或称为“加载命令”,紧跟在基本的mach_header之后。

每一条命令,在load.c文件中,都有对应的结构体,来记录信息。共同点是都采用“类型-长度-值”的格式:

struct xxx_command {
  uint32_t  cmd;        / 32位的cmd值(表示类型) ,下面列举了部分 /
  uint32_t  cmdsize;    / 32位的cmdsize值(32位二进制为4的倍数,64位二进制为8的倍数) /
  ...                   / 记录命令本身的一些信息 /
}

//下面列举一些load command的类型(对应的cmd值),这里只列举了部分,全面的可以看源码,总共50多种load command。按照加载命令是由内核加载器、动态链接器处理分开记录。
内核加载器处理的加载命令:
  #define   LC_SEGMENT                0x1    / 定义一个段(Segment),加载后被映射到内存中,包括里面的节(Section) /
  #define   LC_LOAD_DYLINKER          0xe    / 默认的加载器路径。通常路径是“/usr/lib/dyld” /
  #define   LC_UUID                   0x1b   / 用于标识Mach-0文件的ID,匹配二进制文件与符号表。在分析崩溃堆栈信息能用到,通过地址在符号表中找到符号 /
  #define LC_CODE_SIGNATURE           0x1d   / 代码签名信息 /
  #define   LC_ENCRYPTION_INFO_64     0x2C   / 文件是否加密的标志,加密内容的偏移和大小 /

动态链接器处理的加载命令:
  #define   LC_SYMTAB                 0x2    / 为文件定义符号表和字符串表,在链接文件时被链接器使用,同时也用于调试器映射符号到源文件。符号表定义的本地符号仅用于调试,而已定义和未定义的 external 符号被链接器使用 /
  #define   LC_DYSYMTAB               0xb    / 将符号表中给出符号的额外符号信息提供给动态链接器。 /
  #define   LC_ID_DYLIB               0xd    / 依赖的动态库,包括动态库名称、当前版本号、兼容版本号。可以使用“otool-L xxx”命令查看 /
  #define   LC_RPATH                 (0x1c | LC_REQ_DYLD)    / RunpathSearchPaths,@rpath搜索的路径 /
  #define   LC_DYLD_INFO_ONLY        (0x22 | LC_REQ_DYLD)    / 记录了有关链接的重要信息,包括在__LINKEDIT中动态链接相关信息的具体偏移和大小。ONLY表示这个加载指令是程序运行所必需的,如果旧的链接器无法识别它,程序就会出错 /
  #define   LC_VERSION_MIN_IPHONEOS   0x25   / 系统要求的最低版本 /
  #define   LC_FUNCTION_STARTS        0x26   / 函数起始地址表,使调试器和其他程序能很容易地看到一个地址是否在函数内 /
  #define   LC_MAIN                  (0x28 | LC_REQ_DYLD)    / 程序的入口。dyld获取该地址,然后跳转到该处执行。replacement for LC_UNIXTHREAD /
  #define   LC_DATA_IN_CODE           0x29   / 定义在代码段内的非指令的表 /
  #define   LC_SOURCE_VERSION         0x2A   / 构建二进制文件的源代码版本号 /

有一些命令是由内核加载器(定义在bsd/kern/mach_loader.c文件中) 直接使用的, 其他命令是由动态链接器处理的。

在Mach-O文件加载解析时,多个Load Command会告诉操作系统应当如何加载文件中每个Segment的数据,对系统内核加载器和动态链接器起引导作用。(不同的数据对应不同的加载命令,可以看到segment_command_64symtab_commanddylib_command等,下面我们会讲解Segment的加载命令,下一节讲静态链接时,会涉及符号表symtab的加载命令)。

下面,以三个内核加载器负责解析处理的load command,来简单看下:

### LC_CODE_SIGNATURE(数字签名)

Mach-O二进制文件有一个重要特性就是可以进行数字签名。尽管在 OS X 中仍然没怎么使用数字签名,不过由于代码签名和新改进的沙盒机制绑定在一起,所以签名的使用率也越来越高。在 iOS 中,代码签名是强制要求的,这也是苹果尽可能对系统封锁的另一种尝试:在 iOS 中只有苹果自己的签名才会被认可。在 OS X 中,code sign(1) 工具可以用于操纵和显示代码签名。man手册页,以及 Apple's code signing guide 和 Mac OS X Code Signing In Depth文档都从系统管理员的角度详细解释了代码签名机制。

LC_CODE_SIGNATURE 包含了 Mach-O 二进制文件的代码签名,如果这个签名和代码本身不匹配(或者如果在iOS上这条命令不存在),那么内核会立即给进程发送一个SIGKILL信号将进程杀掉,没有商量的余地,毫不留情。

在iOS 4之前,还可以通过两条sysctl(8)命令覆盖负责强制执行(利用内核的MAC,即Mandatory AccessControl)的内核变量,从而实现禁用代码签名检查:

sysctl -w security.mac.proc_enforce = 0 //禁用进程的MAC
sysctl -w security.mac.vnode_enforce=0 //禁用VNode的MAC

而在之后版本的iOS中,苹果意识到只要能够获得root权限,越狱者就可以覆盖内核变量。因此这些变量变成了只读变量。untethered越狱(即完美越狱)因为利用了一个内核漏洞所以可以修改这些变量。由于这些变量的默认值都是启用签名检查,所以不完美越狱会导致非苹果签名的应用程序崩溃——除非i设备以完美越狱的方式引导。

此外,通过 Saurik 的 ldid 这类工具可以在 Mach-O 中嵌入伪代码签名。这个工具可以替代OS X的code sign(1),允许生成自我签署认证的伪签名。这在iOS中尤为重要,因为签名和沙盒模型的应用程序“entitlement”绑定在一起, 而后者在iOS中是强制要求的。entitlement 是声明式的许可(以plist的形式保存),必须内嵌在Mach-O中并且通过签名盖章,从而允许执行安全敏感的操作时具有运行时权限。

OS X 和 iOS 都有一个特殊的系统调用csops(#169)用于代码签名的操作

### LC_SEGMENT(进程虚拟内存设置)

LC_SEGMENT(或LC_SEGMENT_64) 命令是最主要的加载命令,这条命令指导内核如何设置新运行的进程的内存空间。这些“段”直接从Mach-O二进制文件加载到内存中。

每一条LC_SEGMENT[64] 命令都提供了段布局的所有必要细节信息。见上文的数据结构成员变量。

有了LC_SEGMENT命令,设置进程虚拟内存的过程就变成遵循LC_SEGMENT命令的简单操作。对于每一个段,将文件中相应的内容加载到内存中:从偏移量为 fileoff 处加载 filesize 字节到虚拟内存地址 vmaddr 处的 vmsize 字节。每一个段的页面都根据 initprot 进行初始化,initprot 指定了如何通过读/写/执行位初始化页面的保护级别。段的保护设置可以动态改变,但是不能超过 maxprot 中指定的值(在iOS中,+x和+w是互斥的)。

### LC_MAIN(设置主线程入口地址)

从Mountain Lion开始,一条新的加载命令LC_MAIN替代了LC_UNIX_THREAD命令。

  • 后者的作用是:开启一个unix线程,初始化栈和寄存器,通常情况下,除了指令指针(Intel的IP)或程序计数器(ARM的r15)之外,所有的寄存器值都为0。
  • 前者作用是设置程序主线程的入口点地址和栈大小

这条命令比LC_UNIXTHREAD命令更实用一些, 因为无论如何除了程序计数器之外所有的寄存器都设置为0了。由于没有LC_UNIXTHREAD命令, 所以不可以在之前版本的 OS X 上运行使用了LC_MAIN的二进制文件(在加载时会导致dyld(1)崩溃)。

LC_Main对应的加载命令如下,记录了可执行文件的入口函数int main(int argc, char * argv[])的信息:

struct entry_point_command {
    uint32_t  cmd;        / LC_MAIN only used in MH_EXECUTE filetypes /
    uint32_t  cmdsize;    / 24 /
    uint64_t  entryoff;   / file (__TEXT) offset of main() /
    uint64_t  stacksize;  / if not zero, initial stack size /
};

从定义上可以看到入口函数的地址计算:Entry Point = vm_addr(__TEXT) + entryOff + Slide

dyld的源码里能看到对Entry Point的获取和调用:

dyld
  ▼ __dyld_start  // 源码在dyldStartup.s这个文件,用汇编实现
    ▼ dyldbootstrap::start()   // dyldInitialization.cpp
      ▼ dyld::_main()
        ▼ //函数的最后,调用 getEntryFromLC_MAIN,从 Load Command 读取LC_MAIN入口,如果没有LC_MAIN入口,就读取LC_UNIXTHREAD,然后跳到主程序的入口处执行

namespace dyldbootstrap {

uintptr_t start(const dyld3::MachOLoaded* appsMachHeader, int argc, const char* argv[],
                const dyld3::MachOLoaded* dyldsMachHeader, uintptr_t* startGlue) {
    //
    // Entry point for dyld.  The kernel loads dyld and jumps to __dyld_start which
    // sets up some registers and call this function.
    //
    // Returns address of main() in target program which __dyld_start jumps to
    //
    uintptr_t
    _main(const macho_header* mainExecutableMH, uintptr_t mainExecutableSlide, 
            int argc, const char* argv[], const char* envp[], const char* apple[], 
            uintptr_t* startGlue) {
        // find entry point for main executable
        result = (uintptr_t)sMainExecutable->getEntryFromLC_MAIN();
        return result;
    }   
}

}

这里简单看一下这几种load command所表示的信息。关于进程地址空间分布、线程入口在第四节 —— 装载会从进程启动到运行详细梳理一下流程。

# 通用二进制格式(Universal Binary)

通常也被称为胖二进制格式(Fat Binary),Apple 提出这个概念是为了解决一些历史原因,macOS(更确切的应该说是 OS X)最早是构建于 PPC 架构智商,后来才移植到 Intel 架构(从 Mac OS X Tiger 10.4.7 开始),通用二进制格式的二进制文件可以在 PPC 和 x86 两种处理器上执行。

说到底,通用二进制格式只不过是对多架构的二进制文件的打包集合文件,而 macOS 中的多架构二进制文件也就是适配不同架构的 Mach-O 文件。即一个通用二进制格式包含了很多个 Mach-O 格式文件。它有以下特点:

  • 因为需要存储多种架构的代码,所以通用二进制文件要比单架构二进制文件要大
  • 因为两种种架构之间可以共用一些资源,所以两种架构的通用二进制文件大小不会达到单一架构版本的两倍。
  • 运行过程中只会调用其中的部分代码,所以运行起来不会占用额外的内存

Fat Header 的数据结构在 <mach-o/fat.h> 头文件中有定义,可以参看 /usr/include/mach-o/fat.h 找到定义头:

#define FAT_MAGIC    0xcafebabe
#define FAT_CIGAM    0xbebafeca    /* NXSwapLong(FAT_MAGIC) */

struct fat_header {
    uint32_t    magic;        /* FAT_MAGIC 或 FAT_MAGIC_64 */
    uint32_t    nfat_arch;    /* 结构体实例的个数 */
};

struct fat_arch {
    cpu_type_t    cputype;    /* cpu 说明符 (int) */
    cpu_subtype_t    cpusubtype;    /* 指定 cpu 确切型号的整数 (int) */
    uint32_t    offset;        /* CPU 架构数据相对于当前文件开头的偏移值 */
    uint32_t    size;        /* 数据大小 */
    uint32_t    align;        /* 数据内润对其边界,取值为 2 的幂 */
};

对于 cputypecpusubtype 两个字段这里不讲述,可以参看 /usr/include/mach/machine.h 头中对其的定义,另外 Apple 官方文档中也有简单的描述。

fat_header 中,magic 也就是我们之前在表中罗列的 magic 标识符,也可以类比成 UNIX 中 ELF 文件的 magic 标识。加载器会通过这个符号来判断这是什么文件,通用二进制的 magic0xcafebabenfat_arch 字段指明当前的通用二进制文件中包含了多少个不同架构的 Mach-O 文件。fat_header 后会跟着多个 fat_arch,并与多个 Mach-O 文件及其描述信息(文件大小、CPU 架构、CPU 型号、内存对齐方式)相关联。

这里可以通过 file 命令来查看简要的架构信息,这里以 iOS 平台 WeChat 4.5.1 版本为例:

~ file Desktop/WeChat.app/WeChat
Desktop/WeChat.app/WeChat: Mach-O universal binary with 2 architectures: [arm_v7: Mach-O executable arm_v7] [arm64]
Desktop/WeChat.app/WeChat (for architecture armv7):    Mach-O executable arm_v7
Desktop/WeChat.app/WeChat (for architecture arm64):    Mach-O 64-bit executable arm64

进一步,也可以使用 otool 工具来打印其 fat_header 详细信息:

~ otool -f -V Desktop/WeChat.app/WeChat
Fat headers
fat_magic FAT_MAGIC
nfat_arch 2
architecture armv7
    cputype CPU_TYPE_ARM
    cpusubtype CPU_SUBTYPE_ARM_V7
    capabilities 0x0
    offset 16384
    size 56450224
    align 2^14 (16384)
architecture arm64
    cputype CPU_TYPE_ARM64
    cpusubtype CPU_SUBTYPE_ARM64_ALL
    capabilities 0x0
    offset 56475648
    size 64571648
    align 2^14 (16384)

之后我们用 Synalyze It! 来查看 WeChat 的 Mach64 Header 的效果:

  • 从第一个段中得到 magic = 0xcafebabe ,说明是 FAT_MAGIC
  • 第二段中所存储的字段为 nfat_arch = 0x00000002,说明该 App 中包含了两种 CPU 架构。
  • 后续的则是 fat_arch 结构体中的内容,cputype(0x0000000c)cpusubtype(0x00000009)offset(0x00004000)size(0x03505C00) 等等。如果只含有一种 CPU 架构,是没有 fat 头定义的,这部分则可跳过,从而直接过去 arch 数据。

# 参考链接