本文分为理论【1-4】和实践【5-6】两部分:
-
main()
函数之前发生了什么 -
Mach-O
格式 - 虚拟内存基础知识
- 如何加载和准备
Mach-O
二进制文件 - 如何测量启动时间
- 优化启动时间
一、Mach-O
文件
Mach-O
是运行时可执行文件的文件类型。
(一)Mach-O
的文件类型
- 可执行文件:它是应用程序中最重要的二进制文件,也是应用扩展文件的主二进制文件。
- 动态库【Dylib】:它是一个动态库,在其他平台上又称为DSO或DLL,
- 捆绑包【Bundle】:它是一种特殊的动态库,无法进行链接,只能在运行时使用
dlopen()
函数打开它。Mac OS的插件会用到。
图像【Image】:它是指可执行文件,动态库或捆绑包的任意一种类型;
框架【Framework】:它是一种带有资源和标头目录的动态库,存储该动态库需要的文件。
函数定义:
void * dlopen( const char * pathname, int mode );
函数描述:
dlopen函数以指定模式打开指定的动态连接库文件,并返回一个句柄给调用进程。使用dlclose()来卸载打开的库。
mode:分为这两种
RTLD_LAZY 暂缓决定,等有需要时再解出符号
RTLD_NOW 立即决定,返回前解除所有未决定的符号。
RTLD_LOCAL
RTLD_GLOBAL 允许导出符号
RTLD_GROUP
RTLD_WORLD
返回值:
打开错误返回NULL
成功,返回库引用
编译时候要加入 -ldl (指定dl库)
(二)Mach-O
图像格式
- 段
-
Mach-O
图像被分成数段; - 所有的段名都由大写字母组成;
- 每一段都是页面大小的倍数,而页面大小由硬件决定,
arm64
处理器的页面大小是16KB,其他都是4KB;
下例中TEXT
段大小是3页,DATA
和LINKEDIT
段大小都是1页。
最常见的段名是TEXT
,DATA
和LINKEDIT
。实际上几乎每一个二进制文件都包含这三段,你可以添加自定义段,但一般不会给它赋值。
TEXT
,DATA
和LINKEDIT
段的作用:
-
TEXT
:它是文件的开头,包含了Mach的头文件,任何机器指令以及任何只读常量,比如C字符串。 -
DATA
:它是重写段,它包含了所有的全局变量。 -
LINKEDIT
:它不包含全局变量的函数,它包含变量函数信息,比如名称和地址
- 分区
- 分区是段的子范围;
- 分区不用遵循页面的大小;
- 分区的名称都用小写字母表示;
(三)Mach-O
通用文件
假设我们生成一个64位的iOS应用,现在我们有一个Mach-O
文件。当我们也想让它在32位的设备上运行,Xcode中会发生什么变化呢?
当我们重新生成时,Xcode会生成另一个单独的Mach-O
文件,这个是为32位生成的armv7
。然后这两个文件合并成第三个文件,这个文件叫作Mach-O
通用文件。它前端有一个头文件,所有的头文件都有一个所有体系结构的列表,它们的偏移值也在文件里。该头文件也是一个页面的大小。
通过上面我们知道,Mach-O
图像的每段都是页面大小的倍数,而且头文件也需要一个页面的大小,这样会浪费很多空间。那为何还要这样做呢?这就涉及到虚拟内存。
二、虚拟内存
在软件工程里有句格言,任何问题都可以通过添加一个间接层加以解决。而虚拟内存所解决的问题就是,所有这些进程存在时该如何管理所有的物理内存。为了解决这个问题,添加了一个小的间接层,每个进程都是一个逻辑地址空间,映射到RAM的某个物理页面,这种映射不一定是一对一的。逻辑地址可以不对应任何物理RAM,也可以多个逻辑地址对应同一个物理RAM,这样带来很多中可能。那能利用虚拟内存做什么呢?
首先如果有一个逻辑地址不映射任何物理RAM,当进程要访问该地址时就会产生页面错误,内核将停止该线程,并试图找出解决方案。
下一点是如果有两个进程,对应两个逻辑地址,这两个逻辑地址映射同一个物理页面,这两个进程共享相同的RAM位,进程之间开始共享。
另一个有趣的功能是基于文件的映射,不用把整个文件读入RAM,而是可以调用mmap()
函数告诉虚拟内存系统,我想把这部分文件映射到进程里的这段地址。这么做的原因是,不用读取整个文件,通过设置该映射,第一次访问这些不同的地址时,如同已经在内存里读过,每次访问未访问过的地址时,都会导致页面错误,内核会读该错误页面。这样将会造成读取文件迟缓。
现在我们结合前面讲的关于Mach-O
的内容,可以知道任何Dylib
和图像的TEXT
段都可以映射到多个进程,这将会造成读取迟缓,而这些页面可以在进程间共享。那么DATA
段呢?
DATA
用来读写,有一个策略叫写入时复制,这和Apple文件系统的克隆很相似。写入时复制所做的就是它积极地在所有进程里共享DATA
页面。一个进程会发生什么,只要它们只是从共享内容的全局变量中读取就可以了。但是一旦有进程想要写入其他DATA
页面,就会发生写入时复制。
写入时复制使内核把该页面复制到另一个物理RAM中,并将映射重定向到该页面。所以该进程有了该页面的副本。这会给我们带来脏页面和净页面,而副本被认为是脏页面。脏页面是指含有进程的特定信息。净页面是指内核可以按照需要重新建立的页面,比如重新读取磁盘。所以脏页面比净页面要昂贵很多。
最后一点是页面也有权限界限,这指的是可以标记一个页面可读、可写或可执行、或者它们的任意组合。
虚拟内存的作用:
1. 虚拟内存是间接层
2. 将每个进程的地址映射到物理RAM(页面粒度)
虚拟内存的特征:
1. 页面错误
2. 相同的RAM页面出现在多个进程中
3. 文件支持的页面
3.1 mmap()
3.2 懒读取
4. 写入时复制(COW)
5. 脏页面与干净页面
6. 权限:rwx
以上就是Mach-O
格式和虚拟内存的内容。现在看看它们是如何一起工作的,在之前,我们先看看Dyld(全称the dynamic link editor,即动态链接器,其本质Mach-O文件,专门用来加载动态库的库)
是如何操作的,它在Mach-O
和虚拟内存之间是如何映射的。
三、Dyld
的操作过程
现有一个Dylib
文件,如下图所示:
我们没有把它读到内存中,而是把它映射到内存,所以在内存里该Dylib
文件本应该占用8个页面。可以看到,不同的是有这些“全零填充”。
大部分全局变量的初始值都是零,所以静态链接器进行了优化,把所有值为0的全局变量都移到了尾端,然后不占用任何磁盘空间。取而代之,我们利用虚拟内存的特性,在该页面第一次被访问时,告诉虚拟内存把它填满0。所以它不需要读取。Dyld
必须要做的第一件事是在内存中查看该进程的Mach
头文件。它将查看内存的顶盒,此时那里是空的,没有内容映射到物理页面上,所以产生页面错误。到那时内核意识到它被映射到了一个文件,所以它将读取文件的第一页,将其放入物理RAM,设置其映射。
现在Dyld
可以真正通过Mach
头文件开始读取。它通过读取Mach
头文件,Mach
头文件让Dyld
到LINKEDIT
段上查看这条信息。再一次,Dyld
跳下去查看进程1的底盒。这又会产生页面错误,内核又读入RAM的另一个LINKEDIT
的物理页面。
Dyld
现在可以期望一个LINKEDIT
。此刻在进程中,LINKEDIT
将会告诉Dyld
对DATA
页面做一些修正,让Dylib
可运行。所以同样的事情又发生了,Dyld
现从DATA
页面读取数据,但是有一点不同,Dyld
想要写回一些内容修改DATA
页面,此刻写入时复制出现了。这个页面变成了脏页面。所以脏RAM的8个页面将会是什么?若我只用malloc()
函数分配8页内存,然后读了一些内容进去,我将会有8个页面的脏RAM。但现在我只有1页的脏RAM和2个净页面。
如果第二个进程加载同一个Dylib
将会发生什么?在第二个进程里,Dyld
会经历相同的步骤,首先它查看Mach
头文件,但内核在RAM某处已经有这页了,所以内核只是简单地把映射重定向,重利用该页面,并没有任何IO操作。LINKEDIT
也是如此,更加快速。我们来看DATA
页面,此时内核必须要看看在DATA
页面,干净的副本是否还存在RAM其他地方,如果还在,就可以重利用;如果不在,就必须要重新读取。
在该进程中,Dyld
会让RAM变脏。
最后一步是LINKEDIT
,只在Dyld
进行操作时被需要。所以它可以提醒内核,当它完成时,它不再需要这些LINKEDIT
页面,当别人需要RAM时,可以回收它们。现在有两个进程在共享这些Dylib
,每个进程都本应该有8个页面,也就是一共有16个脏页面。但现在我们只有2个脏页面和1个干净的、共享页面。
以上讲了Dyld
如何将Mach-O
映射到虚拟内存中,下面我们看看安全如何影响Dyld
的。
四、安全
有两点安全问题会影响到Dyld
:
-
ASLR
地址空间布局随机化
这是20年前的旧技术,基本概念是把加载地址随机化。 - 代码签名
在Xcode中,代码签名是指对整个文件运行一个加密哈希算法,然后在文件上签名。为了在运行时进行验证,整个文件都必须要重新读取。所以在编译阶段,我们让Mach-O
文件的每一个页面都进行自己的加密哈希算法,所有哈希都存储在LINKEDIT
里。这使得你的每个未被修改的页面在被读取的过程中都能得到及时验证。
现在我们来研究从exec()
到main()
五、exec()
exec()
是一个系统调用函数,它用新程序替换当前进程中的程序。当进你入内核,想把这个进程换成这个新程序时:
首先内核会抹去整个地址,映射到你指定的可执行程序。ASLR
把它映射到一个随机地址。
下一步是从该随机地址回溯到零地址,把整个区域标记为不可访问,意思是指不可读、不可写、不可执行。该区域在32位处理器下至少4KB大小,64位处理器下至少4GB大小。这样可以捕捉任何空指针引用,捕捉任何指针截断。
六、关于Dylibs
Unix诞生的前几十年,一切都很简单,我只需映射一个程序,把指针引用指向它,开始运行它即可。然后共享库被发明出来。那么谁来加载Dylibs
呢?人们很快意识到情况太过复杂,不想让内核做这件事。所以人们新建了帮助程序,在我们的平台上叫作Dyld
,在其他Unix平台又叫作LD.SO
。
因此当内核完成进程的映射时,它现在将另一个名为Dyld
的Mach-O
文件映射到另一个随机地址的进程中。把PC指向Dyld
,让Dyld
完成进程的启动。现在Dyld
在运行进程,它的工作是加载所有依赖的动态库,让它们完全准备好开始运行。
七、Dyld
步骤
让我们来浏览这些步骤,底部有很多步骤和一个时间线,我们浏览这些的时候,也会浏览时间线。
- Map all dependent dylibs, recurse Rebase all images
- Bind all images
- ObjC prepare images
- Run initializers
(一) 加载动态库
首先Dyld
是否需要映射所有依赖的动态库?什么是依赖的动态库?
要找到它们,首先要读取内核中已经映射好的主可执行文件的头部,在该头文件中是一个所有依赖库的列表。因此必须将其解析出来。所以必须要找到每一个动态库。一旦找到每个动态库,必须打开并运行每个文件的开头,需要确保是这是一个Mach-O
文件,对它进行验证,找到它的编码签名,将这个编码签名注册到内核中。
然后它可以在这个动态库中的每一段调用mmap()
函数
总结:
- 解析依赖的动态库列表;
- 找到必须的`Mach-O`文件;
- 打开并读取文件的开头;
- 验证`Mach-O`文件;
- 注册代码签名;
- 为每一段调用`mmap()`函数;
(二) 递归加载
假如你的应用依赖A.dylib
和B.dylib
两个动态库,而A.dylib
和B.dylib
自身也可能依赖其他dylib
。所以Dyld
必须为每一个dylib
再做一次同样的事,而每个dylib
可能依赖于已经加载的东西或新的东西,所以Dyld
必须确定它是否已经被加载,如果没有被加载,Dyld
需要加载它。所以如此继续这种操作,最终所有依赖的都被加载了。
通常一个系统里的普通进程,都会加载1至400个动态库,这个加载数量很大。还好这些动态库大部分都是OS库,OS系统在构建时,会预计算和预缓存那些Dyld
加载内容所要做的工作。所以OS库加载很快。
现在所有的动态库都已经加载完成,但是它们都彼此独立,我们必须要把它们捆绑在一起,这就是所谓的修复(fix-ups)
。
(三) 修复(fix-ups)
关于修复,有一点我们已经知道,由于代码签名的存在我们无法修改指令。那么如果不能修改它调用的指令,动态库如何调用另一个动态库呢?这又用到了间接引用的技术。
所以我们的code-gen
称为动态PIC
,即地址无关代码。这意味着代码可以动态地加载到该地址,也就是说地址间接地被分配。这所意味的是为了让一个调用另一个,code-gen
实际上在DATA
段里新建一个指针,并且该指针指向了我们想调用的位置
。代码加载该指针,并且跳向该指针。所以所有的Dyld
都在修复指针和数据。
现在主要有两种修复,重设基址和绑定。它们的区别是什么呢?
-
重设基址:
是指如果有一个指针指向图像范围内,需要做出的所有的修改。 -
绑定:
是指如果指针指向图像范围外,他们必须进行不同的修复。
下面我们一起看看其步骤:
我们可以在任何二进制文件上运行dyldinfo
指令,就可以看到dyld
必须为该二进制文件做的所有修复工作。
[~]> xcrun dyldinfo -rebase -bind -lazy_bind DongDong.app/DongDong
for arch armv7:
rebase information (from compressed dyld info):
segment section address type value
__DATA __nl_symbol_ptr 0x002F800C pointer 0x002FC9E0
__DATA __nl_symbol_ptr 0x002F8010 pointer 0x002FC458
__DATA __nl_symbol_ptr 0x002F8014 pointer 0x002FEFE8
__DATA __nl_symbol_ptr 0x002F8018 pointer 0x002EDB00
__DATA __nl_symbol_ptr 0x002F8050 pointer 0x00322A6C
__DATA __nl_symbol_ptr 0x002F8054 pointer 0x002FC878
......
bind information:
segment section address type addend dylib symbol
__DATA __nl_symbol_ptr 0x002F833C pointer 0 Alamofire _$s9Alamofire12JSONEncodingVAA17ParameterEncodingAAWP
__DATA __nl_symbol_ptr 0x002F8340 pointer 0 Alamofire _$s9Alamofire12JSONEncodingVN
__DATA __objc_classrefs 0x0031C4C8 pointer 0 CFNetwork _OBJC_CLASS_$_NSHTTPURLResponse
__DATA __objc_classrefs 0x0031C4B4 pointer 0 CFNetwork _OBJC_CLASS_$_NSMutableURLRequest
__DATA __objc_classrefs 0x0031C57C pointer 0 CFNetwork _OBJC_CLASS_$_NSURLConnection
__DATA __objc_classrefs 0x0031C4B0 pointer 0 CFNetwork _OBJC_CLASS_$_NSURLSession (weak import)
......
lazy binding information (from lazy_bind part of dyld info):
segment section address index dylib symbol
__DATA __la_symbol_ptr 0x002F8574 0x0000 libswiftFoundation _$s10Foundation10URLRequestV19_bridgeToObjectiveCSo12NSURLRequestCyF
__DATA __la_symbol_ptr 0x002F8578 0x004D libswiftFoundation _$s10Foundation10URLRequestV3url11cachePolicy15timeoutIntervalAcA3URLV_So017NSURLRequestCacheE0VSdtcfC
__DATA __la_symbol_ptr 0x002F857C 0x00BC libswiftFoundation _$s10Foundation10URLRequestVMa
__DATA __la_symbol_ptr 0x002F8580 0x00E3 libswiftFoundation _$s10Foundation12CharacterSetV11whitespacesACvgZ
__DATA __la_symbol_ptr 0x002F8584 0x011C libswiftFoundation _$s10Foundation12CharacterSetVMa
__DATA __la_symbol_ptr 0x002F8588 0x0145 libswiftFoundation _$s10Foundation17NSLocalizedString_9tableName6bundle5value7commentS2S_SSSgSo8NSBundleCS2StF
......
(四) 重设基址
在过去你可以为每一个dylib
指定首选加载地址,该首选加载地址是一个静态链接器,和Dyld
一起工作。这样,若把它加载到该首选加载地址,则所有本应该在内部编码的指针和数据都是正确的,那么Dyld
就不用做任何修复。但是现在,因为有了ASLR
,dylib
被加载到随机地址上。
它被滑动到其他地址,也就是说所有那些指针和数据都还依然指向旧地址。所以为了修复它们,我们需要计算滑动值,也就是移动距离,并且将该滑动值添加到每一个内部指针上。
因此重设基址是指遍历所有内部数据指针,然后为它们添加一个滑动值。所以这个概念很简单,读、添加、写,读、添加、写。但是这些数据指针在哪里呢?这些指针在段中的位置都编码在LINKEDIT
段里。此时,所有映射都已经结束,当我们开始重设基址时,实际上在所有DATA
页面都产生了页面错误。然后对页面进行修改时,产生写入时复制。
由于所有的这些IO操作,重设基址有时会非常昂贵。但是有一个技巧,就是按顺序操作,从内核的角度来看,它认为数据错误是按顺序产生的。当它如此认为时,内核会进行预读,这样I/O
成本会降低很多。
下面我们来看另一种修复---绑定
(五) 绑定
绑定是针对那些指向动态库范围外的指针而言的。这些指针通过名称进行绑定,实际上都是字符串。本例中,LINKEDIT
段里的malloc
,也就是说该数据指针需要指向malloc
。所以运行时,dylib
需要找到实现该符号的位置,这需要很多的计算,遍历查找符号表。一旦找到,就把值存储到该数据指针中。所以这种方式的计算复杂度要比重设基址高很多。但是I/O
很少,因为重设基址已经完成大部分的I/O
。
(六) 通知ObjC运行时
ObjC
有很多DATA
结构,DATA
结构类也就是指向其方法的指针,以及super gloss
的指针等等。几乎所有这些都通过重设基址或绑定被修复。但在ObjC
运行时还需要一些额外的操作。首先ObjC
是一门动态语言,可以把一个类用名称实例化。即ObjC
在运行时,必须要维护一张表格,这张表中包含了其映射类的所有名称。每次加载的名称都将定义一个类,并将该类的名称注册到一个全局的表中。接下来,在C++中,你们可能听说过关于脆弱的ivar问题。
在ObjC
中不存在脆弱的基类问题,因为我们做的其中一种修复就是,在加载时动态地改变所有ivar的偏移值。在ObjC
里,我们可以定义改变另一个类中方法的分类。有时这些分类在一些类中,而这些类不在另一个动态库的图像中,此刻应用那些方法修复。最后,ObjC
基于选择器是唯一的,所以我们需要唯一的选择器。
(七) 初始化器
现在我们完成了所有的DATA
修复,现在我们可以进行所有可以静态描述的DATA
修复。现在是进行动态DATA
修复的时机。
在C++里,有一个初始化器,可以指定等于任何你想要的表达式。那个任意的表达式此时需要运行,现在就运行了。因此,C++编译器生成初始化器来完成那些任意DATA的初始化。
在ObjC
有一种方法,叫+load
方法,现在+load
方法已经被否决,不建议使用。建议使用+initialize
方法。若有+load
方法,此时它开始运行。
看下面的这张图,顶端是主可执行文件,所有的动态库都依照这张图,必须要运行初始化器。按什么顺序运行呢?我们选择从下往上,原因在于当初始化器运行时,可能会调用一些动态库,你需要确保那些动态库已经准备好被调用。所以从下开始运行初始化器,一直向上到应用类,可以很安全地调用依赖的内容。所以一旦所有初始化器完成时,现在我们终于可以调用主Dyld
程序了。
八、main()
函数之前发生了什么
通过上面的知识,我们了解了进程是如何启动的,知道了Dyld
是一个帮助程序。
- 加载所有的依赖库;
- 修复
DATA
页面的所有指针; - 运行所有的初始化器;
- 跳到主函数;
理论部分到此结束,那么如何把这些理论应用到实际中呢?
请阅读下一章的实战内容:App启动优化 --- 实践部分