重学iOS系列之APP启动(二)dyld

导读

    本文承接自APP启动-流程(一),有疑惑的同学可以先阅读上一篇的内容。本文会带大家详细的解读dyld-852.2源码中关于APP启动最重要的一个函数_main()。我们从上一节了解到_main函数的实现代码有900行,所以不可能一行一行的来进行解读,本文只会针对重点函数进行跟进解读。有需要的同学自行下载源码解读。源码解读并非跟着文章看一遍就能记住学会,这个过程需要反复的跟读,所以建议读者将源码下载下来,跟着笔者的进度同时对照着源码学习效果才会最佳,也不至于看得云里雾里。

dyld下载地址:http://opensource.apple.com/tarballs/dyld

在分析具体的源码之前,我们必须要了解一些前置知识点:

dyld的全称是dynamic loader。dyld 是 iOS 上的二进制加载器,用于加载 Image。有不少人认为 dyld 只负责加载应用依赖的所有动态链接库,这个理解是错误的。dyld 工作的具体流程如下:


dyld2与dyld3

在 iOS 13 之前,所有的第三方 App 都是通过 dyld 2 来启动 App 的,主要过程如下:

1、Parse mach-o headers     解析mach-o头文件

2、find dependencels 根据头文件的信息查找依赖项

3、Map mach-o files  将mach-o文件映射到内存,简单来讲就是加载到内存中

4、Perform symbol lookups这个步骤表示执行符号查找。(例如:如果你使用了printf函数,就会查找printf是否在库系统中,找到它的地址,将它赋值到你的程序中的函数指针)

5、Bind and rebase  符号绑定和地址重定位(由于ASLR的原因)

6、Run initializers  dyld会运行初始化函数,初始化动态库,初始化主程序,然后进入运行时runtime初始化,注册类,分类,方法唯一性检查等(后续的文章会详细分析runtime的初始化过程)

在iOS 13之后,dyld3开放给第三方APP使用了,也就是说,在iOS 13以上的系统里,APP是通过dyld3来启动的。

从上图来看dyld3是由2个部分组成,上半部分在程序启动进程外执行的,这一步会在App下载安装和版本更新的时候会去执行。下半部分才是在程序启动进程内执行的。

原本在dyld2中执行的1、2、4步被放到了程序启动进程外执行,然后向磁盘写入闭包处理 “Write closure to disk”(启动闭包(launch closure):这是一个新引入的概念,指的是 app 在启动期间所需要的所有信息。比如这个 app 使用了哪些动态链接库,其中各个符号的偏移量,代码签名在哪里等等)。这样,启动闭包处理就成了启动程序的重要环节。稍后可以在APP的进程中使用 dyld 3包含的这三个部分,

启动闭包比mach-o更简单。它们是内存映射文件,不需要用复杂的方法进行分析。

我们可以简单的验证它们,这样可以提高速度,也就是说在不需要修改代码的情况下官方帮我们做了启动优化。

dyld3的主要过程如下:

主程序进程外

1、Parse mach-o headers     解析mach-o头文件

2、find dependencels 根据头文件的信息查找依赖项

3、Perform symbol lookups  这个步骤表示执行符号查找。(例如:如果你使用了printf函数,就会查找printf是否在库系统中,找到它的地址,将它赋值到你的程序中的函数指针)

4、Write closure to disk  将1、2、3步做完的事情组装成一个启动闭包,并且写入磁盘

主程序进程内

5、Read in closure    从启动闭包中读取必要的信息数据

6、Validata closure     验证启动闭包

7、Map mach-o files  将mach-o文件映射到内存,简单来讲就是加载到内存中

8、Bind and rebase  符号绑定和地址重定位(由于ASLR的原因)

9、Run initializers  dyld会运行初始化函数,初始化动态库,初始化主程序,然后进入运行时runtime初始化,注册类,分类,方法唯一性检查等(后续的文章会详细分析runtime的初始化过程)

dyld3 被分为了三个组件:

一、一个进程外的 MachO 解析器

1、预先处理了所有可能影响启动速度的 search path、@rpaths 和环境变量

2、然后分析 Mach-O 的 Header 和依赖,并完成了所有符号查找的工作

3、最后将这些结果创建成了一个启动闭包

4、这是一个普通的 daemon 进程,可以使用通常的测试架构

二、一个进程内的引擎,用来运行启动闭包

1、这部分在进程中处理

2、验证启动闭包的安全性,然后映射到 dylib 之中,再跳转到 main 函数

3、不需要解析 Mach-O 的 Header 和依赖,也不需要符号查找。

三、一个启动闭包缓存服务

1、系统 App 的启动闭包被构建在一个 Shared Cache 中, 我们甚至不需要打开一个单独的文件

2、对于第三方的 App,我们会在 App 安装或者升级的时候构建这个启动闭包。

3、在 iOS、tvOS、watchOS中,这这一切都是 App 启动之前完成的。在 macOS 上,由于有 Side Load App,进程内引擎会在首次启动的时候启动一个 daemon 进程,之后就可以使用启动闭包启动了。

dyld 3 把很多耗时的查找、计算和 I/O 的事前都预先处理好了,这使得启动速度有了很大的提升。



_main函数

函数的每个参数意义如下:

// dyld的main函数 dyld的入口方法kernel加载dyld并设置设置一些寄存器并调用此函数,之后跳转到__dyld_start

// mainExecutableSlide 主程序的slider,用于做重定向 会在main方法中被赋值

// mainExecutableMH 主程序MachO的header

// argc 表示main函数参数个数

// argv 表示main函数的参数值 argv[argc] 可以获取到参数值

// envp[] 表示以设置好的环境变量

// apple 是从envp开始获取到第一个值为NULL的指针地址

uintptr_t

_main(constmacho_header* mainExecutableMH,uintptr_tmainExecutableSlide, 

intargc,constchar* argv[],constchar* envp[],constchar* apple[], 

uintptr_t* startGlue)

源码分析_main()函数:

配置运行环境

检查是否开启debug追踪,追踪的是启动可执行文件的过程。

检查是否有内核相关的标记,setFlags内部会检查dyld3是否已经初始化,没有初始化则不设置flags

检查并查看内核是否禁用了JOP(Jump-Oriented Programming)指针签名,指针签名是用于防范内核攻击的一个手段。

从环境变量中获取主要可执行文件的 cdHash 值。这个哈希值 mainExecutableCDHash 在后面用来校验 dyld3 的启动闭包

获取当前设备的一些信息,比如:cpu架构类型,基本信息,mach进程通信端口等


通知内核开始加载dyld和主程序的可执行文件了

获取主程序的macho_header结构

获取主程序的slide值,slide其实就是mach-o映射到内存的偏移量

查找可执行文件支持的平台(看绿色高亮的FIXME这行,其实这部分代码可以删除,因为在内核中已经处理过了)

接着没截出来的那一段代码是基于OS系统的判断逻辑,本文不做分析。


设置上下文信息,保存回调函数的地址,以便后续直接调用。可以看看setContext的源码如下:


根据环境变量从内核拿到可执行文件的路径。

移除过渡代码(修复了rdar://problem/13868260这个bug)


由于是多平台共用一套代码,内核传递出来的exec路径是不全的,如果是iphone真机环境,则会拼接一段路径的前缀,大家肯定打印过很多Document的路径,真机都是/var/开头的和模拟器的不一样。

判断 exec 路径是否为绝对路径,如果为相对路径,使用 cwd 转化为绝对路径

为了后续的日志打印从 exec 路径中取出进程的名称 (strrchr 函数是获取第二个参数出现的最后的一个位置,然后返回从这个位置开始到结束的内容)


配置进程的受限模式,设置gLinkContext的环境变量,跟签名以及代码注入有关。查看详细的configureProcessRestrictions代码实现,会发现一个新系统AMFI (AppleMobileFileIntegrity)。

简单介绍下AMFI是一个内核扩展,最初是在iOS中引入的。在版本10.10中,它也被添加到macOS中。它扩展了MACF(强制访问控制框架),就像沙盒一样,它在实施SIP和代码签名方面起着关键作用(这一块涉及的内容非常多,不做详细分析)

dyld3::internalInstall()这个函数在xcode默认设置下是返回yes的。

ClosureMode是个calss类型的枚举,有4种模式:

Unset    表示我们没有提供env变量或boot arg来显式选择模式

On    表示我们将DYLD_USE_CLOSURES设置为1,或者我们没有将DYLD_USE_CLOSURES设置为0,但在iOS上设置了-force_dyld3=1环境变量或者一个外部缓存(启动闭包)

Off    意味着我们设置了DYLD_USE_CLOSURES=0,或者我们没有设置DYLD_USE_CLOSURES=1,但在iOS上设置了-force\u dyld2=1环境变量或者一个内部缓存

PreBuiltOnly    意味着只使用共享缓存闭包,而不尝试构建新的缓存闭包

ClosureKind 也是个class类型的枚举,有3种状态:unset, full, minimal

默认值都是unset

检查是否强制使用共享缓存,在iOS上为了节省内存,是要求强制使用共享缓存的。



创建了缓存闭包路径的数组变量,如果出错,则直接退出进程。

成功则将闭包模式设置为on。

到此,运行环境全部设置完成。细心的同学应该会注意到,整个过程中有很多DYLD_****开头的环境变量。其实这些都是可以在Xcode中配置使其在上面的流程中生效的,我们打开工程然后依次点击“Product”->“Scheme”->“Edit Scheme…”,如下图所示。


然后运行Xcode即可看到控制台打印的详细信息。有很多这样的DYLD_*开头的环境变量,感兴趣的同学可以自行测试。

加载共享缓存

这里补充一下共享缓存的知识点:

在iOS系统中,每个程序依赖的动态库都需要通过dyld(位于/usr/lib/dyld)一个一个加载到内存,然而如果在每个程序运行的时候都重复的去加载一次,势必造成运行缓慢,为了优化启动速度和提高程序性能,共享缓存机制就应运而生。所有默认的动态链接库被合并成一个大的缓存文件,放到/System/Library/Caches/com.apple.dyld/目录下,按不同的架构保存分别保存着。

如果没有缓存库存在的话,那么我们手机上的每一个App,如果要用到系统动态库的话,是需要每一个App都要去加载一次的,一样的资源被加载多次(加载几次就需要消耗几份内存),无论是空间还是执行效率,都是造成了浪费。

如果有共享缓存库(系统会提前将一些常用的库加载到内存中)存在的话,那么我们手机上的每一个App,如果要用到系统动态库的话,只需要先去内存中找,找到了就直接链接就行,没有找到的花,加载一份到共享缓存的内存空间中,然后再链接这个库。节省了内存,还提高了运行速度。

前面说过,iOS系统是强制使用共享缓存的,所以checkSharedRegionDisable里面什么都不会做,然后将共享缓存映射到当前进程的内存空间内。

真正加载共享缓存的是这句代码:mapSharedCache(); 里面调用了loadDyldCache(),从if else代码可以看出,共享缓存加载又分为三种情况:

仅加载到当前进程,调用mapCachePrivate()。

共享缓存已加载,不做任何处理。

当前进程首次加载共享缓存,调用mapCacheSystemWide()。

mapCachePrivate()、mapCacheSystemWide()里面就是具体的共享缓存解析逻辑,感兴趣的同学可以自己深入详细分析。

dyld3

查找启动闭包

如果没有设置闭包模式,则检查环境变量里的字段以及缓存类型来判断设置哪种模式。

4种模式在前文有详细介绍。


宏检查是否是真机,创建一些零时变量存储启动闭包的信息,检查共享缓存中是否有启动闭包,前文有提到,系统app会将启动闭包保存到共享缓存中,所以是有必要做这项检查的。

从 findClosure() 实现里面也可以看出:

(anything in /System/ should have a closure)任何在/System/ 路径下的可执行文件都有一个启动闭包。

重新回到_main()函数

接下来是一系列重新构建闭包的判断。所有的if else 都是在为allowClosureRebuilds服务的。

需要特别注意的是6997行用红色框框出来的closureValid()函数,这个函数里面做了大量的判断,来判断缓存里是否存在闭包。注意,这里的缓存和上文的共享缓存是有区别的!

下面是具体的实现,由于实现代码太长,把很多分支都隐藏还是无法展示全部代码,所以这里只截取了前半部分代码,不过我会把有注释的判断都列出来:


如上图,在_main()函数里获取的CDHash在这里发挥了作用,代码能执行到这说明是有缓存闭包的,后续的判断都是针对这个存在的闭包,判断是否有效。

下面列出剩余的注释,一行注释就是一个判断:

//If we found cd hashes, but they were all invalid, then print them out - 如果我们发现了cdHash,但它们都是无效的,那么就把它们打印出来

// verify UUID of main executable is same as recorded in closure - 验证主可执行文件的UUID是否与闭包中记录的相同

// verify DYLD_* env vars are same as when closure was built - 验证DYLD_*env变量是否与构建闭包时相同

// verify files that are supposed to be missing actually are missing - 验证应该丢失的文件是否确实丢失

// verify files that are supposed to exist are there with the - 验证假定存在的文件是否与 (这里的注释不全,根据打印log,应该是验证文件完整性的)

// verify closure did not require anything unavailable - 验证闭包是否有依赖任何不可用的内容


分析完closureValid(),我们回到_main()函数中,继续闭包相关的源码阅读:

这里验证了上文提到的只有第三方app的启动闭包会保存在磁盘中。

主要逻辑是 获取主程序启动闭包CDHash,然后做如下判断:

1、如果CDHash不为空,则再判断是否启动了dyld3,如果启动了则什么都不做,从打印我们知道,在启动dyld3的情况下内部系统是允许磁盘上的启动闭包的。如果没有启动dyld3,则设置canUseClosureFromDisk = false,也就是说不允许使用磁盘上的启动闭包。

2、如果CDHash为空,则判断主程序启动闭包是否为空,如果不为空则将mainClosure设置为空指针。结合注释我们可以了解到cdHash和启动闭包是必须同时存在的,如果CDHash为空,则找到的缓存闭包也不能使用。

构建启动闭包

rdar的bug先不管(其实是笔者水平不够没弄懂)。

然后如果发现没有找到一个有效的启动闭包,则尝试自己构建一个。

buildLaunchClosure()这个函数就是构建闭包的函数,内部实现比较长比较复杂,在本文不做详细的分析。后续有空的话会新开一篇专门分析启动闭包构建和加载。

接下来判断sJustBuildClosure是否为yes,如果为yes意思就是只构建闭包,不做加载动作,上文提到APP在下载完成以及升级完成后会重新构建启动闭包,在这种情况下dyld构建完闭包后就直接退出了。

加载闭包

这里的逻辑比较简单,launchWithClosure()加载启动闭包,判断是否成功,如果失败,判断是否启动闭包是否过期,过期了重新构建一个,构建完成后再重新加载闭包。

launchWithClosure()函数内部实现很长,粗略的看了下具体的实现,简单说一下:

拿到所有的image数组(最多三个:缓存动态库、其他操作系统动态库和主程序),

然后调用了dyld3 的一个Load的loader()方法,然后递归的加载依赖库,并且将加载的库添加到allImages数组中保存。

找到dyld的入口,把所有镜像的信息传递给dyld,然后run initializers。

加载完闭包后返回一个result,这是一个假main()函数的入口,内部实现就是return 0;

到此,dyld3的启动过程就完成了。

但是_main()函数还未结束,后续的代码是使用dyld2的方式加载流程。模拟器以及iOS13以下是没有dyld3的,所以是走的dyld2启动流程。

dyld2

后面我们继续分析dyld2的流程:

实例化主程序

// add dyld itself to UUID list

addDyldImageToUUIDList();

这里会添加 dyld 的镜像文件到 UUID 列表中,主要的目的是启用堆栈的符号化。

这里有个SUPPORT_ACCELERATE_TABLES宏,判断是否支持加速器表。从注释我们可以得出arm64架构的情况下是不使用加速器表的。(后面还会有个有意思的判断)

然后接着就是reloadAllImages:的一个标签。可以使用goto语句直接跳转到这个标签,然后从这开始执行代码。

这里插一个知识点:ImageLoader

ImageLoader 是一个用于加载可执行文件的基类,它负责链接镜像,但不关心具体文件格式,因为这些都交给子类去实现。每个可执行文件都会对应一个 ImageLoader实例。ImageLoaderMachO 是用于加载 Mach-O 格式文件的 ImageLoader 子类,而 ImageLoaderMachOClassic 和 ImageLoaderMachOCompressed 都继承于 ImageLoaderMachO,分别用于加载那些 __LINKEDIT 段为传统格式和压缩格式的 Mach-O 文件。


这一步将主程序的Mach-O加载进内存,并实例化一个ImageLoader。instantiateFromLoadedImage()首先调用isCompatibleMachO()检测Mach-O头部的magic、cputype、cpusubtype等相关属性,判断Mach-O文件的兼容性,如果兼容性满足,则调用ImageLoaderMachO::instantiateMainExecutable()实例化主程序的ImageLoader。

ImageLoaderMachO::instantiateMainExecutable()函数里面首先会调用sniffLoadCommands()函数来获取一些数据,包括:

compressed:若Mach-O存在LC_DYLD_INFO和LC_DYLD_INFO_ONLY加载命令,则说明是压缩类型的Mach-O

segCount:根据 LC_SEGMENT_COMMAND 加载命令来统计段数量,这里抛出的错误日志也说明了段的数量是不能超过255个

libCount:根据 LC_LOAD_DYLIB、LC_LOAD_WEAK_DYLIB、LC_REEXPORT_DYLIB、LC_LOAD_UPWARD_DYLIB 这几个加载命令来统计库的数量,库的数量不能超过4095个

当sniffLoadCommands()解析完以后,根据compressed的值来决定调用哪个子类进行实例化。

这里总结为4步:

ImageLoaderMachOCompressed::instantiateStart()创建ImageLoaderMachOCompressed对象。

image->disableCoverageCheck()禁用段覆盖检测。

image->instantiateFinish()首先调用parseLoadCmds()解析加载命令,然后调用this->setDyldInfo()设置动态库链接信息,最后调用this->setSymbolTableInfo() 设置符号表相关信息,代码片段如下:

image->setMapped()函数注册通知回调、计算执行时间等等。

在调用完ImageLoaderMachO::instantiateMainExecutable()后继续调用addImage(),将image加入到sAllImages全局镜像列表,并将image映射到申请的内存中。

加载插入的动态库

这一步是加载环境变量DYLD_INSERT_LIBRARIES中配置的动态库,先判断环境变量DYLD_INSERT_LIBRARIES中是否存在要加载的动态库,如果存在则调用loadInsertedDylib()依次加载。

loadInsertedDylib()内部设置了一个LoadContext参数后,调用了load()函数,

load()函数的实现为一系列的loadPhase*()函数,loadPhase0()~loadPhase1()函数会按照下面所示顺序搜索动态库,并调用不同的函数来继续处理。

DYLD_ROOT_PATH   -->  LD_LIBRARY_PATH  -->  DYLD_FRAMEWORK_PATH || DYLD_LIBRARY_PATH  -->  raw path  -->  DYLD_FALLBACK_LIBRARY_PATH

当内部调用到loadPhase5load()函数的时候,会先在共享缓存中搜寻,如果存在则使用ImageLoaderMachO::instantiateFromCache()来实例化ImageLoader,否则通过loadPhase5open()打开文件并读取数据到内存后,再调用loadPhase6(),通过ImageLoaderMachO::instantiateFromFile()实例化ImageLoader,最后调用checkandAddImage()验证镜像并将其加入到全局镜像列表中。


链接主程序

在调用link()进行链接主程序之前,会有一个宏判断,如果支持加速器表并且主程序已经rebase了,则重新rebase一次,用于ASLR的工作。

这一步调用link()函数先将传入的image添加进sAllImages全局静态数组,接着将image添加进sImageRoots(后续递归初始化所有image的时候有用到)数组,然后将实例化后的主程序进行动态修正,让二进制变为可正常执行的状态。link()函数内部调用了ImageLoader::link()函数

从源代码可以看到,这一步主要做了以下几个事情:

recursiveLoadLibraries() 根据LC_LOAD_DYLIB加载命令把所有依赖库加载进内存。

recursiveUpdateDepth() 递归刷新依赖库的层级。

recursiveRebase() 由于ASLR的存在,必须递归对主程序以及依赖库进行重定位操作。

recursiveBind() 把主程序二进制和依赖进来的动态库全部执行符号表绑定。

weakBind() 如果链接的不是主程序二进制的话,会在此时执行弱符号绑定,主程序二进制则在link()完后再执行弱符号绑定,后面会进行分析。

recursiveGetDOFSections()、context.registerDOFs() 注册DOF(DTrace Object Format)节。

链接插入的动态库

这一步与链接主程序一样,将前面调用addImage()函数保存在sAllImages中的动态库列表循环取出并调用link()进行链接,需要注意的是,sAllImages中保存的第一项是主程序的镜像,所以要从i+1的位置开始,取到的才是动态库的ImageLoader:

ImageLoader* image = sAllImages[i+1];

接下来循环调用每个镜像的registerInterposing()函数,该函数会遍历Mach-O的LC_SEGMENT_COMMAND加载命令,读取__DATA,__interpose,并将读取到的信息保存到fgInterposingTuples中,为后续的bind做准备工作。


这里宏判断加速器表,然后如果加速器表和隐试插入库同时存在,则禁用加速器表,并且使用goto 语句跳转到reloadAllImages重新加载。

递归Bind

先调用applyInterposingToDyldCache()申请将所有链接的库插入共享缓存,内部将会找到链接库的symbol符号表,为后面的Rebase做准备。

接着执行主程序的recursiveBindWithAccounting()函数,该函数内部其实就是调用了recursiveBind(),然后for循环对插入的库执行recursiveBind(),recursiveBind内部先进行递归所有依赖库调用recursiveBind,然后调用doBind()函数,该函数有2个不一样的实现,分别在ImageLoaderMachOClassic.cpp 和 ImageLoaderMachOCompressed.cpp文件中。

ImageLoaderMachOClassic.cpp文件的实现:调用doBindExternalRelocations 以及 bindIndirectSymbolPointers执行真正的符号绑定。

ImageLoaderMachOCompressed.cpp文件的实现:调用eachBind() 和eachLazyBind(),具体处理函数是bindAt()。

执行弱符号绑定

weakBind()首先通过getCoalescedImages()合并所有动态库的弱符号到一个列表里,然后调用initializeCoalIterator()对需要绑定的弱符号进行排序,接着调用incrementCoalIterator()读取dyld_info_command结构的weak_bind_off和weak_bind_size字段,确定弱符号的数据偏移与大小,最终进行弱符号绑定。


执行初始化方法(重点)

这一步由initializeMainExecutable()完成。我们看看内部的具体实现:

先看注释 run initialzers for any inserted dylibs 将已经插入的动态库执行初始化操作。

然后用一个for循环拿到每一个动态库的指针调用runInitializers()函数。

继续看注释 run initializers for main executable and everything it brings up ,执行主程序的初始化。

所以dyld会优先初始化链接的动态库,然后再初始化主程序。

这点很重要,后面我会从源码分析为什么dyld要这样做!


runInitializers()内部调用了processInitializers()

函数的注释解释:向上动态库初始化的执行为时过早。为了处理向上链接而不是向下链接的悬空动态库,所有向上链接的动态库都将其初始化延迟到通过向下动态库的递归完成之后。

什么意思呢,动态库直接也是有依赖关系的,举个例子:现在要初始化动态库A,但是动态库A依赖了动态库B,动态库B又依赖了动态库C,D等,那么这里就会递归的先初始化C、D,然后再初始化B,最后才初始化A。

processInitializers内部调用了recursiveInitialization()来进行递归初始化,我们再来看看recursiveInitialization的实现

为了方便截图,笔着把for循环递归调用逻辑给折叠了,从注释也可以知道低级的库优先初始化。这跟我们上面的分析是一样的。然后我们重点看看红框里的代码,context上下文我们之前分析过了,里面保存了很多函数的地址,参数等。

红框内先调用了notifySingle,注意第一个参数dyld_image_state_dependents_initialized,说明这次是调用的依赖库的初始化,然后接着调用了doInitialization(),从函数名称完全可以猜到这个函数才是真正去执行初始化操作的。接着再次调用notifySingle,看第一个参数dyld_image_state_initialized,这次是当前库的初始化。我们先分析notifySingle的内部实现(非常重要!!!),后面再分析doInitialization。

notifySingle函数里面唯一跟init有关的能调用的函数就是上图红色框里的sNotifyObjCInit。全局搜索sNotifyObjCInit。只发现registerObjCNotifiers函数内部的赋值操作。

// record functions to call

sNotifyObjCMapped = mapped;

sNotifyObjCInit = init;

sNotifyObjCUnmapped = unmapped;

看注释:记录函数用于调用。这里赋值了3个函数,一个mapped,一个init,一个unmapped。

接着全局搜索registerObjCNotifiers,发现是_dyld_objc_notify_register函数调用的。

全局搜索一下,发现在dyldAPIsInLibSystem.cpp文件中还有一个具体的实现。但是没有发现有任何函数调用过_dyld_objc_notify_register。

上图说明这个函数可能跟LibSystem.dylib有关。

那么到底谁调用了_dyld_objc_notify_register()呢?静态分析已经无法得知,只能对_dyld_objc_notify_register()下个符号断点观察一下了,

点击Xcode的“Debug”菜单,然后点击“Breakpoints”,接着选择“Create Symbolic Breakpoint...”。然后添加_dyld_objc_notify_register,运行工程,如下图所示

请看打印信息,调用顺序如下:


最后发现是_objc_init()方法调用的,而且也验证了上面真正做初始化的函数是doInitialization()

我们把上面的几个库(libSystem、libdispatch、libobjc)的源码都下载下来验证一下!

从libSystem_init()到_objc_init(),内部调用顺序和我们打印的确实一样,最终调用了_dyld_objc_notify_register(),然后_objc_init函数就结束了。

那么问题来了,libSystem_initializer()什么时候调用的呢?

其实这个答案在前文分析initializeMainExecutable()源码的时候就已经讲过了。dyld会优先初始化动态库,然后初始化主程序。libSystem_initializer()就是在这个时候调用的。

我们回到_dyld_objc_notify_register函数来,看看这3个参数,都是函数指针

_dyld_objc_notify_register(&map_images, load_images, unmap_image);

最后得到

sNotifyObjCMapped = map_images;

sNotifyObjCInit = load_images;

sNotifyObjCUnmapped = unmap_image;

也就是说sNotifyObjCInit的调用其实就是调用load_images();

由于篇幅的原因,load_images的分析我们放到下一节。

前文提到doInitialization()才是真正执行初始化的函数,我们回过头来继续分析doInitialization()的具体实现:

内部调用doImageInit() 和 doModInitFunctions()

这2个函数内部都有libSystemInitialized的判断,要求libSystem这个动态库必须先初始化,这也验证了我们前面的结论是对的。

然后调用func执行初始化方法。假设当前是libSystem库,那么这个func就是libSystem_initializer()

到此,所有的初始化方法调用已经闭环。


查找入口点并返回

这一步调用主程序镜像的getEntryFromLC_MAIN(),从加载命令读取LC_MAIN入口,如果没有LC_MAIN就调用getEntryFromLC_UNIXTHREAD()读取LC_UNIXTHREAD,找到后就跳到入口点指定的地址并返回。

至此,整个dyld的加载过程就分析完成了。

总结下:

dyld3的加载流程如下:

第一步:设置运行环境。

第二步:加载共享缓存。

第三步:检查启动闭包。

第四步:构建启动闭包。

第五步:加载启动闭包。(这一步包括dyld2中的3、4、5、6、7、8)

第六步:找到入口点并返回。

dyld2的加载流程如下:

第一步:设置运行环境。

第二步:加载共享缓存。

第三步:实例化主程序。

第四步:加载插入的动态库。

第五步:链接主程序。

第六步:链接插入的动态库。

第七步:执行弱符号绑定

第八步:执行初始化方法。

第九步:查找入口点并返回。

那么我们现在既然了解了dyld的加载流程,那么在这个阶段我们能做什么具体的优化呢?

1、避免链接无用的 frameworks,在 Xcode 中检查一下项目中的「Linked Frameworks and Librares」部分是否有无用的链接

2、避免在启动时加载动态库,将项目的 Pods 以静态编译的方式打包,尤其是 Swift 项目,这地方的时间损耗是很大的

3、硬链接你的依赖项,这里做了缓存优化

也许有人会有疑惑,现在都使用了 dyld3 了,我们就不需要做 Static Link 了,其实还是需要的,这里放2张对比图给大家看看。

Dyld 2 和 Dyld 3启动时间的对比:

Static linking 和 Dyld 3启动时间对比:

结果很明显,可以看出,在冷启动时Dyld3 比 Dyld2快20%,静态链接依旧是比dyld3要快,提升了23%左右。

dyld2加载流程图如下:

你可能感兴趣的:(重学iOS系列之APP启动(二)dyld)