OC底层-应用程序加载初探

1,164 阅读7分钟

写在开始

应用程序加载,是我们每天都要面对的一个课题。每年的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不到的点,多查资料,前面已经有很多巨人,我们可以站在巨人的肩膀上继续前行
    • 眼见为真,只有自己理解到了,并且程序真的调试到了,才能算是探究,切勿人云亦云。