李峰峰博客

APP 启动优化 1-冷启动流程

2021-04-02

iOS 的冷启动流程可用下图表示:

接下来,详细介绍 APP 启动流程中的各个阶段:

一、exec()

iOS 操作环境的操作系统部分是 Darwin,Darwin 是一种类 Unix 操作系统,其内核是 XNU,XNU 是一个宏内核 BSD 与微内核 Mach 混合内核。

在 iOS 中,用户环境始于 launchd 进程,它是第一个被内核启动的用户态进程,launchd 由操作系统内核启动,Mac OS 中 launchd 不止一个,第一个为 PID 1,由内核启动,如果有用户登录进系统,则会创建另一个 launchd,这是由第一个 launchd fork 出来的,并且所有权属于登录用户。由于 iOS 不需要登录,所以只有一个系统范围的 launchd,并且它是系统运行期间唯一不能终止的进程,当系统关闭时,它作为最后一个进程退出。

launchd 主要负责直接或间接的启动系统中的其他进程。它是用户模式里所有进程的父进程,同时也将负责两种后台作业:守护程序和代理程序。

  • 守护程序(daemon):守护程序由系统自动启动,不考虑是否有用户登录进系统,是后台服务,通常和用户没有交互。比如 push 通知、外接设备插入的处理和 XPC 等。
  • 代理程序(agent):是一类特殊的守护程序,只有在用户登录的时候才启动,可以和用户交互,有的程序还会有 GUI,比如 MacOS 的 Finder 或 iOS 的 SpringBoard。

当启动一个 APP 时,launchd 进程(launchd 是用户态进程)会通过 fork() 函数进行系统调用,进入内核态克隆出另一个 launchd 进程,再通过 exec() 函数进行系统调用,传入目标 APP 对应主 Mach-O 文件的路径,作为该进程的内容。

也就是说,我们在 Mac OS 和 iOS 里启动的 APP,父进程 parent process 都是 launchd,例如我们通过活动监视器双击任意一个进程可以看到这点:

在 iOS 的 crash log 里我们也能看到 APP 的 parent process 是 launchd

launchd 进程的 parent process 是谁呢?

launchd 的 parent process 是 kernel_taskkernel_task 进程就是内核进程本程了,在内核启动时自行创建。

这里提到内核态和用户态,那内核态和用户态是什么呢?

内核控制着操作系统最核心的部分,为了防止应用程序崩溃而导致的内核崩溃,内核与应用程序之间需要进行严格的分离。基于软件的分离会产生巨大的开销,因此现代的操作系统都是依靠硬件来分离。分离的结果就是用户态与内核态。

用户态和内核态之间有两种切换方式:

  • 自愿转换:比如系统调用;
  • 非自愿转换:当发生异常、中断或处理器陷阱的时候,代码的执行会被挂起,并且保留发生错误时候的完整状态。控制权被转交给预定义的内核态错误处理程序或中断服务程序。

在 XNU 中,系统调用有四种类别:

  • BSD 系统调用
  • Mach 陷阱
  • 机器相关调用
  • 诊断调用

exec() 函数的作用是根据指定的文件名找到可执行文件,并用它来取代调用进程的内容。

UNIX 提供了 6 种不同的 exec() 函数:

1
2
3
4
5
6
7
#include<unistd.h>
int execl(const char *pathname, const char *arg0, ... /* (char *)0 */ );
int execv(const char *pathname, char *const argv[]);
int execle(const char *pathname, const char *arg0, ... /* (char *)0, char *const envp[] */ );
int execve(const char *pathname, char *const argv[], char *const envp[]);
int execlp(const char *filename, const char *arg0, ... /* (char *)0 */ );
int execvp(cosnt char *filename, char *const argv[]);

其中只有 execve 是真正意义上的系统调用,其它都是在此基础上经过包装的库函数,包括 exec() 函数。

也就是说,执行完 exec() 函数,APP 对应的 Mach-O 会被加载进新创建的进程里了。

二、加载 Mach-O

1、Mach-O 的结构

Mach-O(Mach Object 的简写)是 Mac 和 iOS 可执行文件的格式。
常见的 Mach-O 格式文件有:

  • 目标文件 .o
  • 库文件
    • .a
    • .dylib
    • .framework
  • 可执行文件
  • dyld ( 动态链接器 )
  • .dsym ( 符号表 )

我们可以使用 MachOView 查看一个 Mach-O 文件内容:

我们可以生成只包含一种架构的 Mach-O 文件,比如 armv7。当然也可以编译生成多架构的的 Mach-O 文件,这种包含多种架构的我们称之为通用 Mach-O,也可以称为 Fat Mach-O。运行通用 Mach-O 的时候,加载器会选择合适的架构的代码去执行。我们 APP 的主二进制文件就是 Fat Mach-O:

Mach-O 可以分为三部分:

  • Header
  • Load Commands
  • Data

Header 存储该二进制文件的一般信息,例如:架构类型、加载命令的数量等。Header 的最开始是 Magic Number,表示这是一个 Mach-O 文件,除此之外还包含一些 flags,这些 flags 会影响 Mach-O 的解析。

Load Commands 即加载命令,又称指令。这些命令在被调用时清晰地指导了如何设置并加载二进制数据。有一些命令是由内核加载器直接使用的,其他命令是由动态链接器(dyld)处理的。其中 LC_LOAD_DYLINKER 命令中存储了动态链接器(dyld)的路径信息。


Data 部分包含了实际的代码和数据,Data 被分割成很多个 Segment,每个 Segment 又被划分成很多个 Section,分别存放不同类型的数据。
标准的三个 SegmentTEXTDATALINKEDIT,也支持自定义:

  • TEXT,代码段,只读可执行,存储函数的二进制代码(__TEXT),常量字符串(__cstring),Objective C 的类/方法名等信息
  • DATA,数据段,读写,存储 Objective C 的字符串(__cfstring),以及运行时的元数据:class/protocol/method
  • LINKEDIT,启动 App 需要的信息,如 bind & rebase 的地址,代码签名信息,符号表…

2、虚拟内存

用户态的一个优点在于虚拟内存隔离,每个进程都独享一个私有的地址空间。何谓虚拟内存呢?

内存可以分为虚拟内存和物理内存,其中物理内存是实际占用的内存,虚拟内存是在物理内存之上建立的一层逻辑地址,保证内存访问安全的同时为应用提供了连续的地址空间。

系统将虚拟内存和物理内存分割成统一大小的单元,叫做页(page)。在早期的 iOS 里,页大小均为 4K;之后基于 A7 和 A8 的 iOS 里,采用虚拟内存每页 16K,物理内存每页 4K;基于 A9 或更新 CPU 的 iOS 里,页大小均为 16K。

CPU 有个内存管理单元(MMU), 它维护了一张页表(page table),内部记录了虚拟地址和物理地址映射关系。用户访问虚拟地址时,会自动被 MMU 转换成物理地址。当 CPU 访问的虚拟地址并未映射到物理地址时,会发生 Page Fault:暂停当前执行的程序代码,然后分配一块干净的物理内存,从磁盘中加载所需的一页数据到该物理内存,同时更新页表,然后继续执行程序代码。

iOS 是通过 mmap(全称 memory map,一种内存映射技术)做物理内存和虚拟内存映射的。进程向系统申请内存时,系统也并不会直接返回物理内存的地址,而是返回一个虚拟内存地址。只有在 CPU 需要访问该虚拟内存地址时,系统才会分配并映射到物理内存。

如何查看 APP 启动时候发生 Page Fault 的次数呢?可以借助 Instrument 中的 System Trace 工具,System Trace 调试结束会获取到启动过程中的分析数据,在结果页面选中主线程,结果中的 File Backed Page In 次数就是 Page Fault 次数:

所以,Page Fault 次数又被称为 Page In 次数。

同样的,加载 Mach-O 实际上也是通过 mmap 将 Mach-O 文件映射到 APP 进程的虚拟内存里的,这时候只分配了虚拟内存,并没有分配物理内存。如果访问一个虚拟内存地址,而物理内存中不存在的时候,触发一个 Page In,分配物理内存,并把文件中的内容拷贝到物理内存里。如果在操作系统的物理内存里有缓存,则会触发一个 Page Cache Hit,后者是比较快的,这也是热启动比冷启动快的原因之一。

启动的路径上会触发很多次 Page In,其实也比较容易理解,因为启动的会读写二进制中的很多内容。Page In 会占去启动耗时的很大一部分,我们来看看单个 Page In 的过程:

  • MMU 找到空闲的物理内存页面
  • 触发磁盘 IO,把数据读入物理内存
  • 如果是 TEXT 段的页,要进行解密
  • 对解密后的页,进行签名验证

其中解密是大头,IO 其次。为什么要解密呢?

因为 iTunes Connect 会对上传 Mach-O 的 TEXT 段进行加密,防止 IPA 下载下来就直接可以看到代码。这也就是为什么逆向里会有个概念叫做“砸壳”,砸的就是这一层 TEXT 段加密。iOS 13 对这个过程进行了优化,Page In 的时候不需要解密了(iOS 13 启用了 dyld3,提前生成了启动闭包文件)。

在 APP 启动过程,Page In 是一个比较耗时的过程,针对这一块,可以通过二进制重排进行优化(后续内容讲解)。

3、ASLR

根据前面内容我们可以知道进程在自己私有的虚拟地址空间中执行。按照传统的方式,APP 在某个架构上进程初始虚拟内存镜像都是基本一致的,使得内存中地址分布具有非常强烈的可预测性,给黑客提供了很大的施展空间,是很不安全的。

大部分操作系统,包括 iOS,都采用了 ASLR(address space layout randomization,地址空间布局随机化) 技术有效避免攻击,通过增加攻击者预测目的地址难度,防止攻击者直接定位攻击代码位置,达到阻止攻击的目的的一种技术。ASLR 使进程每一次启动时,地址空间都会被简单地随机化——只是偏移,而不是搅乱,内存整体布局保持不变。实现方法在通过 mmap 将 Mach-O 文件映射到 APP 进程的虚拟内存时,通过内核将 Mach-O 的 Segment “平移”某个随机系数。所以在使用了 ASLR 之后:
实际地址(偏移后地址)= 偏移前地址 + ASLR

三、加载 dyld & 加载动态库

1、动态库与静态库

在了解 dyld 之前,先来看下动态库和静态库:

  • 静态库在程序编译时会被链接到目标代码中一起打包生成可执行文件,以 .a 和 .framework 为文件后缀名。
  • 而动态库在程序编译时并不会被链接到目标代码中,只是在程序运行时才被载入,以 .dylib 和 .framework 为文件后缀名(系统直接提供给我们的 framework 都是动态库!)。

我们在 MyApp.app 内通过 otool 命令查看主二进制文件依赖的动态链接库:

1
2
3
4
5
6
7
8
9
$ otool -L MyApp
MyApp:
/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics
/System/Library/Frameworks/UIKit.framework/UIKit
/System/Library/Frameworks/Foundation.framework/Foundation
/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation
/usr/lib/libobjc.A.dylib
/usr/lib/libSystem.dylib
......

静态库优缺点

  • 优点
    • 静态库被打包到可执行文件中,编译成功后可执行文件可以独立运行,不需要依赖外部环境。
  • 缺点
    • 编译生成的可执行文件会变大,如果静态库更新必须重新编译。
    • 多个 APP 使用同一个静态库,每个 APP 都会拷贝一份,浪费内存。

动态库优缺点

  • 优点
    • 减小编译生成的可执行文件(也可简单理解为 APP)的体积。
    • 共享内容,节省资源。
    • 通过更新动态库,达到更新程序的目的。
  • 缺点
    • 可执行文件不可以单独运行,必须依赖外部环境。

其中动态库分动态链接库和动态加载库两种:

  • 动态链接库(Dynamic Link Library,简写 dylib):在编译阶段需要指定 app 需要依赖哪些动态库。当运行可执行文件时,如果系统还没有加载过这些库时,就会随着运行可执行文件的加载一起加载这些动态库。如果有多个可执行文件依赖同一个动态库,不会重复加载。iOS 中用到的所有系统 .framework 都是动态链接库。
  • 动态加载库:编译阶段不需要指定 app 需要依赖哪些动态库。当运行过程中需要加载某个动态库时,就会用 dlopen 函数动态的把库加载到内存中使用。

需要特别注意的是,上面提到的动态库多个 APP 共用,节省内存,这是针对系统动态库而言的。对于我们自己的动态库,会被打包进 IPA 文件中。所以,对于使用非系统的动态库并不能有效减少包体积。

既然我们自己的动态库无法实现多 APP 共用,不能减少包体积,而且还会影响启动速度,那动态库还有什么用呢?iOS 8 之后,开放了 App Extension 功能,可以为一个应用创建插件,APP Extension 和主 APP 是两个独立的进程,两者之间共享代码逻辑就必须使用动态库了。

上面提到,动态链接库在程序运行时才被载入,那么这些动链接态库是如何被载入的呢?这就是 dyld 的工作了。

把主二进制 mmap 进来后,读取 load command 中的 LC_LOAD_DYLINKER 命令,找到 dyld 的的路径。然后 mmap dyld 到虚拟内存,找到 dyld 的入口函数 _dyld_start,把 PC 寄存器设置成 _dyld_start,接下来启动流程交给了 dyld

注意这个过程都是在内核态完成的,这些流程完成后,将会进入用户态由 dyld 完成后续的工作,这里提到了 PC 寄存器,PC 寄存器存储了下一条指令的地址,程序的执行就是不断修改和读取 PC 寄存器来完成的。

2、dyld 2 和 dyld 3

dyld 全名 The dynamic link editor,即动态链接器,是 iOS & Mac OS 系统的重要组成部分。在内核将 APP 的主 Mach-O 文件和 dyld 加载完成后,后续的流程将交由 dyld 来完成。dyld 是 in-process 的,也就是在我们 APP 的进程里工作的。dyld 负责解析 Mach-O 的 Header, 得到所依赖的 dylib(动态链接库),通过递归的方式把全部需要的 dylib 都加载进来。

不过,需要注意的是,dyld 工作不仅仅如此,APP 启动流程中的 RebaseBind 等一直到执行 main 函数,都是由 dyld 来完成的。dyld 主要有两个版本:dyld 2dyld 3,iOS 3.1~iOS 12 系统使用 dyld 2,iOS 13 开始采用 dyld 3

dyld 2 主要工作过程如下:

  1. 解析 Mach-O 的 Header
  2. 查找依赖库
  3. 映射 Mach-O 文件到进程内存地址空间中
  4. 执行符号查找
  5. 进行 rebasebind
  6. 运行所有初始化器
  7. 执行 main 函数

iOS 13 开始 Apple 对三方 App 启用了 dyld 3dyld 3 将上面 dyld 2 工作过程的第 (1)、(2)、(3) 这三步在另外一个进程上提前(APP 安装或升级时)处理好了,并且将处理结果保存到启动闭包中,并将闭包存储到磁盘(存储在沙盒的 tmp/com.apple.dyld 目录)中。

dyld 3 被分为了三个组件:

  • 进程外的 MachO 解析器

    • 预先处理了所有可能影响启动速度的 search path、@rpaths 和环境变量

    • 然后分析 Mach-O 的 Header 和依赖,并完成了所有符号查找的工作

    • 最后将这些结果创建成了一个启动闭包

    • 这是一个普通的 daemon 进程(守护进程),可以使用通常的测试架构

  • 进程内的引擎,用来运行启动闭包

    • 这部分在进程中处理

    • 验证启动闭包的安全性,然后映射到 dylib 之中,再跳转到 main 函数

    • 不需要解析 Mach-O 的 Header 和依赖,也不需要符号查找。

  • 启动闭包缓存服务

    • 系统 App 的启动闭包被构建在一个 Shared Cache 中, 我们甚至不需要打开一个单独的文件

    • 对于第三方的 App,闭包创建时机如下:

      • App 安装/升级后首次启动时
      • 重启手机后首次启动 App 时
        • 因为闭包存储在沙盒 temp 目录中,重启 App 会被清除。

最终闭包中内容如下:

  • dependends:依赖动态库列表
  • fixuprebase & bind 的地址
  • initializer-order:初始化器调用顺序
  • optimizeObjc:Objective C 的元数据
  • 其他:main entry, uuid…

相较于 dyld 2dyld 3 把很多耗时的查找、计算和 I/O 的事前都预先处理好了,这使得启动速度有了很大的提升。因为这些信息是每次启动都需要的,把信息存储到一个缓存文件就能避免每次都解析,尤其是 Objective C 的运行时数据(Class/Method…)解析非常慢。

四、Rebase & Bind

这里需要先了解下什么是 PIC

PIC,全拼 Position Independ Code,中文名称:位置独立代码、地址无关代码,又称地址无关可执行文件(英文: position-independent executable,缩写为 PIE)。是指可在主存储器中任意位置正确地运行,而不受其绝对地址影响的一种机器码。PIC 广泛使用于共享库,使得同一个库中的代码能够被加载到不同进程的地址空间中。

iOS 系统有许多动态链接库(dylib),只有在使用的时候,这些动态链接库才会被系统加载到内存中。这些系统的动态链接库有一个公用的共享缓存区(也叫共享缓存库),由于动态链接库在系统中只保留一份内存,所以当某个 APP 使用到这个动态链接库的时候,就会去访问这个共享缓存区。

因为动态链接库可以被多个进程共享,所以在编译的时候,是不知道动态链接库中的对象或方法在一个进程中的虚拟内存地址的,只有在 APP 运行时才知道对应虚拟内存地址。当 APP 要访问一个 Mach-O 外部(例如动态链接库中的)的对象或方法时,PIC 会在 Mach-O 文件的 __DATA 段中创建一个指针,指向动态链接库中的对应的方法,在调用方法时,实际上是通过这个指针进行间接调用的。将这个指针指向动态链接库中对象或方法的过程就叫做 Bind(绑定)。FishHook 也正是利用修改这个指针的指向实现的 Hook 目的。

由于 ASLR 的存在,Mach-O 文件和动态链接库在虚拟内存中的加载地址每次启动都不固定,发生偏移。所以就需要进行 Rebase 操作修正 __DATA 段中这个指针的指向,即将地址加上内存偏移量得到真实地址。

  • Rebase

    • 修复指针偏移。这是因为 Mach-O 在 mmap 到虚拟内存的时候,起始地址会有一个 ASLR 的随机的偏移量 slide,需要把内部的指针指向加上这个 slide。
  • Bind

    • 绑定指针与动态链接库中的函数的地址。因为对于动态链接库中的函数,只有运行时才知道它的地址是什么,Bind 就是把指针指向这个地址。

这里为什么一定要通过 Rebase 修复内部指针而不是在 mmap 时直接修改 Mach-O 中对应内存地址呢?

这是因为在 Mach-O 编译时 Xcode 会进行代码签名(Code Sign),Code Sign 签名时,会对每个 page(这里是指 Segment Data)进行签名生成一个单独的加密散列值,并存储到 __LINKEDIT 中去,系统在加载时会校验每页内容确保没有被篡改,所以如果直接修改对应内存地址会导致签名验证不通过,所以不能直接修改 Image。Rebase 的时候只需要增加对应的偏移量即可,待 Rebase 的数据都存放在 __LINKEDIT 中。

五、Objc setup & Initializers

dyldBind & Rebase 流程结束之后,首先会执行 libSystem 的 Initializer,做一些最基本的初始化:

  • 初始化 libdispatch(libdispatch 的别称是 GCD)
  • 初始化 objc runtime、注册 sel、加载 category

接下来会进行 main 函数之前的一些初始化,主要包括 +load 和 static initializer,两者调用顺序根据 Apple 官方文档对 +load 方法的描述可以知道:

he order of initialization is as follows:
All initializers in any framework you link to.
All +load methods in your image.
All C++ static initializers and C/C++ attribute(constructor) functions in your image.
All initializers in frameworks that link to you.
In addition:
A class’s +load method is called after all of its superclasses’ +load methods.
A category +load method is called after the class’s own +load method.

即:先调用 +load 方法,再调用 static initializer 相关方法。父类 +load 方法先于子类被调用,类的 +load 方法先于分类被调用。

static initializer 静态初始化产生的条件
包括但不限于以下几种逻辑会导致静态初始化:

(1) attribute((constructor)) 修饰的函数

例如:

1
2
3
__attribute__((constructor)) void myentry(){
NSLog(@"constructor");
}

(2) 通过执行代码初始化全局变量

例如需要执行 C/C++ 函数

1
2
3
4
5
6
7
8
bool initBar(){
int i = 0;
++i;
return i == 1;
}

static bool globalBar = initBar();
bool globalBar2 = initBar();

六、main

+load 和 static initializer 执行完毕之后,dyld 会把启动流程交给 App,开始执行 main 函数。main 函数里要做的最重要的事情就是初始化 UIKit。UIKit 主要会做两个大的初始化:

  • 初始化 UIApplication
  • 启动主线程的 Runloop

由于主线程的 dispatch_async 是基于 runloop 的,所以在 +load 里如果调用了 dispatch_asyncdispatch_async 相关任务会在这个阶段执行。

之后,就进入到我们熟悉的 UIApplicationDelegate 流程了。

参考:
抖音APP启动速度提升实践

Tags: 优化