共计 7541 个字符,预计需要花费 19 分钟才能阅读完成。
App 运行实践
编译器做了什么
预处理
- 符号化 (Tokenization)
- 宏定义的开展
#include
的开展
语法和语义剖析
- 将符号化后的内容转化为一棵解析树 (parse tree)
- 解析树做语义剖析
- 输入一棵_形象语法树_(Abstract Syntax Tree* (AST))
生成代码和优化
- 将 AST 转换为更低级的两头码 (LLVM IR)
- 对生成的两头码做优化
- 生成特定指标代码
- 输入汇编代码
汇编器
- 将汇编代码转换为指标对象文件。
链接器
- 将多个指标对象文件合并为一个可执行文件 (或者一个动静库)
所以总的流程以流程图来示意的话如下图所示
main()
执行前产生的事- Mach-O 格局
- 虚拟内存根底
- Mach-O 二进制的加载
实践速成
Mach-O 术语
Mach-O 是针对不同运行时可执行文件的文件类型。
文件类型:
- Executable:利用的次要二进制
- Dylib:动态链接库(又称 DSO 或 DLL)
- Bundle:不能被链接的 Dylib,只能在运行时应用
dlopen()
加载,可当做 macOS 的插件。
Image:executable,dylib 或 bundle
Framework:蕴含 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 指针援用和指针截断误差都是会被它捕捉。
加载 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
。比照详见:比照
当初有了主执行文件,一堆 dylib,其依赖关系形成了一张微小的有向图,那么执行初始化器的程序是什么?自底向上!依照依赖关系,先加载叶子节点,而后逐渐向上加载两头节点,直至最初加载根节点。这种加载程序确保了安全性,加载某个 dylib 前,其所依赖的其余 dylib 文件必定曾经被事后加载。
最初 dyld
会调用 main()
函数。main()
会调用 UIApplicationMain()
。
改善启动工夫
从点击 App 图标到加载 App 闪屏之间会有个动画,咱们心愿 App 启动速度比这个动画更快。尽管不同设施上 App 启动速度不一样,但启动工夫最好管制在 400ms。须要留神的是启动工夫一旦超过 20s,零碎会认为产生了死循环并杀掉 App 过程。当然启动工夫最好以 App 所反对的最低配置设施为准。直到 applicationWillFinishLaunching
被调动,App 才启动完结。
测量启动工夫
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%) |
优化启动工夫
能够针对 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()
开启了多线程,零碎不得不加锁,这就重大影响了性能,还可能会造成死锁以及产生未知的结果。所以也不要在初始化器中创立线程。
参考文章