这些文件运行在那些基于,App 的启动时间

这是 WWDC 2016 Session 406 理论部分的笔记,内容包含着了 Mach-O,虚拟内存的一点点知识,不过主要还是关注在 main() 函数之前做了什么。

App 运行理论

这是一篇 WWDC 2016 Session 406 的学习笔记,从原理到实践讲述了如何优化 App 的启动时间。


这是一篇 WWDC 2016 Session 406 的学习笔记,从原理到实践讲述了如何优化 App 的启动时间。

Mach-O 是一种运行时可执行二进制文件类型。除了在应用中常见的可执行文件之外,还有 dylib,bundle(特殊的 dylib,只能在运行时通过 dlopen() 函数装载)等等,都是 Mach-O 文件,也被称作 Image。这些文件运行在那些基于 Mach 内核的操作系统上,比如 macOS 和 iOS 等等。


App 运行理论

main()
执行前发生的事
Mach-O 格式
虚拟内存基础
Mach-O 二进制的加载

前言

这篇文章主要是介绍在iOS开发过程中App 启动流程以及优化的问题。这里只涉及main函数之前的优化,对于main函数以后的优化,需要根据业务情况具体分析,这里暂时不作探讨。

另外,本文不是原创文章,内容大多来自:
WWDC 2016 Session 406 Optimizing App Startup Time ,

英文过关的可以直接去看了,本文可以直接略过。

翻译及整理来自优化 App 的启动时间

还有一些也是网上前人总结所得。


App 运行理论

Mach-O 文件的结构

图片 1Mach-O_Format.png

Mach-O 被分为不同的 segment,每个 segment 的大小都是页面大小(page size)的倍数(arm64 环境下 page size = 16KB,其它为 4K)。常见的有 __TEXT, __DATA, __LINKEDIT。通过 otool -tV -d 可以读取对象文件的 __TEXT 段和 __DATA 段的内容:

  1. __TEXT segment 包含 Mach 头文件、代码以及只读常量;
  2. __DATA segment 包含有可读的内容,如全局变量、静态变量等等;
  3. __LINKEDIT segment 包含有如何装载程序的元数据(meta data)。
  • main() 执行前发生的事

  • Mach-O 格式

  • 虚拟内存基础

  • Mach-O 二进制的加载

理论速成

  • Mach-O 术语
    Mach-O 是针对不同运行时可执行文件的文件类型。

  • 文件类型:
    Executable: 应用的主要二进制
    Dylib: 动态链接库(又称 DSO 或 DLL)
    Bundle: 不能被链接的 Dylib,只能在运行时使用 dlopen()
    加载,可当做 macOS 的插件。
    Image: executable,dylib 或 bundleFramework: 包含 Dylib 以及资源文件和头文件的文件夹

  • Mach-O 镜像文件
    Mach-O 被划分成一些 segement,每个 segement 又被划分成一些 section。segment 的名字都是大写的,且空间大小为页的整数。页的大小跟硬件有关,在 arm64 架构一页是 16KB,其余为 4KB。
    section 虽然没有整数倍页大小的限制,但是 section 之间不会有重叠。

  • 几乎所有 Mach-O 都包含这三个段(segment): __TEXT,__DATA
    和 __LINKEDIT:

  • __TEXT
    包含 Mach header,被执行的代码和只读常量(如C 字符串)。只读可执行(r-x)。

  • __DATA
    包含全局变量,静态变量等。可读写(rw-)。

  • __LINKEDIT
    包含了加载程序的『元数据』,比如函数的名称和地址。只读(r–)。

  • Mach-O Universal 文件
    FAT 二进制文件,将多种架构的 Mach-O 文件合并而成。它通过 Fat Header 来记录不同架构在文件中的偏移量,Fat Header 占一页的空间。
    按分页来存储这些 segement 和 header 会浪费空间,但这有利于虚拟内存的实现。

  • 虚拟内存
    虚拟内存就是一层间接寻址(indirection)。软件工程中有句格言就是任何问题都能通过添加一个间接层来解决。虚拟内存解决的是管理所有进程使用物理 RAM 的问题。通过添加间接层来让每个进程使用逻辑地址空间,它可以映射到 RAM 上的某个物理页上。这种映射不是一对一的,逻辑地址可能映射不到 RAM 上,也可能有多个逻辑地址映射到同一个物理 RAM 上。针对第一种情况,当进程要存储逻辑地址内容时会触发 page fault;第二种情况就是多进程共享内存。
    对于文件可以不用一次性读入整个文件,可以使用分页映射(mmap()
    )的方式读取。也就是把文件某个片段映射到进程逻辑内存的某个页上。当某个想要读取的页没有在内存中,就会触发 page fault,内核只会读入那一页,实现文件的懒加载。也就是说 Mach-O 文件中的 __TEXT段可以映射到多个进程,并可以懒加载,且进程之间共享内存。__DATA段是可读写的。这里使用到了 Copy-On-Write 技术,简称 COW。也就是多个进程共享一页内存空间时,一旦有进程要做写操作,它会先将这页内存内容复制一份出来,然后重新映射逻辑地址到新的 RAM 页上。也就是这个进程自己拥有了那页内存的拷贝。这就涉及到了 clean/dirty page 的概念。dirty page 含有进程自己的信息,而 clean page 可以被内核重新生成(重新读磁盘)。所以 dirty page 的代价大于 clean page。

  • Mach-O 镜像 加载
    所以在多个进程加载 Mach-O 镜像时 __TEXT和 __LINKEDIT因为只读,都是可以共享内存的。而 __DATA 因为可读写,就会产生 dirty page。当 dyld 执行结束后,__LINKEDIT 就没用了,对应的内存页会被回收。

  • 安全
    ASLR(Address Space Layout Randomization):地址空间布局随机化,镜像会在随机的地址上加载。这其实是一二十年前的旧技术了。
    代码签名:可能我们认为 Xcode 会把整个文件都做加密 hash 并用做数字签名。其实为了在运行时验证 Mach-O 文件的签名,并不是每次重复读入整个文件,而是把每页内容都生成一个单独的加密散列值,并存储在 __LINKEDIT中。这使得文件每页的内容都能及时被校验确并保不被篡改。

  • 从 exec() 到 main()
    exec() 是一个系统调用。系统内核把应用映射到新的地址空间,且每次起始位置都是随机的(因为使用 ASLR)。并将起始到 0x000000
    这段范围的进程权限都标记为不可读写不可执行。如果是 32 位进程,这个范围至少是 4KB;对于 64 位进程则至少是 4GB。NULL 指针引用和指针截断误差都是会被它捕获。

  • dyld

  • 加载 dylib 文件
    Unix 的前二十年很安逸,因为那时还没有发明动态链接库。有了动态链接库后,一个用于加载链接库的帮助程序被创建。在苹果的平台里是 dyld,其他 Unix 系统也有 ld.so 。 当内核完成映射进程的工作后会将名字为 dyld的Mach-O 文件映射到进程中的随机地址,它将 PC 寄存器设为 dyld 的地址并运行。dyld 在应用进程中运行的工作是加载应用依赖的所有动态链接库,准备好运行所需的一切,它拥有的权限跟应用一样。

  • 下面的步骤构成了 dyld 的时间线:

Load dylibs -> Rebase -> Bind -> ObjC -> Initializers

  • 加载 Dylib
    从主执行文件的 header 获取到需要加载的所依赖动态库列表,而 header 早就被内核映射过。然后它需要找到每个 dylib,然后打开文件读取文件起始位置,确保它是 Mach-O 文件。接着会找到代码签名并将其注册到内核。然后在 dylib 文件的每个 segment 上调用 mmap()。应用所依赖的 dylib 文件可能会再依赖其他 dylib,所以 dyld 所需要加载的是动态库列表一个递归依赖的集合。一般应用会加载 100 到 400 个 dylib 文件,但大部分都是系统 dylib,它们会被预先计算和缓存起来,加载速度很快。

  • Fix-ups
    在加载所有的动态链接库之后,它们只是处在相互独立的状态,需要将它们绑定起来,这就是 Fix-ups。代码签名使得我们不能修改指令,那样就不能让一个 dylib 的调用另一个 dylib。这时需要加很多间接层。现代 code-gen 被叫做动态 PIC(Position Independent Code),意味着代码可以被加载到间接的地址上。当调用发生时,code-gen 实际上会在 __DATA 段中创建一个指向被调用者的指针,然后加载指针并跳转过去。所以 dyld 做的事情就是修正(fix-up)指针和数据。

  • Fix-up 有两种类型,rebasing 和 binding。
    Rebasing 和 Binding
    Rebasing:在镜像内部调整指针的指向Binding:将指针指向镜像外部的内容
    可以通过命令行查看 rebase 和 bind 等信息:

xcrun dyldinfo -rebase -bind -lazy_bind myapp.app/myapp

通过这个命令可以查看所有的 Fix-up。rebase,bind,weak_bind,lazy_bind 都存储在 __LINKEDIT 段中,并可通过 LC_DYLD_INFO_ONLY 查看各种信息的偏移量和大小。建议用 MachOView 查看更加方便直观。从 dyld
源码层面简要介绍下 Rebasing 和 Binding 的流程。

ImageLoader是一个用于加载可执行文件的基类,它负责链接镜像,但不关心具体文件格式,因为这些都交给子类去实现。每个可执行文件都会对应一个 ImageLoader 实例。ImageLoaderMachO 是用于加载 Mach-O 格式文件的 ImageLoader 子类,而 ImageLoaderMachOClassic 和 ImageLoaderMachOCompressed 都继承于 ImageLoaderMachO,分别用于加载那些 __LINKEDIT 段为传统格式和压缩格式的 Mach-O 文件。
因为 dylib 之间有依赖关系,所以 ImageLoader 中的好多操作都是沿着依赖链递归操作的,Rebasing 和 Binding 也不例外,分别对应着 recursiveRebase() 和 recursiveBind() 这两个方法。因为是递归,所以会自底向上地分别调用 doRebase() 和 doBind() 方法,这样被依赖的 dylib 总是先于依赖它的 dylib 执行 Rebasing 和 Binding。传入 doRebase() 和 doBind() 的参数包含一个 LinkContext 上下文,存储了可执行文件的一堆状态和相关的函数。
在 Rebasing 和 Binding 前会判断是否已经 Prebinding。如果已经进行过预绑定(Prebinding),那就不需要 Rebasing 和 Binding 这些 Fix-up 流程了,因为已经在预先绑定的地址加载好了。

ImageLoaderMachO 实例不使用预绑定会有四个原因:
Mach-O Header 中 MH_PREBOUND 标志位为 0

镜像加载地址有偏移(这个后面会讲到)
依赖的库有变化
镜像使用 flat-namespace,预绑定的一部分会被忽略
LinkContext 的环境变量禁止了预绑定

ImageLoaderMachO 中 doRebase() 做的事情大致如下:
如果使用预绑定,fgImagesWithUsedPrebinding
计数加一,并 return;否则进入第二步
如果 MH_PREBOUND 标志位为 1(也就是可以预绑定但没使用),且镜像在共享内存中,重置上下文中所有的 lazy pointer。(如果镜像在共享内存中,稍后会在 Binding 过程中绑定,所以无需重置)
如果镜像加载地址偏移量为0,则无需 Rebasing,直接 return;否则进入第四步调用 rebase() 方法,这才是真正做 Rebasing 工作的方法。如果开启 TEXT_RELOC_SUPPORT 宏,会允许 rebase() 方法对 __TEXT 段做写操作来对其进行 Fix-up。所以其实 __TEXT 只读属性并不是绝对的。

ImageLoaderMachOClassic 和 ImageLoaderMachOCompressed 分别实现了自己的 doRebase() 方法。实现逻辑大同小异,同样会判断是否使用预绑定,并在真正的 Binding 工作时判断 TEXT_RELOC_SUPPORT 宏来决定是否对 __TEXT 段做写操作。最后都会调用 setupLazyPointerHandler 在镜像中设置 dyld 的 entry point,放在最后调用是为了让主可执行文件设置好 __dyld 或 __program_vars。

  • Rebasing
    在过去,会把 dylib 加载到指定地址,所有指针和数据对于代码来说都是对的,dyld 就无需做任何 fix-up 了。如今用了 ASLR 后悔将 dylib 加载到新的随机地址(actual_address),这个随机的地址跟代码和数据指向的旧地址(preferred_address)会有偏差,dyld
    需要修正这个偏差(slide),做法就是将 dylib 内部的指针地址都加上这个偏移量,偏移量的计算方法如下:

Slide = actual_address - preferred_address

然后就是重复不断地对 __DATA 段中需要 rebase 的指针加上这个偏移量。这就又涉及到 page fault 和 COW。这可能会产生 I/O 瓶颈,但因为 rebase 的顺序是按地址排列的,所以从内核的角度来看这是个有次序的任务,它会预先读入数据,减少 I/O 消耗。

  • Binding
    Binding 是处理那些指向 dylib 外部的指针,它们实际上被符号(symbol)名称绑定,也就是个字符串。之前提到 __LINKEDIT
    段中也存储了需要 bind 的指针,以及指针需要指向的符号。dyld
    需要找到 symbol 对应的实现,这需要很多计算,去符号表里查找。找到后会将内容存储到 __DATA 段中的那个指针中。Binding 看起来计算量比 Rebasing 更大,但其实需要的 I/O 操作很少,因为之前 Rebasing 已经替 Binding 做过了。

  • ObjC Runtime
    Objective-C 中有很多数据结构都是靠 Rebasing 和 Binding 来(fix-up)的,比如 Class 中指向超类的指针和指向方法的指针。
    ObjC 是个动态语言,可以用类的名字来实例化一个类的对象。这意味着 ObjC Runtime 需要维护一张映射类名与类的全局表。当加载一个 dylib 时,其定义的所有的类都需要被注册到这个全局表中。
    C++ 中有个问题叫做易碎的基类(fragile base class)。ObjC 就没有这个问题,因为会在加载时通过 fix-up 动态类中改变实例变量的偏移量。
    在 ObjC 中可以通过定义类别(Category)的方式改变一个类的方法。有时你想要添加方法的类在另一个 dylib 中,而不在你的镜像中(也就是对系统或别人的类动刀),这时也需要做些 fix-up。
    ObjC 中的 selector 必须是唯一的。

  • Initializers
    C++ 会为静态创建的对象生成初始化器。而在 ObjC 中有个叫 +load
    的方法,然而它被废弃了,现在建议使用 +initialize。对比详见:http://stackoverflow.com/questions/13326435/nsobject-load-and-initialize-what-do-they-do
    现在有了主执行文件,一堆 dylib,其依赖关系构成了一张巨大的有向图,那么执行初始化器的顺序是什么?自顶向上!按照依赖关系,先加载叶子节点,然后逐步向上加载中间节点,直至最后加载根节点。这种加载顺序确保了安全性,加载某个 dylib 前,其所依赖的其余 dylib 文件肯定已经被预先加载。

  • 最后 dyld 会调用 main() 函数。main() 会调用 UIApplicationMain()。

首先是WWDC 2016的Session:

一张图看完这个Session

图片 2

image.png

main()执行前发生的事

Mach-O 格式

虚拟内存基础

Mach-O 二进制的加载

Universal Binary

图片 3Univseral_Files.png

Universal Binary 最早在 WWDC 2005 上被提出,目的是帮助 OS X 上的应用从基于 PowerPC 架构到 Intel 架构的转变。即可执行文件中包含着多种指令集,不同的系统可以根据 Mach-O 上的 Fat Header 上的信息选择执行相应的指令,副作用是使得可执行文件的体积增大。

比如将 Build Setting -> Valid Architectures 中的 armv7 和 armv7s 删除,仅剩 arm64,那么导出的可执行文件的体积会小很多(除非应用仅支持64位处理器,否则不要这么做)。

图片 4Size_Cmp_Diff_Arch.png

虚拟内存是一种将进程虚拟地址空间映射到物理地址的一种机制。使用虚拟地址的好处是使得程序空间独立。一般的操作系统在实现虚拟内存的时候,都会将内存空间划分为页,通过页表(page table)去管理虚拟页和物理页之间的映射关系和 page-in & page-out 。

理论速成

改善启动时间

从点击 App 图标到加载 App 闪屏之间会有个动画,我们希望 App 启动速度比这个动画更快。虽然不同设备上 App 启动速度不一样,但启动时间最好控制在 400ms。需要注意的是启动时间一旦超过 20s,系统会认为发生了死循环并杀掉 App 进程。当然启动时间最好以 App 所支持的最低配置设备为准。直到 applicationWillFinishLaunching
被调动,App 才启动结束。

概念

在开始之前需要了解几个名词和概念 Mach-O虚拟内存

理论速成

ASLR

地址空间布局随机化(Address Space Layout Randomization),即将可执行文件、动态库等文件随机地装载到内存的某个地址中防止缓冲区溢出攻击。

Mach-O 术语

测量启动时间

Warm launch: App 和数据已经在内存中Cold launch: App 不在内核缓冲存储器中冷启动(Cold launch)耗时才是我们需要测量的重要数据,为了准确测量冷启动耗时,测量前需要重启设备。在 main()
方法执行前测量是很难的,好在 dyld
提供了内建的测量方法:

在 Xcode 中 Edit scheme -> Run -> Auguments 将环境变量 DYLD_PRINT_STATISTICS 设为 1
。控制台输出的内容如下:
Total pre-main time: 228.41 milliseconds (100.0%) dylib loading time: 82.35 milliseconds (36.0%) rebase/binding time: 6.12 milliseconds (2.6%) ObjC setup time: 7.82 milliseconds (3.4%) initializer time: 132.02 milliseconds (57.8%) slowest intializers : libSystem.B.dylib : 122.07 milliseconds (53.4%) CoreFoundation : 5.59 milliseconds (2.4%)

Mach-O

Mach-O is a bunch of file types for different run time executables.

这里说的很笼统了,Mach-O 是一组针对不同的运行时可执行文件的文件类型。
通过其他资料可以查到 Mach-O的概念:
维基百科 的简单描述:

Mach-O 是 Mach object 文件格式的缩写,它是一种用于记录可执行文件、对象代码、共享库、动态加载代码和内存转储的文件格式。作为 a.out 格式的替代品,Mach-O 提供了更好的扩展性,并提升了符号表中信息的访问速度。
大多数基于 Mach 内核的操作系统都使用 Mach-O。NeXTSTEP、OS X 和 iOS 是使用这种格式作为本地可执行文件、库和对象代码的例子。

Mach-O 包含三个基本区域(苹果的文档):

  • 头部(Header)。
    指定文件的目标架构,如PPC,ppc64,IA-32,或x86-64。

  • 加载命令(Load commands)。
    指定文件的逻辑结构和文件在虚拟内存中的布局。

  • 原始段数据(Raw segment data)。
    包含加载命令中定义的段的原始数据。

图片 5

image.png

对应具体的 iOS平台,现在需要了解的有如下几种File Types:

Executable—Main binary for application
应用的主要二进制

Dylib—Dynamic library (aka DSO or DLL)
动态链接库(又称 DSO 或 DLL)

Bundle—Dylib that cannot be linked, only dlopen(), e.g. plug-ins
不能被链接的 Dylib,只能在运行时使用 dlopen() 加载,可当做 macOS 的插件。

Image—An executable, dylib, or bundle
executable,dylib 或 bundle

Framework—Dylib with directory for resources and headers
包含 Dylib 以及资源文件和头文件的文件夹

Mach-O 术语

Code Signing

每一个页的内容都被加密散列,散列值存放在 __LINKEDIT segment 中。在 page-in 的时候会检验内容的正确性,这意味着程序的指令不会被修改。

在执行 main() 函数之前,大概经过了这些步骤:

  • 运行辅助程序 dyld(与进程在同一地址空间);
  • dyld 装载所有程序依赖的动态库;
  • dyld 修正 __DATA segment 内的数据指针;
  • 调用所有 initializers.

Mach-O 是针对不同运行时可执行文件的文件类型。

优化启动时间

可以针对 App 启动前的每个步骤进行相应的优化工作。

加载 Dylib之前提到过加载系统的 dylib 很快,因为有优化。但加载内嵌(embedded)的 dylib 文件很占时间,所以尽可能把多个内嵌 dylib 合并成一个来加载,或者使用 static archive。使用 dlopen() 来在运行时懒加载是不建议的,这么做可能会带来一些问题,并且总的开销更大。

Rebase/Binding
之前提过 Rebaing 消耗了大量时间在 I/O 上,而在之后的 Binding 就不怎么需要 I/O 了,而是将时间耗费在计算上。所以这两个步骤的耗时是混在一起的。之前说过可以从查看 __DATA 段中需要修正(fix-up)的指针,所以减少指针数量才会减少这部分工作的耗时。对于 ObjC 来说就是减少 Class,selector 和 category 这些元数据的数量。从编码原则和设计模式之类的理论都会鼓励大家多写精致短小的类和方法,并将每部分方法独立出一个类别,其实这会增加启动时间。对于 C++ 来说需要减少虚方法,因为虚方法会创建 vtable,这也会在 __DATA 段中创建结构。虽然 C++ 虚方法对启动耗时的增加要比 ObjC 元数据要少,但依然不可忽视。最后推荐使用 Swift 结构体,它需要 fix-up 的内容较少。

ObjC Setup
针对这步所能事情很少,几乎都靠 Rebasing 和 Binding 步骤中减少所需 fix-up 内容。因为前面的工作也会使得这步耗时减少。

Initializer
显式初始化
使用 +initialize 来替代 +load

不要使用 atribute((constructor)) 将方法显式标记为初始化器,而是让初始化方法调用时才执行。比如使用 dispatch_once(),pthread_once() 或 std::once()。也就是在第一次使用时才初始化,推迟了一部分工作耗时。

隐式初始化
对于带有复杂(non-trivial)构造器的 C++ 静态变量:
在调用的地方使用初始化器。
只用简单值类型赋值(POD:Plain Old Data),这样静态链接器会预先计算 __DATA 中的数据,无需再进行 fix-up 工作。
使用编译器 warning 标志 -Wglobal-constructors 来发现隐式初始化代码。
使用 Swift 重写代码,因为 Swift 已经预先处理好了,强力推荐。

不要在初始化方法中调用 dlopen(),对性能有影响。因为 dyld 在 App 开始前运行,由于此时是单线程运行所以系统会取消加锁,但 dlopen() 开启了多线程,系统不得不加锁,这就严重影响了性能,还可能会造成死锁以及产生未知的后果。所以也不要在初始化器中创建线程。

Mach-O 镜像文件:

图片 6

image.png

Mach-O 被划分成一些 segement,每个 segement 又被划分成一些 section。
segment 的名字都是大写的,每一段且的空间大小为页的倍数。页的大小由硬件决定,在 arm64 架构一页是 16KB,其余为 4KB。
上图中一共分为3页, __DATA 和 __LINKEDIT 也是各占一页。

section是segment的一部分,没有整数倍页大小的限制,但是 section 之间不会有重叠。

现在来看三个最常见的segment:__TEXT,__DATA 和 __LINKEDIT,几乎所有 Mach-O 都包含这三个段:

- __TEXT 包含 Mach-O header,机器指令和只读常量(如C 字符串)。只读可执行(r-x)。
- __DATA 包含所有的全局变量。可读写(rw-)。
- __LINKEDIT 包含了加载程序的元数据,比如函数的名称和地址。只读(r–)。

Mach-O 是针对不同运行时可执行文件的文件类型。

文件类型:

Executable: 应用的主要二进制

Dylib: 动态链接库(又称 DSO 或 DLL)

Bundle: 不能被链接的 Dylib,只能在运行时使用dlopen()加载,可当做 macOS 的插件。

Image: executable,dylib 或 bundle

Framework: 包含 Dylib 以及资源文件和头文件的文件夹

Loading Dylibs :

动态库(或者叫共享库,通常是 .so .dylib 作为后缀)是一种可以在程序在运行时装载的目标文件。使用动态库可以有效减少代码的体积,提高代码的复用率。下面是 iOS 在运行前加载动态库的过程:

  1. 解析程序所依赖的 dylibs;
  2. 找到所需的 Mach-O 文件;
  3. 打开并读取文件;
  4. 校验 Mach-O 文件;
  5. 向内核注册代码签名;
  6. 对每个 segment 调用 mmap(),将目标文件映射到内存中。

上面的这个过程是递归进行的,因为一个动态库可能还依赖着另一个动态库。

文件类型:

Reference

http://yulingtianxia.com/blog/2016/10/30/Optimizing-App-Startup-Time
/https://developer.apple.com/videos/play/wwdc2016/406/

Mach-O Universal Files

Mach-O通用文件,将多种架构的 Mach-O 文件合并而成。它通过 header 来记录不同架构在文件中的偏移量,segement 占多个分页,header 占一页的空间。可能有人会觉得 header 单独占一页会浪费空间,但这有利于虚拟内存的实现。

Mach-O 镜像文件

Rebasing

Rebasing 的工作是改变 dylibs 或者 bundles 的基地址。基地址是 image(dylib 或者 bundle)在被装载时优先选择的地址,被编码到 __LINKEDIT 中,默认为零。在运行时,如果基地址范围被占用了,那么 dyld 会将这个 image 装载到一个新的地址空间去(内容来源于 rebase 的 manual,在 terminal 里敲入 man rebase 就能看到)。

  • Executable: 应用的主要二进制

  • Dylib: 动态链接库(又称 DSO 或 DLL)

  • Bundle: 不能被链接的 Dylib,只能在运行时使用 dlopen() 加载,可当做 macOS 的插件。

虚拟内存

虚拟内存就是一层间接寻址(indirection)。软件工程中有句格言就是任何问题都能通过添加一个间接层来解决。虚拟内存解决的是管理所有进程使用物理 RAM 的问题。通过添加间接层来让每个进程使用逻辑地址空间,它可以映射到 RAM 上的某个物理页上。这种映射不是一对一的,逻辑地址可能映射不到 RAM 上,也可能有多个逻辑地址映射到同一个物理 RAM 上。针对第一种情况,当进程要存储逻辑地址内容时会触发 page fault;第二种情况就是多进程共享内存。
对于文件可以不用一次性读入整个文件,可以使用分页映射(mmap())的方式读取。也就是把文件某个片段映射到进程逻辑内存的某个页上。当某个想要读取的页没有在内存中,就会触发 page fault,内核只会读入那一页,实现文件的懒加载。
也就是说 Mach-O 文件中的 __TEXT 段可以映射到多个进程,并可以懒加载,且进程之间共享内存。__DATA 段是可读写的。这里使用到了 Copy-On-Write 技术,简称 COW。也就是多个进程共享一页内存空间时,一旦有进程要做写操作,它会先将这页内存内容复制一份出来,然后重新映射逻辑地址到新的 RAM 页上。也就是这个进程自己拥有了那页内存的拷贝。这就涉及到了 clean/dirty page 的概念。dirty page 含有进程自己的信息,而 clean page 可以被内核重新生成(重新读磁盘)。所以 dirty page 的代价大于 clean page。

所以在多个进程加载 Mach-O 镜像时 __TEXT 和 __LINKEDIT 因为只读,都是可以共享内存的,读取速度就会很快。而 __DATA 因为可读写,就有可能会产生 dirty page,如果检测到有clean page 就可以直接使用,反之就需要重新读取 DATA page。一旦产生了dirty page,当 dyld 执行结束后,__LINKEDIT 需要通知内核当前页面不再需要了,当别人需要的使用时候就可以重新clean 这些页面。

Mach-O 被划分成一些 segement,每个 segement 又被划分成一些 section。

segment 的名字都是大写的,且空间大小为页的整数。页的大小跟硬件有关,在 arm64 架构一页是 16KB,其余为 4KB。

section 虽然没有整数倍页大小的限制,但是 section 之间不会有重叠。

几乎所有 Mach-O 都包含这三个段(segment):

__TEXT,__DATA和__LINKEDIT.

__TEXT包含 Mach header,被执行的代码和只读常量(如C 字符串)。只读可执行(r-x)。

__DATA包含全局变量,静态变量等。可读写(rw-)。

__LINKEDIT包含了加载程序的『元数据』,比如函数的名称和地址。只读(r–)。

Binding

不同于 rebasing(修正 dylib 内部每个指向 image 内部的地址),binding 是要修正所有指向其它 dylib 指针的值。比如说在程序中调用 malloc 函数,dyld 需要在共享库中找到 malloc 这个符号对应的子程序的地址然后修改调用程序中的指针。

这里有个命令可以查看可执行文件中的 dyld 信息:xcrun dyldinfo -rebase -bind -lazy_bind YourExecutableFile

Image: executable,dylib 或 bundle
Framework: 包含 Dylib 以及资源文件和头文件的文件夹

ASLR

  • ASLR(Address Space Layout Randomization):地址空间布局随机化,镜像会在随机的地址上加载。这其实是一二十年前的旧技术了。
  • 代码签名:可能我们认为 Xcode 会把整个文件都做加密 hash 并用做数字签名。其实为了在运行时验证 Mach-O 文件的签名,并不是每次重复读入整个文件,而是把每页内容都生成一个单独的加密散列值,并存储在 __LINKEDIT 中。这使得文件每页的内容都能及时被校验确并保不被篡改。

基本概念到此为止,下面开始进入正题:

Mach-O Universal 文件

Notify ObjC Runtime

在 rebasing 和 binding 结束之后,ObjC runtime 初始化,接着通过 class_createInstance()注册类到 runtime 中。类的成员变量的偏移量随之更新,category 上的方法也被插入到方法列表中。

Mach-O 镜像文件

App启动流程

  • Exec

Exec is a system call. When you trap into the kernel, you basically say I want to replace this process with this new program.

exec() 是一个系统调用。系统内核把应用映射到新的地址空间,且每次起始位置都是随机的(因为使用 ASLR)。并将起始位置到 0x000000 这段范围的进程权限都标记为不可读写不可执行。如果是 32 位进程,这个范围至少是 4KB;对于 64 位进程则至少是 4GB。NULL 指针引用和指针截断误差都是会被它捕获。

  • dyld

Unix 的前二十年很安逸,因为那时还没有发明动态链接库。有了动态链接库后,一个用于加载链接库的帮助程序被创建。在苹果的平台里是 dyld,其他 Unix 系统也有 ld.so。 当内核完成映射进程的工作后会将名字为 dyld 的Mach-O 文件映射到进程中的随机地址,它将 PC 寄存器设为 dyld 的地址并运行。dyld 在应用进程中运行的工作是加载应用依赖的所有动态链接库,准备好运行所需的一切,它拥有的权限跟应用一样。

加载 Dylib
从主执行文件的 header 获取到需要加载的所依赖动态库列表,而 header 早就被内核映射过。然后它需要找到每个 dylib,然后打开文件读取文件起始位置,确保它是 Mach-O 文件。接着会找到代码签名并将其注册到内核。然后在 dylib 文件的每个 segment 上调用 mmap()。应用所依赖的 dylib 文件可能会再依赖其他 dylib,所以 dyld 所需要加载的是动态库列表一个递归依赖的集合。一般应用会加载 100 到 400 个 dylib 文件,但大部分都是系统 dylib,它们会被预先计算和缓存起来,加载速度很快。

下面的步骤构成了 dyld 的时间线:

FAT 二进制文件,将多种架构的 Mach-O 文件合并而成。它通过 Fat Header 来记录不同架构在文件中的偏移量,Fat Header 占一页的空间。

按分页来存储这些 segement 和 header 会浪费空间,但这有利于虚拟内存的实现。

Initializers

之后调用 ObjC 中类的 +load 方法。视频中 Nick Kledzik 说 +load 这个方法已经 deprecated 了,不建议使用,但是去 NSObject Class Reference 看,并没有对此做了什么标记。接着调用 __attribute__((constructor)) 修饰的函数,然后就是对 C++ 中全局对象进行初始化(如下图,Person 的构造函数先于程序的 main() 函数的调用)。

图片 7static_cpp_object.png

至此可知,在 main() 函数之前,我们可以通过减少库的数目、减少类的数目、不在+load里面做过多的事情等手段,加快应用的启动速度。在 main() 函数之后,就是要求主线程在 -application:didFinishLaunchingWithOptions: 中尽快返回。

上述的每一个步骤都可以展开很多内容来讲,这里推荐一些比较厉害的博客,sunnyxx 的《iOS 程序 main 函数之前发生了什么》 还有 mikeash.com 上的《Friday Q&A 2012-11-09: dyld: Dynamic Linking On OS X》。

我才不会说我把这个视频的字幕翻译了一遍才勉强看得懂咧……

本文由必威发布于必威-编程,转载请注明出处:这些文件运行在那些基于,App 的启动时间

TAG标签:
Ctrl+D 将本页面保存为书签,全面了解最新资讯,方便快捷。