写在开始
应用程序加载,是我们每天都要面对的一个课题。每年的WWDC都会关于应用程序的启动时间做出一些优秀的改进更新说明。具体在程序的底层,应用程序的加载做了哪些工作,我们去一探究竟。
一、准备工作
动态库和静态库
- 库:是已写好的、供使用的 可复用代码,每个程序都要依赖很多基础的底层库。从本质上,库是一种可执行代码的二进制形式。可以被操作系统载入内存执行。库分为两种:静态库(.a .lib)和 动态库 (framework .so .dll)。所谓的静态、动态指的是 链接的过程。
- 静态库:在链接阶段,会将汇编生成的目标文件.o 与 引用的库一起链接到可执行文件中。对应的链接方式称为 静态链接。
- 静态库对函数库的链接是在编译期完成的。执行期间代码装载速度快。
- 使可执行文件变大,浪费空间和资源(占空间)。
- 对程序的更新、部署与发布不方便,需要全量更新。如果 某一个静态库更新了,所有使用它的应用程序都需要重新编译、发布给用户。
- 动态库:在程序编译时并不会链接到目标代码中,而是在运行时才被载入。不同的应用程序如果调用相同的库,那么在内存中只需要有一份该共享库的实例,避免了空间浪费问题。同时也解决了静态库对程序的更新的依赖,用户只需更新动态库即可。动态库在内存中只存在一份拷贝,如果某一进程需要用到动态库,只需在运行时动态载入即可。
- 动态库把对一些库函数的链接载入推迟到程序运行时期(占时间)。
- 可以实现进程之间的资源共享。(因此动态库也称为共享库)
- 将一些程序升级变得简单,不需要重新编译,属于增量更新。
Mach-O
- Mach-O:为 Mach Object 文件格式的缩写,是 iOS 系统不同运行时期可执行文件的文件类型统称。它是一种用于可执行文件、目标代码、动态库、内核转储的文件格式。简单的可以分为三个部分,Header,Load Commands,Segment Data。
- Header中包含的是可执行文件的CPU架构,Load Commands的数量和占用空间。
- Load Commands中包含的是Segment的Header与内存分布,以及依赖动态库的版本和Path等
- Segment Data就是Segment汇编代码的实现,每段Segment的内存占用大小都是分页页数的整数倍。
dyld
- dyld:the dynamic link editor[动态链接器]是苹果操作系统一个重要部分,在 iOS / macOS 系统中,仅有很少的进程只需内核就可以完成加载,基本上所有的进程都是动态链接的,所以 Mach-O 镜像文件中会有很多对外部的库和符号的引用,但是这些引用并不能直接用,在启动时还必须要通过这些引用进行内容填充,这个填充的工作就是由 dyld 来完成的。
- dyld源码链接
dyld2 和 dyld3
- dyld2:ios13之前使用。主要流程为
- 解析 Mach-O的Header 和 Load Commands,找到其依赖的库,并递归找到所有依赖的库
- 加载Mach-O文件
- 进行符号查找
- 绑定和变基
- 运行初始化程序
- dyld3:ios13之后使用,把很多耗时的查找、计算和I/O 的事件都预先处理好,使得启动速度有了很大的提升。dyld3包含了3个组件来完成dyld的工作
- 作为一个进程外的Mach-O 解析器:先处理了所有可能影响启动速度的search path、@rpaths 和环境变量 然后分析Mach-O的Header和依赖,并完成了所有符号查找的工作 最后将这些结果创建成一个启动闭包 这是一个普通的daemon进程,可以使用通常的测试架构
- 作为一个进程内的引擎:用来运行启动闭包 这部分在进程中处理 验证启动闭包的安全性,然后映射到dylib之中,再跳转到main函数 不需要解析Mach-O的 Header 和依赖,也不需要符号查找。
- 作为一个启动闭包缓存服务:系统App的启动闭包被构建在一个Shared Cache [共享缓存]中,我们甚至不需要打开一个单独的文件对于第三方的App,我们会在App安装或者升级的时候构建这个启动闭包。在iOS、tvOS、watchOS中,这一切都是App启动之前完成的。在macOS上,由于有Side Load App,进程内引擎会在首次启动的时候启动一个daemon进程,之后就可以使用启动闭包启动了。
二、APP加载流程
load方法引入
在viewController中重写load方法,查看对应执行的堆栈信息
_dyld_start
打开下载好的dyld源码,搜索_dyld_start如下
dyldbootstrap::start
搜索dyldbootstrap如下
dyld::_main
command进入实现方法,发现dyld::_main有很多行代码,这里分享一个分析源码的小思路:
- 我们先将带有{}的代码折叠为代码块,看主线流程
- 主线流程理的差不多,再展开分析细节
- 注意看英文注释抓关键点
通过分析我们看到dyld::_main为我们做了以下事情
1、环境变量配置,根据环境变量设置相应的值以及获取当前运行架构
2、共享缓存,检查是否开启了共享缓存,以及共享缓存是否映射到共享区域!
3、实例化主程序
4、插入动态库
5、link 主程序
6、link 动态库
7、弱符号绑定
8、执行初始化方法
8.1、initializeMainExecutable方法
8.2、ImageLoader::runInitializers
8.3、ImageLoader::processInitializers
8.4、ImageLoader::recursiveInitialization
8.5、sNotifyObjCInit
-
全局搜索sNotifyObjCInit
-
发现没有实现,只有赋值操作
-
继续搜索registerObjCNotifiers
-
追寻_dyld_objc_notify_register,在dyld中_dyld_objc_notify_register已经是尽头了,_dyld_objc_notify_register的实现是在 libobjc源码中
-
_dyld_objc_notify_register 追踪,_objc_init源码中调用了该方法,并传入了参数load_images,所以sNotifyObjCInit的赋值的就是objc中的load_images,而load_images会调用所有的+load方法。所以综上所述,notifySingle是一个回调函数
-
进入load_images的源码实现,调用了call_load_methods
-
call_load_methods 可以看到调用了load方法。
-
至此印证了load在程序启动的堆栈信息。这里留下疑问_objc_init的调用时机。
8.6、doInitialization
-
dyld追踪doInitialization实现
-
doImageInit,遍历加载所有镜像文件
-
doModInitFunctions,加载所有的cxx方法
8.7、_objc_init的调用时机
-
添加符号断点_objc_init,查看堆栈信息
-
在libsystem中查找libSystem_initializer
-
在libdispatch中查找libdispatch_init
-
进入_os_object_init,至此找到_objc_init的调用时机,
9、寻找主程序入口即main函数
这里在开头的汇编源码里也有说明对照。
10、dyld 和 libobjc 关联
关键先生:sNotifyObjCInit,当程序准备好初始化的时候会递归去初始化已经加载的镜像文件recursiveInitialization,我在这里理解为程序添加sNotifyObjCInit的通知,然后执行 doInitialization,初始化则会执行 ->libSystem_initializer -> libdispatch_init -> _os_object_init -> _objc_init。而_objc_init 执行则会发送_dyld_objc_notify_register的通知,_dyld_objc_notify_register会传递已经加载好的 map_images、load_image、unmap_image回调给sNotifyObjCInit。至此形成闭环关联。
三、写在结束
- dyld的源码总体来说,还是比较晦涩的。所以这次写的比较详细,贴图也给到位。方便自己后边回头来看。
- 研究源码的时候重要的是方法,前面我也简单提到了自己的一些小方法。
- 不要半途而废,遇到get不到的点,多查资料,前面已经有很多巨人,我们可以站在巨人的肩膀上继续前行
- 眼见为真,只有自己理解到了,并且程序真的调试到了,才能算是探究,切勿人云亦云。