iOS main()之前都发生了什么
这篇文章首发在公司微信技术公众号:京东金融技术说
「摘要」每个iOS app从点击启动到首页面加载渲染完成,虽然时间比较短暂,但系统进行了不少重要的操作,比如会加载100-400个支撑app后续运行的dylib。本文从Dylib、Mach-O等基本运行时文件说起,介绍了iOS 的Virtual Memory,以及从exec()到main()之间经历的主要阶段:Dylibs Loading、Rebasing、Binding、ObjC Runtime、Initializer。然后从理论升级到实践,针对启动中的各个环节分析如何更进一步提升app的启动速度。
一、Mach-O
相关术语说明(文件类型)
Executable—iOS 应用程序的主要二进制文件
Dylib—Dynamic library,动态库 (其他平台中叫DSO 或者 DLL)
Bundle—可以看做是不能链接的Dylib,只能使用dlopen()来加载,就像插件一样
Image— 泛指 Executable,Dylib或者Bundle
Framework—包含Dylib、图片等资源、头文件(.h)的特定结构的目录文件
1、Mach-O Image
Image 被分割成多个 segment。segment一般用大写字母来命名,其大小一般是 page 大小的整数倍(arm64环境下1page是16KB,其它环境下是4KB)。如下图,一个Image通常由三个segment组成,分别是__TEXT(3page)、__DATA(1page)、__LINKEDIT(1page)。
Section 是Segment所包含的一部分,一般用小写字母命名。Section的大小没有是page整数倍的要求,但它是不可被覆盖的,同时它也是被编译器忽略的。如下图,__TEXT是文件的开始,包含了Machheader,主要有对应硬件环境信息的说明,只读的常量(如C字符串)。__DATA是可读亦可写的,主要包含一些全局变量,静态变量。__LINKEDIT并不包含全局变量的函数/方法(function),而是包含函数/方法(function)的一些信息,比如其名字,地址等等。
2、Mach-O universal file(通常称为胖二进制文件)
由于硬件在快速的升级换代,之前是32位的机器,现在已经有好多的64位机器。并且,每一代的iPhone,其CPU架构都不完全相同,有i386,armv7,armv7s,arm64。为了让同一份代码可以部署到不同的环境下,就需要一种通用的可执行文件了—Mach-O universal file。如下图,将armv7s和arm64下的Mac-O文件合并,形成一个新的Mach-O universal file,其包含了一个Fat Header。Fat Header中包含了所支持的架构列表以及其在文件中的偏移量(offsets)。
二、Virtual Memory
Virtual Memory 是一个中间层,用于将每个进程(process)的逻辑地址空间映射到物理RAM上(以page的粒度)。VM有如下的特点:
某些逻辑地址没有映射到具体物理RAM上,内核访问该地址时,会出现Pagefault
多个process可以映射到相同的一个page 上
可以实现对文件的延迟读取
对于共享的page可以进行 Copy-On-Write
Copy-On-Write 会导致Dirtypage,所以需要pageclean
RAM的权限(rwx)与映射权限的联系
1、Mach-O ImageLoading
结合上面的Dylib和VM的基本知识,Mach-O Image 的加载过程可通过下图来展示出来:
两个重要的安全因素对Image Loading的影响:
一个是 ASLR:(address space layout randomization),为了防止Image 每次都被加载到同一个物理RAM上而被恶意利用,Image每次分配到的地址偏移量是随机的。
另一个是 code sign:这个使用XCode编译打包过的人应该非常熟悉了,和编译期不同的是,每一个page的Mach-O都有自己的签名,这些签名信息保存在__LINKEDIT中。
三、从exex() 到 main()
exec()是一个系统级别的调用,当点击appicon或者不同app间切换时,会触发它。对于该app,exec()主要进行如下的操作:
将app映射到一个随机的地址空间
app的起始地址是随机的
将起始地址和0x000000之间的地址空间,对该app标记为不可操作(不可读,不可写,不可执行)
捕获空指针的使用
捕获指针缺失的错误
1、加载Dylibs
接下来,Dyld(dynamicloader)会开始加载app中的dylib,主要步骤如下:
从胖二进制文件中的header中解析app所依赖的dylibs列表
找到该环境下所需的mach-O 文件
打开每一个dylib,读取其头部,进行验证,看是否是mach-O格式,然后找到代码签名,并将代码签名注册到内核
调用mmap(),对所有的segment进行映射
现在,所有的一级dylib都加载了,但是有的dylib依赖于其他dylib,甚至同一dylib被多个dylib依赖。所以需要进行递归的加载,一直到所有的都加载完成
通常一个app会加载100-400个dylib,这其中大部分是操作系统的,系统本身已经对这些dylib的加载进行了优化
上述过程如下图:
2、Rebasing 和 Binding
每个dylib分配到的地址空间也是随机的,也就是其起始地址会不断的变化,或者说是滑动(slide)。
Rebasing就是在发生变化时,对内部的指针按照新的偏移量进行校正。而binding是对指向外部的指针进行校正。校正后的地址信息都是保存在__LINKEDIT中。
可以使用 dyldinfo 命令来查看任意 dylib 的Rebasing和Binding信息:
2、通知 ObjC Runtime
大多数的ObjC设置都通过Rebasing和Binding完成了,比如注册ObjC class 声明,将Category中声明的方法插入到方法列表中。之后,Runtime启动并开始初始化操作
3、Initializer
如果是C++,静态对象的initializer开始执行。对于ObjC,+load 方法被调用。其她的+方法也开始被调用。由于+方法之间会存在继承调用关系,所以,整个+方法的被调用顺序是由下向上的。
接下来,app中的main()方法会被调用,main()被调用前经历的TimeLine:
四、从理论到实践-如何提升启动时间?
1、怎样的速度算快
启动速度应当比闪屏动画速度快,不同的硬件设备、系统环境下,启动速度都不相同,400ms是一个比较合理的目标,启动时间不能超过20 秒,超过了会被系统Kill掉
需要在低性能设备上做测试。
2、回顾一下整个启动堆栈
Parse images
Map images
Rebase images
Bind images
Run imageinitializers
Call main()
Call UIApplicationMain()
CallapplicationWillFinishLaunching
Call applicationdidFinishLaunchingWithOptions(iOSer 写应用程序的入口)
3、冷启动、热启动,检测启动时间
热启动是指app启动之前,app执行文件和相关的数据已经在缓存中。冷启动则相反,内核缓存中没有任何数据,所以对于冷启动来说,时间更为重要。冷启动的使用场景是,更新系统后第一次打开app,或者很长时间没有使用过app,再次打开。
之前,检测main()之前的时间消耗是非常困难的,在最新的iOS10系统,嵌入了新功能,可以方便的测试启动时间:
4、dylib 加载阶段
尽量减少dylib 的数量,可以合并多个dylib,或者将代码直接放在executable中。
使用dlopen来延迟加载,虽然使用dlopen会导致额外的开销,甚至大于在启动时加载的,但它是延迟的。(注:近一年内Hot patch非常火热,由于Hot patch本身的机制和安全问题,导致苹果开始封杀,dlopen就被加入了禁用名单了)
从架构的角度,把app中的一些共用功能以dylib的形式模块化,可以降低耦合,提升开发效率。但是过多的话,会影响启动时间。
5、Rebasing和Binding阶段
Rebasing和Binding阶段,会产生IO和一些计算消耗,可以通过如下方案优化:
减少__DATA 中的指针数量可以减少处理时间
减少Objective-C的元数据,如Classes,selectors,categores
减少C++中的虚方法
使用swift 的 struct
6、Initializer阶段
ObjC +load方法替换成+initialize 形式的:C/C++中的__attribute__((constructor)) 换成 dispatch_once(),pthread_once(),std:once()
不要在initialize中使用 dlopen
不要在initialize中创建线程
使用swift