理解 Mach-O 并提高程序启动速度

我们日常开发的打包或者 SDK 的打包会生成一个ipa 或者 framework。在 framework 和 ipa 文件中其实都可以找到一个 exec 文件。这个文件就是一个 Mach-O 文件。这一次主要就是深入的去了解 Mach - O 文件在到底都用来做什么。

(一)了解 Mach - O 的结构

如果我们想对 Mach -O 文件有所了解,可以将我们打包好的 ipa 文件后缀改成 .zip,然后解压生成 Payload 文件,在其中就可以找到 exec 文件。或者找一个动态库的 framework 在其中也可以找到 exec 文件。

然后用 MachOView 获取文件内容。MachOView 相关教程
文件格式大致如下。

Mach-O 1.0

1.Fat Header 文件

MachOView 中查看 Fat Header结构大概如下图
PS:上下两个图使用了不一样的 exec 文件 因为我的 MachOView 一直闪退... 知道好的解决方案的小伙伴也烦请告知

Mach-O 1.1

Magic Number 主要是快速的获取当前的二进制文件用于 32 位还是 64 位CPU

从中我们同时可知这个二进制文件支持的架构个数。如果想知道 framework 是否存在隐患,不支持你需要支持机型的架构,你提前就可以这样进行查看。

同时可知如果我们的 ipa 打包好后,下发给用户,如图Mach-O 1.0可知文件中包含多个所支持架构生成的文件。也是说使用Fat Header读取来获取与当前 CPU 匹配的 Executable,然后在进行后续的操作。当然如果我们是制作 SDK , 此时就是生成一个 Library
接下来就来探究他们的结构。

2.Executable 和 Library

打开后可以看到其架构结构大致如下

Mach-O 1.2

Mach Header 的结构如下

Mach-O 1.3

其实这和上边的 Fat Header 很相似,但是这里主要包含下文会介绍的加载过程中的信息(比如 SEGMENT 段中需要加载的 dyld 信息就是由 Mach Header 提供)

现在看看 Load Commands ,这里就是二进制文件加载进内存要执行的一些指令。
这里的指令主要在负责我们 APP 对应进程的创建和基本设置(分配虚拟内存,创建主线程,处理代码签名/加密的工作),然后对动态链接库(.dylib 系统库和我们自己创建的动态库)进行库加载和符号解析的工作。

首先看下 Load Commands 的目录结构

Mach-O 1.4

从上图可知 Load Commands 主要包含了有多个 Segment 段,每个中又包含了多个 Section 段。每一部分都是系统执行指令。
其中 LC_SEGMENT 包含空指针陷阱
__TEXT 段主要包含程序代码和只读的常量,这个段的内容如果是系统动态库的内容那么所有进程公用
__DATA 段主要包含全局变量和静态变量,这个段的内容每个进程单独进行维护
__LINKEDIT 主要包含链接器使用的符号和其他的表(比如函数名称、地址等) 这个段的内容也是可以多进程公用的。

此外还需介绍下和 SEGMENT 并列的一些比较重要的指令。

LC_MAIN 是在所有的库都加载完成后,有其中的指令启动程序的主线程。我们的程序也是在这个函数之后才开始执行 main() 函数的。

LC_CODE_SIGNATURE 我想每个 iOSer 都知道代码签名的机制,其实代码签名的校验也是在这个指令下进行。实际上指令会把整个文件进行 hash 化处理并签名,在运行时去验证签名的正确性。(想要详细了解代码签名机制的小伙伴看这里)

(二)Mach - O 加载过程

我们在了解了 Mach-O 的结构后再看加载过程应该更好理解一些。
Mach-O 的加载的过程大致如下

  • load dyld

PS:在 iOS 10 后 dyld 为 tbd,网上有说法 tbd 的出现是因为 iOS 10 后对系统文件进行压缩后的文件就是现在的 tbd , 能起到减少包大小的作用。

dyld 加载阶段主要是加载动态链接库的过程,所要加载的 dyld 在上文中的 Mach Header 中有记录,这样就知道了文件的读取位置,然后进行代码签名并注册进内核。但是当前加载的 dyld 可能会包含其他 dyld ,所以这是就需要递归的进行加载。MAC OS 和 iOS 中都有共享缓存库的概念,一般都把 dyld 进行预先链接,然后将链接保存在一个磁盘上,这样对于这一部分的加载速度会很快。一般应用加载的 dyld 在 100 ~ 400 个左右。

  • Rebasing

因为当前系统内存空间地址布局的随机化,所有现在读取 dyld 之后加载到的地址的都是随机化的,这就和代码以及数据指向的旧地址有偏差, 在这个过程中主要做的就是修复这个随机化的地址。

  • Binding

简单的解释,就是我们在调用 dyld 的过程中可能会插入自己的代码,在上一步中我们修复了 dyld 的指针地址,但是在 __LINKEDIT 中对于我们自己写的代码是用符号(symbol)进行绑定的。这个时候就需要找到指针指向的符号以及符号的具体的实现,然后进行 bind 的过程,这时候就去符号表中进行查找,找到后存储到 __DATA 段中的那个指针中,保证程序运行时可以正确的 jump 到正确的指令处 。

  • Objc Setup

这个过程如下:
1.类注册的过程,然后维护一张映射类名和类的全局表。
2.对 Category 中的定义的方法,协议等插入对应的方法,协议等列表。
3.确定类方法的唯一性。

  • Initializers

这里主要对于 OC 对象回调用每个类的 +load 方法。
对于类对象的调用顺序是 根据之前 dylib 加载行程了一张巨大的网,现在从子节点一直向上加载到根节点。 这样确保 dyld 加载前依赖的 dyld 已经加载。

上边一些列步骤执行结束之后会执行我们程序中的 main() 函数,然后执行 APPDelegate中的函数。

(三)改善启动时间

在了解了 Mach-O 文件的原理之后,那么我们能做些什么呢?其实我们已经知道了 main() 函数调用前都做了什么,那么我们就可以优化这一部分的执行时间。

测试启动时间可以如下设置

Mach-O 3.1

用我们的项目测了下启动时间,大致如下。


Mach-O 3.2

从上图可知项目的启动时间,就从上边各个阶段的原理上去找寻优化方案。

  • load dyld images

上文已说苹果对于这部分的优化已经做了共享缓存库,如果有部分内嵌(embedded)库,这一部分的加载时间可能会较慢,现有方案就是将这一部分的库进行组合或者使用静态链接库进行解决。记得去年听 devLink 的时候小虎哥说过一些场景下用静态库会出现问题,他们最后的解决方案可以参考这篇文章

  • rebase & bind

对于 OC 而言,这一部分主要就是减少地址随机化的修正的过程和符号寻址的过程,实际应用中减少 Class ,Selector 和 Category 的数量。

  • ObjC Setup

这一部分可优化空间。这里出现的问题,其实和 rebase & bind 中的问题类似,其实还是需要减少 Class 、Category、Selector 的数量。

  • Initializer

因为 + load 方法在这个过程中调用,所以调用 +load 的方法最好改成 +initialize

现在我们对 Mach - O 就有了一定的理解。此时对于 Mach-O 文件的生成过程比较感兴趣,接下来的文章可能会关于编译过程文章阅读后的总结和理解。

本文在书写过程中参考了国内大牛们的优秀文章。
参考文章如下:
杨萧玉的文章
今日头条技术博客
苹果去年的WWDC
南栀倾寒的简书
深入解析 MAC OS X & iOS 操作系统一书。

推荐阅读更多精彩内容