iOS进阶 - Mach-O文件解析

3,276 阅读13分钟

Mach-O文件解析

Mach-O是什么?

Mach-O 其实是 Mach Object 文件格式的缩写,在 Mac 和 iOS 上,可执行文件的格式通常都是 Mach-O 格式,但是 Mach-O 格式的文件并非一定是可执行文件。

Mach 不是 Mac,Mac 是苹果电脑 Macintosh(麦金塔电脑) 的简称,而 Mach 是一个由卡内基梅隆大学开发的用于支持操作系统研究的操作系统内核。史蒂夫·乔布斯在1985年离开苹果后创立了 NeXT 公司,NeXT.Inc 以 Mach内核 和 BSD Unix 操作系统的部分代码为基础,以 Objective-C 作为原生语言,开发出了 NEXTSTEP 操作系统,在 Mach 上,可执行文件的格式就是 Mach-O(Mach Object file format)。1996年,乔布斯将 NEXTSTEP 带回苹果,Math 也成为了 OS X 的内核基础。所以虽然 Mac OS X 是 Unix 的“后代”,但主要支持的可执行文件格式是 Mach-O。iOS 是从 OS X 演变而来,所以同样支持 Mach-O 格式的可执行文件。

常见属于 Mach-O 格式的文件类型:

  • Mach-O Object 目标文件 .o
  • Mach-O Ececutable 可执行文件
  • Mach-O Dynamically 动态库文件 .a.dylib
  • Mach-O dynamic linker 动态链接器文件
  • Mach-O dSYM companion 符号表文件
  • Bundle 自己创建的动态库,运行在沙盒中,无法被 dyld 链接,只能通过 dlopen() 加载
  • Framework 包含Dylib、资源文件和头文件的文件夹

查看Mach-O文件

当我们使用Xcode运行我们的iOS程序的时候,会生成一个 .app 文件,位于 工程目录/Product 文件夹下,.app 文件实际上是一个文件夹,我们可以右键,显示包内容,就可以查看文件夹中的内容了,其中有一个与工程同名的 unix 可执行文件,这就是一个 Mach-O 格式的文件。

使用 file 命令可以查看文件格式:

➜ file xxxx
xxxx: Mach-O universal binary with 2 architectures: [arm_v7:Mach-O executable arm_v7] [arm64]
xxxx (for architecture armv7):	Mach-O executable arm_v7
xxxx (for architecture arm64):	Mach-O 64-bit executable arm64

使用 lipo - info 命令可以查看文件所支持的系统架构:

➜ lipo -info xxxx
Architectures in the fat file: xxxx are: armv7 arm64

我们可以使用 MachOView 打开该文件,文件结构如下:

Fat Binary

可以看到,在该文件中有两种系统架构 armv7 和 arm64,这种包含了支持多架构的 Mach-O Ececutable 可执行文件被称为:通用二进制文件,即多种架构都可读取运行。

在 Xcode 中通过编译设置 Architectures 是可以更改所生成的 Mach-O ececutable 可执行文件的支持架构,想要支持多架构的话,在Valid Architectures 中继续添加就可以了。

Architectures

通用二进制文件

  • 苹果公司提出的一种程序代码。能同时适用多种架构的二进制文件
  • 同一个程序包中同时为多种架构提供最理想的性能。
  • 因为需要储存多种代码,通用二进制应用程序通常比单一平台二进制的程序要大。
  • 但是由于两种架构有共通的非执行资源,所以并不会达到单一版本的两倍之多。
  • 而且由于执行中只调用一部分代码,运行起来也不需要额外的内存。

通用二进制文件通常被称为 Universal binary ,在 MachOView 中叫做 Fat binary ,这种二进制文件是可以完全拆分开来,或者重新组合的,那么接下来我们来试一下。

拆分 Fat binary,拆分后源文件并不会改变:

➜ lipo xxxx -thin armv7 -output xxxx_armv7
➜ lipo xxxx -thin arm64 -output xxxx_arm64
➜ ls
xxxx
xxxx_arm64
xxxx_armv7

合并 Fat binary

➜ lipo -create xxxx_armv7 xxxx_arm64 -output NewXXXX

合并后我们来看下新生成的和以前的文件的哈希值,一模一样:

Desktop md5 xxxx
MD5 (xxxx) = 3becf3b28d7bb00035ae2dd85172b303
➜  Desktop md5 NewXXXX
MD5 (NewXXXX) = 3becf3b28d7bb00035ae2dd85172b303

多架构二进制文件组合成通用二进制文件时 , 代码部分是不共用的 ( 因为代码的二进制文件不同的组合在不同的 cpu 上可能会是不同的意义 ) 。而公共资源文件是会共用的。

Mach-O文件格式

Mach-O格式

Header

Header 中保存了一些基本信息,包括了该文件是 32 位还是 64 位、运行该文件对应的处理器架构是什么、文件类型、LoadCommands 的个数等。通过 MachOView 打开可执行文件,可以看到 Header 的结构:

Header

接下来我们结合 Mach-O/loader.h 的源码来分析一下 header 中每一项的含义,如何找到该源码呢?

  1. Darwin-XNU 内核中的 Mach-O/loader.h

  2. Xcode 目录下 mach-o 文件夹的路径

    /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/usr/include/mach-o
    

从源码中我们可以找到 mach_headermach_header_64 结构体,mach_header_64mach_header ,多了一个保留字段。

struct mach_header_64 {
    uint32_t			magic;					/* 魔数,快速定位64位/32位 */
    cpu_type_t		cputype;				/* 支持的 CPU 架构类型 比如 ARM */
    cpu_subtype_t	cpusubtype;			/* 在支持的CPU架构类型下,所支持的具体机器型号 比如arm64 */
    uint32_t			filetype;				/* 文件类型 例如可执行文件 .. */
    uint32_t			ncmds;					/* load commands 加载命令条数 */
    uint32_t			sizeofcmds;			/* 所有 load commands 加载命令的大小*/
    uint32_t			flags;					/* 标志位 标识二进制文件支持的功能 , 主要是和系统加载、链接有关*/
    uint32_t			reserved;				/* reserved , 保留字段 */
};

filetype,Mach-O的文件类型:

#define MH_OBJECT   		0x1     /* Target 文件:编译器对源码编译后得到的中间结果 */
#define MH_EXECUTE  		0x2     /* 可执行二进制文件 */
#define MH_FVMLIB   		0x3     /* VM 共享库文件(还不清楚是什么东西) */
#define MH_CORE     		0x4     /* Core 文件,一般在 App Crash 产生 */
#define MH_PRELOAD  		0x5     /* preloaded executable file */
#define MH_DYLIB    		0x6     /* 动态库 */
#define MH_DYLINKER 		0x7     /* 动态连接器 /usr/lib/dyld */
#define MH_BUNDLE   		0x8     /* 非独立的二进制文件,往往通过 gcc-bundle 生成 */
#define MH_DYLIB_STUB   0x9 		/* 静态链接文件 */
#define MH_DSYM     		0xa     /* 符号文件以及调试信息,在解析堆栈符号中常用 */
#define MH_KEXT_BUNDLE  0xb 		/* x86_64 内核扩展 */

flags,Mach-O文件的标志位。主要作用是告诉系统该如何加载这个Mach-O文件以及该文件的一些特性。有很多值,我们取常见的几种:

#define MH_NOUNDEFS	0x1     /* Target 文件中没有带未定义的符号,常为静态二进制文件 */
#define MH_SPLIT_SEGS	0x20  /* Target 文件中的只读 Segment 和可读写 Segment 分开  */
#define MH_TWOLEVEL 0x80        /* 该 Image 使用二级命名空间(two name space binding)绑定方案 */
#define MH_FORCE_FLAT 0x100 /* 使用扁平命名空间(flat name space binding)绑定(与 MH_TWOLEVEL 互斥) */
#define MH_WEAK_DEFINES 0x8000 /* 二进制文件使用了弱符号 */
#define MH_BINDS_TO_WEAK 0x10000 /* 二进制文件链接了弱符号 */
#define MH_ALLOW_STACK_EXECUTION 0x20000/* 允许 Stack 可执行 */
#define MH_PIE 0x200000  /* 加载程序在随机的地址空间,只在 MH_EXECUTE中使用 */
#define MH_NO_HEAP_EXECUTION 0x1000000 /* 将 Heap 标记为不可执行,可防止 heap spray 攻击 */

Load Commands

load commands 紧跟在 header 之后,用来告诉内核和 dyld,如何将 APP 运行所需的资源加载入内存中。比如 main 函数的加载地址,动态链接器 dyld 的文件路径,以及相关依赖库的文件路径,还有 Data 中的 Segment 如何加载入内存。

load command 在源码被表示为 struct,有若干种 load command,但是共同的特点是,在其结构的开头处,必须是如下两个属性:

struct load_command {
    uint32_t cmd;       /* type of load command */
    uint32_t cmdsize;   /* total size of command in bytes */
};

苹果为 cmd 定义了若干的宏,用来表示 cmd 的类型,下面列举出几种:

#define	LC_SEGMENT	0x1	/* 描述该如何将该32位 segment 加载如内存,对应 segment_command 类型 */
#define	LC_SEGMENT_64	0x19	/* 描述该如何将该64位 segment 加载如内存,对应 segment_command_64 类型 */
#define LC_UUID     0x1b /* 二进制文件的唯一标识符 */
#define LC_LOAD_DYLINKER 0xe /* 启动动态链接器 dyld */
Segment Load Command

在这么多的 load command 中,需要我们重点关注的是 segment load command。segment command 解释了该如何将 Data 中的各个 Segment 加载入内存中。

Segment load command 分为32位和64位,32位和64位的 Segment load command 基本类似,只不过在64位的结构中,把和寻址相关的数据类型,由32位的 uint32_t 改为了64位的 uint64_t 类型。

struct segment_command { /* for 32-bit architectures */
    uint32_t    cmd;        /* LC_SEGMENT */
    uint32_t    cmdsize;    /* includes sizeof section structs */
    char        segname[16];    /* segment name */
    uint32_t    vmaddr;     /* memory address of this segment */
    uint32_t    vmsize;     /* memory size of this segment */
    uint32_t    fileoff;    /* file offset of this segment */
    uint32_t    filesize;   /* amount to map from the file */
    vm_prot_t   maxprot;    /* maximum VM protection */
    vm_prot_t   initprot;   /* initial VM protection */
    uint32_t    nsects;     /* number of sections in segment */
    uint32_t    flags;      /* flags */
};

struct segment_command_64 { /* for 64-bit architectures */
    uint32_t    cmd;        /* LC_SEGMENT_64 */
    uint32_t    cmdsize;    /* includes sizeof section_64 structs */
    char        segname[16];    /* segment name */
    uint64_t    vmaddr;     /* memory address of this segment */
    uint64_t    vmsize;     /* memory size of this segment */
    uint64_t    fileoff;    /* file offset of this segment */
    uint64_t    filesize;   /* amount to map from the file */
    vm_prot_t   maxprot;    /* maximum VM protection */
    vm_prot_t   initprot;   /* initial VM protection */
    uint32_t    nsects;     /* number of sections in segment */
    uint32_t    flags;      /* flags */
};

我们通过 MachOView 来看看:

segment load command

可以看到 LC_SEGMENT(__PAGEZERO)LG_SEGMENT(__TEXT)LG_SEGMENT(__DATA)LG_SEGMENT(__LINKEDIT) 都是 Segment load command。

LC_SEGMENT(__PAGEZERO) 是一个特殊的 Segment,这个 Segment 其实是苹果虚拟出来的,只是一个逻辑上的段,而在Data中,根本没有对应的内容,也没有占用任何硬盘空间。LC_SEGMENT(__PAGEZERO) 在VM中被置为 Read only,逻辑上占用APP最开始的一段内存空间,用来处理空指针。由上图可以看到其 vm size 是 16384 字节 = 16KB,但其真正的物理地址 File size 和 File offset 都是0。

当启动一个应用程序的时候,系统内核把应用映射到新的地址空间,且每次起始位置都是随机的(因为使用了ASLR),并将起始位置到某段范围的进程权限都标记为不可读写不可执行,而这个范围便是 LC_SEGMENT(__PAGEZERO) 的 vm size。

  • 如果是32位进程,这个范围至少是4KB。
  • 如果是64位进程,至少是4GB。
  • 可以捕捉任何空指针引用。
  • 捕捉任何指针截断。

LC_LOAD_DYLINKER 用来导入 dyld,其中保存着 dyld 的路径,一般为 /usr/lib/dyld 。

LC_LOAD_DYLIB

在 Load Commands 里,有很多 LC_LOAD_DYLIB 命令,用来加载动态库,包括系统动态库和我们自己添加的动态库,比如我们通过 CocoaPods 引入的第三方库。

第三方库

Section Header

在 Data 中,程序的逻辑和数据是按照 Segment(段)存储,而在 Segment 中,又分为0或多个 Section(节),每个 Section 中存储的才是实际的内容。之所以这样组织数据,是因为同一个段下的节,在内存的权限相同,可以不完全按照页大小进行内存对齐,节省内存空间。而对外整体暴露段,在装载程序的时候完整映射成一个vma,可以更好的做内存对齐。每一个 Segment 的大小都是内存页大小的整数倍。

每一个 Segment load command 下面,都会包含对应 Segment 下所有 Section 的 header。

Section Header 的定义如下:

struct section { /* for 32-bit architectures */
    char        sectname[16];   /* name of this section */
    char        segname[16];    /* segment this section goes in */
    uint32_t    addr;       /* memory address of this section */
    uint32_t    size;       /* size in bytes of this section */
    uint32_t    offset;     /* file offset of this section */
    uint32_t    align;      /* section alignment (power of 2) */
    uint32_t    reloff;     /* file offset of relocation entries */
    uint32_t    nreloc;     /* number of relocation entries */
    uint32_t    flags;      /* flags (section type and attributes)*/
    uint32_t    reserved1;  /* reserved (for offset or index) */
    uint32_t    reserved2;  /* reserved (for count or sizeof) */
};

struct section_64 { /* for 64-bit architectures */
    char        sectname[16];   /* name of this section */
    char        segname[16];    /* segment this section goes in */
    uint64_t    addr;       /* memory address of this section */
    uint64_t    size;       /* size in bytes of this section */
    uint32_t    offset;     /* file offset of this section */
    uint32_t    align;      /* section alignment (power of 2) */
    uint32_t    reloff;     /* file offset of relocation entries */
    uint32_t    nreloc;     /* number of relocation entries */
    uint32_t    flags;      /* flags (section type and attributes)*/
    uint32_t    reserved1;  /* reserved (for offset or index) */
    uint32_t    reserved2;  /* reserved (for count or sizeof) */
    uint32_t    reserved3;  /* reserved */
};
  • sectname:比如 _textstubs

  • segname : 该 section 所属的 segment,比如 __TEXT

  • addr : 该 section 在内存的起始位置

  • size : 该 section 的大小

  • offset : 该section的文件偏移

  • align :字节大小对齐

  • reloff :重定位入口的文件偏移

  • nreloc :需要重定位的入口数量

  • flags :包含 sectiontypeattributes

加载对应的 Segment 的时候,就是根据 Segment load command 下面的 Section Header 来逐个加载对应的 Section。

Data

Data 是真正存储 APP 二进制数据的地方,前面的 Header 和 Load Command,仅是提供文件的说明以及如何加载信息的功能。Data 也被分为若干的部分,除了包含程序的逻辑和数据的 Segment 外,还包括符号表,代码签名,动态加载器信息等。我们具体来讨论一下 Segment 部分。

Segment 根据内容的不同,分为若干类型,类型名称均是以 “双下划线+大写英文” 表示,有的 Segment 下面还会包含若干的 Section,Section 的命名是以 ”双下划线+小写英文” 表示。

在 Mach-O 中定义了以下5种 Segment:

#define SEG_PAGEZERO    "__PAGEZERO" /* 当时 MH_EXECUTE 文件时,表示空指针区域 */
#define SEG_TEXT    "__TEXT" /* 代码/只读数据段 */
#define SEG_DATA    "__DATA" /* 数据段 */
#define SEG_OBJC    "__OBJC" /* Objective-C runtime 段 */
#define SEG_LINKEDIT    "__LINKEDIT" /* 包含需要被动态链接器使用的符号和其他表,包括符号表、字符串表等 */

注意这里提到的 SEG_OBJC ,是和 OC 的 Runtime 相关的,但是在 OBJC2.0 中已经废弃掉 __OBJC 段,而是将其放入 __DATA 段中以 __objc 开头的 Section 中。

OC项目的 Mach-O 文件 Data 模块内容:

OC

Swift项目的 Mach-O 文件 Data 模块内容:

Swift

__TEXT

__TEXT 是程序的只读段,用于保存我们所写的代码和字符串常量,const 修饰常量等。下面是 __TEXT 段下常见的 Section:

Section用途
__TEXT.__text主程序代码
__TEXT.__cstringC 语言字符串
__TEXT.__constconst 关键字修饰的常量
__TEXT.__stubs用于 Stub 的占位代码,很多地方称之为桩代码。
__TEXT.__stubs_helper当 Stub 无法找到真正的符号地址后的最终指向
__TEXT.__objc_methname记录了当前APP中所定义的所有方法的名称
__TEXT.__objc_methtype这个seciton与__objc_methname节对应,记录了method的描述字符串
__TEXT.__objc_classname这里面以字符串常量的形式,记录了我们自定义 Class 以及所引用的系统 Class 的名称,同时也包括 Category,protocol 的名称

值得注意的是,这些都是以明文形式展现的。如果我们将加密key用字符串常量或宏定义的形式存储在程序中,可以想象其安全性是得不到保障的。

__Data

__DATA 段用于存储程序中所定义的数据,可读写。在 __DATA 段下,有许多以 __objc 开头的 Section,而这些 Section,均是和 Runtime 的加载有关的。__DATA 段下常见的 Section 有:

Section用途
__DATA.__data初始化过的可变数据
__DATA.__la_symbol_ptrlazy binding 的指针表,表中的指针一开始都指向 __TEXT.__stub_helper
__DATA.__nl_symbol_ptr非 lazy binding 的指针表,每个表项中的指针都指向一个在装载过程中,被动态链接器搜索完成的符号
__DATA.__const记录在OC内存初始化过程中的不可变内容,比如 method_t 结构体定义
__DATA.__cfstring程序中使用的 Core Foundation 字符串(CFStringRefs)
__DATA.__objc_ivar存储程序中的 ivar 变量
__DATA.__objc_classlist记录了App中所有的class,包括meta class。该节中存储的是一个个的指针,指针指向的地址是class结构体所在的地址
__DATA.__objc_protolist记录了App 中所有的 Protocol,存储了一个个指向 protocol_t 的指针
__DATA.__objc_catlist记录了App中所有的Catgory,存储了一个个指向 __objc_category 的指针
__DATA.__objc_imginfo主要用来区分OC的版本是 1.0 还是 2.0
__DATA.__objc_selrefs标记哪些SEL对应的字符串被引用了
__DATA.__objc_classrefs标记哪些类被引用了
__DATA.__objc_protorefs标记哪些 Protocol 被引用了
__DATA.__objc_superrefsObjective-C 超类引用