+load 和 main() 谁先调用?有经验的 iOSer 们会毫不犹豫的回答出来是 load 方法,但为什么是 load 方法呢?今天我们来探讨一下底层的原理

新建一个项目,在 AppDelegate 里添加 load 方法,打上一个断点就会看到如下图所示的调用堆栈,如果觉得左侧太长了看不全,可以在控制台输入 bt 指令查看调用堆栈

image.png

从调用堆栈中我们可以看到,程序由 dyld 的 _dyld_start 函数开始,一步一步的层层调用,最终到了我们 Demo 程序的 [AppDelegate load] 方法中。看上去这个 dyld 也是一个程序,就是这个 dyld 程序在启动我们的 APP

简介

dyld(the dynamic link editor)是苹果的动态链接器,是苹果操作系统一个重要组成部分,在系统内核做好程序准备工作之后,交由 dyld 负责余下的工作。而且它是开源的,任何人可以通过苹果官网下载它的源码来阅读理解它的运作方式,了解系统加载动态库的细节。

WWDC 从 2016,2017 到 2019 都有 session 对 APP 启动的过程以及如何优化做过介绍,直到 WWDC2019(iOS 13)才把 Dyld3 开放给所有 APP,在 iOS 13 系统中,iOS 将全面采用新的 dyld 3 以替代之前版本的 dyld 2。 因为 dyld 3 完全兼容 dyld 2,API 接口是一样的,所以在大部分情况下,开发者不需要做额外的适配就能平滑过渡。

现在网上大多数关于 dyld 的文章介绍都是基于 dyld2 版本的,虽然现在已经是 dyld3 版本了,但 dyld3 也并非是对 dyld2 的完全重构,dyld2 里的主要流程在 dyld3 里面依然存在,所以我这篇文章也会先介绍一下 dyld2 中的 9 个主要流程,最后再简单介绍一下 dyld3 做了哪些优化

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

从调用堆栈跟踪

我们先跟着刚刚的调用堆栈来跟踪一遍,首先打开我们刚刚下载的 dyld 源码,搜索我们刚刚在调用堆栈里看到的最外层的调用 _dyld_start

_dyld_start

image.png

从截图中可以看到,搜索结果的第一部分和第二部分,都不可能是我们想要找的东西,第一部分 .xcconfig 像是配置文件,第二部分都是注释。那么肯定只有第三部分了,其实一开始看到这个第三部分 dyldStartup.s 文件内心是崩溃的…作为非科班 iOSer,虽然学过 C,Objective-C,Swift 也了解过一点点 C++,但是这个 .s 文件真不认识。

点开这个 .s 文件后,看到一堆指令,猜测应该是汇编代码了,这下头更大了,不过好在是汇编,估计大家都挺难懂的,所以苹果的注释给的也很到位,根据搜索结果综合注释来看,找到 arm64 架构并且不是模拟器的这部分也还算简单,然后看到 bl 指令上面有一段注释,跟我们刚刚调用堆栈里面的第 8 帧是一模一样的就能猜到个大概了… dyld 程序由 _dyld_start 开始,执行到这个 bl 指令后开始调用 start 函数了,那么接下来就是搜索这个 start 函数在哪了

start

这个 start 一开始还不好搜,直接搜 start 会发现有 3125 个结果在 363 个文件里…作者表示看到这个结果头很大,于是想了个办法,我要找的是个函数的声明,那么后面必定紧跟着一个 ( 符号,重新搜索一番后发现好了很多,只有 173 个结果在 118 个文件了。头虽然没那么大了,但是这 173 个结果要一个个去找去看,也还是蛮费时间的…

最后灵机一动,尝试在 start( 前面加一个空格,好家伙,这不就出现了么,结合一下注释,还有函数的参数和调用堆栈第 8 帧里一模一样的就确定了。如果是有 C++ 基础的同学应该就更加好找了 dyldbootstrap 是命名空间直接搜就会找到一份文件,再在该文件里搜 start( 很快就找到了

image.png

函数的 return 的前面一行有一个 appsSlide 变量,这个变量是 ASLR地址空间配置随机加载技术 的应用,这是是一种防范内存损坏漏洞被利用的计算机安全技术

这个 start 函数里面的最后一行代码调用了 _main() 函数,这个比较舒服,不需要我们再去找了,直接按住 command 然后左键点击就可以跳转到对应的实现代码了

_main

这个 _main 函数就是启动我们 APP 的关键代码,从它的行数就可以看出来它的分量了,从 6455 行到 7303 一共 848 行,这里就不贴它的全部源码了。好家伙,还是头一回看到一个 C 函数写这么长的,本来是想吐槽一下的,但一想到这是苹果的工程师写的底层代码,咱还是老老实实看源码…这部分的代码就是今天重点中的重点,我们在下一部分重点讲这个函数,现在还是接着调用堆栈继续往后面走,后面的几个都挺好找的

image.png

initializeMainExecutable

这个 initializeMainExecutable 函数在 _main 里面调用了两次,不过是分不同架构的,也是可以直接 command 加左键定位到函数实现的,从这个函数里的注释中可以看到,是先执行的所有插入的库的 initialzers 方法,再执行我们主程序的 initialzers 方法的,这也说明了我们写的 Framework 中的 load 方法会比我们主程序的 load 方法先执行。如果不认为 run initialzers 就是调用 load 方法,可以跟着调用堆栈流程走完,你就会知道到底是不是了

image.png

runInitializers

这个 runInitializers 函数也是在上面 initializeMainExecutable 函数里面调用了两次,从两次调用的注释来看,上面调用的是所有插入的动态库的初始化方法,而下面的才是我们主程序调用初始化方法,所以在 [AppDelegate laod] 方法中打的断点卡住的应该是下面的这段代码。这个函数也比较好找,直接搜 runInitializers( 只有 12 个结果在 5 个文件里,再结合它前面的 ImageLoader 作用域,函数的参数,很快就能找到它的实现

image.png

processInitializers

结合调用堆栈,找到 runInitializers 里面的 processInitializers 函数实现很容易,找到 processInitializers 函数的实现也很简单,可以直接 command 加左键点击就到了

image.png

recursiveInitialization

同样是在 ImageLoader.cpp 文件内,processInitializers 就能直接 command 加左键定位到实现代码,而 recursiveInitialization 却不可以,不知道为什么,不过也没什么大问题,recursiveInitialization 在当前文件一搜就找到实现了

image.png

这里有个小细节可以说一下,在第一次 content.notifySingle() 之后有一个 doInitialization 函数,这个函数里面又会有两个初始化函数

image.png

doInitialization() 内部首先调用 doImageInit 来执行镜像的初始化函数,也就是 LC_ROUTINES_COMMAND 中记录的函数。

再执行 doModInitFunctions() 方法来解析并执行 _DATA_,__mod_init_func 这个 section 中保存的函数。使用 __attribute__((constructor)) 开头的 C 函数会保存在这里面,如图所示:

image.png

notifySingle

notifySingle 的调用同样是在上一个函数 recursiveInitialization 的实现里面,但是 notifySingle 的实现结合调用堆栈它前面的作用域来看,不在 ImageLoader 里面,那就直接全局搜索 notifySingle( 也比较容易找

image.png

接下来,由 notifySingleload_images 会发现调用的地方已经不是在 dyld 了…那么如何实现代码的执行从一个程序跳到另一个程序呢?有很多种办法,通知,代理,block,函数指针,函数作为参数传递,我们仔细观察一下 notifySingle 里面有没有以上任何一种,会发现下面这里有一个不太一样的地方 sNotifyObjCInit

image.png

那接下来就看看这个 sNotifyObjCInit 变量是在哪里被赋值的,搜索一番后发现在这里被赋值了

image.png

紧接着搜一搜这个 registerObjCNotifiers 在哪里被调用了

image.png

搜索一番后发现这里是 dyld 提供的对外部的接口…那就说明我们在 dyld 里面应该是找不到这个调用的地方了,不过至少我们找到了这个对外的接口函数_dyld_objc_notify_register,这个时候,我们可以回到最开始新建的项目中去,下一个 _dyld_objc_notify_register 的符号断点

image.png

会发现是 libobjc.A.dylib 的 _objc_init 里面调用了我们的 _dyld_objc_notify_register 函数,这个 libobjc.A.dylib 我们不是头一回看到了,前面的调用堆栈第 1 帧也是在 libobjc.A.dylib 的 load_images 函数,那么这个 libobjc.A.dylib 到底是什么库呢?其实这个 libobjc.A.dylib 就是我们的 Objective-C 的运行时库,好消息是这个库苹果依旧开源了出来,可以免费供大家学习,我这里下载的是 objc4-818.2

Objective-C 运行时库下载:https://opensource.apple.com/tarballs/objc4/

打开下载的 objc4 源码,找到 Products 目录,是不是可以看到一个非常眼熟的东西

image.png

这样看来我们真的找对地方了,那就直接全局搜索我们刚刚获取到的 _dyld_objc_notify_register 函数,看看是不是 _objc_init 里面调用了它

image.png

果然如此,看到这里,就会发现 dyld 里面的 sNotifyObjCInit 变量是被赋值了一个 load_images 的函数,到这里就完全能解释的通,从调用堆栈的第 2 帧到第 1 帧的执行了,这个 load_images 可以直接按住 command 点击定位到实现代码

load_images

接下来就是看怎么从 libobjc.A.dylib 的 load_images 函数到我们的断点 [AppDelegate load] 方法的了

image.png

很明显,我们需要查看 call_load_methods 函数

image.png

在这个函数里,我们就可以明显的看到先是调用所有类的 load 方法,然后再调用所有分类中的 load 方法,查看 call_class_loads 的实现

image.png

到这里,我们断点卡住的所有调用堆栈就全部跟踪完毕了

需要了解的是,这个时候我们依然还在 dyld 的 _main 函数里面,连 _main 里面的 initializeMainExecutable 都没有执行完…而我们主程序的 main() 是在什么时候调用的呢?

dyld 的 _main 函数的返回值 result 就是我们主程序的入口,_main 执行完毕之后,会把返回值返回到 start 函数,start 函数又会把返回值返回到我们的最初的入口 _dyld_start 里面的那条 bl 指令后,x0 就是返回的我们主程序入口,接下来一个 mov x16,x0 看注释也知道是将主程序入口地址保存到 x16 寄存器了,再搜索一下 x16 发现后面基本都是各种情况下的 br 或者 braazx16,就是跳转到我们的主程序了,所以我们 APP 的 main 函数远远晚于 load 方法的调用

dyld 的 _main

上面调用堆栈里这么多函数里面,最复杂最长的就是这个 _main 了,其实这个函数里面才是 dyld 启动我们 APP 的主要流程,但是这个函数实在太长了,我们将这个函数分成 9 个主要的部分,我向来讨厌在文章里面贴那种几页几页都翻不完的代码块的,所以下面的介绍都尽量精简

1.获取当前程序架构,设置上下文信息

getHostInfo() 获取当前程序架构

1
getHostInfo(mainExecutableMH, mainExecutableSlide);

接着调用 setContext() 设置上下文信息,包括一些回调函数、参数、标志信息等。设置的回调函数都是 dyld 模块自身实现的,如 loadLibrary() 函数实际调用的是 libraryLocator(),负责加载动态库。代码片断如下:

1
2
3
4
5
static void setContext(const macho_header* mainExecutableMH, int argc, const char* argv[], const char* envp[], const char* apple[])
{
gLinkContext.loadLibrary = &libraryLocator;
gLinkContext.terminationRecorder = &terminationRecorder;
......

2.配置进程是否受限,检查环境变量

configureProcessRestrictions() 用来配置进程是否受限

checkEnvironmentVariables() 检查环境变量

细心的读者可能会注意到,整个过程中有一些 DYLD_PRINT_ 开头的环境变量,比如:

1
2
3
4
if ( sEnv.DYLD_PRINT_OPTS )
printOptions(argv);
if ( sEnv.DYLD_PRINT_ENV )
printEnvironmentVariables(envp);

如果在 Xcode 中配置了这些环境变量,就会在我们的控制台中打印相关的信息。
除了上面的两个外,我下面以另外一个打印启动时间的环境变量 DYLD_PRINT_STATISTICS_DETAILS 为例,这个应该可以作为优化启动时间的参考数据

image.png

image.png

3.加载共享缓存

这里先说明一下,iOS 的共享缓存机制:在 iOS 系统中,每个程序依赖的动态库都需要通过 dyld 一个个加载到内存。然而,很多系统库基本上是每个程序都会用到的,比如 UIKit,Foundation。如果每个程序启动运行的时候都重复的去加载一次,势必会造成运行缓慢,不必要的内存消耗,为了优化启动速度和节约内存消耗,共享缓存机制就出现了。

这里还要说明一下,在 iOS 中,只有苹果内部或拥有特殊权限的开发者才有能力在系统级别创建和分发真正的动态库,普通 iOS 开发者并不能自由开发和分发真正意义上的动态库,动态框架(Framework)也只能在我们自己的 APP 中使用,无法在多个应用间共享。苹果通过严格的权限控制、沙盒机制和审核流程,确保 iOS 平台的安全和稳定性。

所有 iOS 系统的动态库被合并成一个大的缓存文件,放在 /System/Library/Caches/com.apple.dyld/ 目录下,按不同的架构分别保存,想要分析某个系统库,可以从 dyld_shared_cache 里将原始的二进制文件提取出来。感兴趣的可以根据我的第一篇参考文章上的步骤去尝试一下

这一步先调用 checkSharedRegionDisable() 检查共享缓存是否禁用。该函数的 iOS 实现部分仅有一句注释,从注释我们可以推断 iOS 必须开启共享缓存才能正常工作,代码如下:

image.png

接下来调用 mapSharedCache() 加载共享缓存,而 mapSharedCache() 里面实则是调用了 loadDyldCache(),从代码可以看出,共享缓存加载又分为三种情况:

  • 仅加载到当前进程,调用 mapCachePrivate()
  • 共享缓存已加载,不做任何处理。
  • 当前进程首次加载共享缓存,调用 mapCacheSystemWide()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
bool loadDyldCache(const SharedCacheOptions& options, SharedCacheLoadInfo* results)
{
results->loadAddress = 0;
results->slide = 0;
results->errorMessage = nullptr;

#if TARGET_OS_SIMULATOR
// simulator only supports mmap()ing cache privately into process
return mapCachePrivate(options, results);
#else
if ( options.forcePrivate ) {
// mmap cache into this process only
return mapCachePrivate(options, results);
}
else {
// fast path: when cache is already mapped into shared region
bool hasError = false;
if ( reuseExistingCache(options, results) ) {
hasError = (results->errorMessage != nullptr);
} else {
// slow path: this is first process to load cache
hasError = mapCacheSystemWide(options, results);
}
return hasError;
}
#endif
}

mapCachePrivate()mapCacheSystemWide() 里面就是具体的共享缓存解析逻辑,感兴趣的读者可以详细分析。

4.为主程序实例化 ImageLoader

1
sMainExecutable = instantiateFromLoadedImage(mainExecutableMH, mainExecutableSlide, sExecPath);

分析一下函数的名称以及参数,从已加载的镜像实例化,因为传入的是我们的主程序的 MachO 头,主程序 Slide 和路径,所以实例化出来的就是我们的主程序,代码如下:

1
2
3
4
5
6
static ImageLoaderMachO* instantiateFromLoadedImage(const macho_header* mh, uintptr_t slide, const char* path)
{
ImageLoader* image = ImageLoaderMachO::instantiateMainExecutable(mh, slide, path, gLinkContext);
addImage(image);
return (ImageLoaderMachO*)image;
}

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

  • compressed 如果若 Mach-O 存在 LC_DYLD_INFO 和 LC_DYLD_INFO_ONLY 加载命令或 LC_DYLD_CHAINED_FIXUPS 加载命令,则说明是压缩类型的 Mach-O,代码片段如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    switch (cmd->cmd) {
    case LC_DYLD_INFO:
    case LC_DYLD_INFO_ONLY:
    if ( cmd->cmdsize != sizeof(dyld_info_command) )
    throw "malformed mach-o image: LC_DYLD_INFO size wrong";
    dyldInfoCmd = (struct dyld_info_command*)cmd;
    *compressed = true;
    break;
    case LC_DYLD_CHAINED_FIXUPS:
    if ( cmd->cmdsize != sizeof(linkedit_data_command) )
    throw "malformed mach-o image: LC_DYLD_CHAINED_FIXUPS size wrong";
    chainedFixupsCmd = (struct linkedit_data_command*)cmd;
    *compressed = true;
    break;
  • segCount 根据 LC_SEGMENT_COMMAND 加载命令来统计段数量,这里抛出的错误日志也说明了段的数量是不能超过 255 个,代码片段如下:
    1
    2
    if ( *segCount > 255 )
    dyld::throwf("malformed mach-o image: more than 255 segments in %s", path);
  • libCount 根据 LC_LOAD_DYLIBLC_LOAD_WEAK_DYLIBLC_REEXPORT_DYLIBLC_LOAD_UPWARD_DYLIB 这几个加载命令来统计库的数量,库的数量不能超过 4095 个。代码片段如下:
    1
    2
    3
    4
    5
    6
    7
    8
    case LC_LOAD_DYLIB:
    case LC_LOAD_WEAK_DYLIB:
    case LC_REEXPORT_DYLIB:
    case LC_LOAD_UPWARD_DYLIB:
    *libCount += 1;
    ......
    if ( *libCount > 4095 )
    dyld::throwf("malformed mach-o image: more than 4095 dependent libraries in %s", path);
  • codeSigCmd 通过解析 LC_CODE_SIGNATURE 来获取代码签名加载命令,代码片段如下:
    1
    2
    3
    4
    5
    6
    7
    8
    case LC_CODE_SIGNATURE:
    if ( cmd->cmdsize != sizeof(linkedit_data_command) )
    throw "malformed mach-o image: LC_CODE_SIGNATURE size wrong";
    // <rdar://problem/22799652> only support one LC_CODE_SIGNATURE per image
    if ( *codeSigCmd != NULL )
    throw "malformed mach-o image: multiple LC_CODE_SIGNATURE load commands";
    *codeSigCmd = (struct linkedit_data_command*)cmd;
    break;
  • encryptCmd 通过 LC_ENCRYPTION_INFO 和 LC_ENCRYPTION_INFO_64 来获取段的加密信息,代码片段如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    case LC_ENCRYPTION_INFO:
    if ( cmd->cmdsize != sizeof(encryption_info_command) )
    throw "malformed mach-o image: LC_ENCRYPTION_INFO size wrong";
    // <rdar://problem/22799652> only support one LC_ENCRYPTION_INFO per image
    if ( *encryptCmd != NULL )
    throw "malformed mach-o image: multiple LC_ENCRYPTION_INFO load commands";
    *encryptCmd = (encryption_info_command*)cmd;
    break;
    case LC_ENCRYPTION_INFO_64:
    if ( cmd->cmdsize != sizeof(encryption_info_command_64) )
    throw "malformed mach-o image: LC_ENCRYPTION_INFO_64 size wrong";
    // <rdar://problem/22799652> only support one LC_ENCRYPTION_INFO_64 per image
    if ( *encryptCmd != NULL )
    throw "malformed mach-o image: multiple LC_ENCRYPTION_INFO_64 load commands";
    *encryptCmd = (encryption_info_command*)cmd;
    break;
    ImageLoader 是抽象类,其子类负责把 Mach-O 文件实例化为 image,当 sniffLoadCommands()解析完以后,根据 compressed 的值来决定调用哪个子类进行实例化,代码如下:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    // create image for main executable
    ImageLoader* ImageLoaderMachO::instantiateMainExecutable(const macho_header* mh, uintptr_t slide, const char* path, const LinkContext& context)
    {
    bool compressed;
    unsigned int segCount;
    unsigned int libCount;
    const linkedit_data_command* codeSigCmd;
    const encryption_info_command* encryptCmd;
    sniffLoadCommands(mh, path, false, &compressed, &segCount, &libCount, context, &codeSigCmd, &encryptCmd);
    // instantiate concrete class based on content of load commands
    if ( compressed )
    return ImageLoaderMachOCompressed::instantiateMainExecutable(mh, slide, path, segCount, libCount, context);
    else
    #if SUPPORT_CLASSIC_MACHO
    return ImageLoaderMachOClassic::instantiateMainExecutable(mh, slide, path, segCount, libCount, context);
    #else
    throw "missing LC_DYLD_INFO load command";
    #endif
    }

在完成实例化之后,将返回的 image 加入到 sAllImages 加入到全局镜像列表,并将 image 映射到申请的内存中。至此,初始化主程序这一步就完成了

5.加载所有插入的库

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

1
2
3
4
if	( sEnv.DYLD_INSERT_LIBRARIES != NULL ) {
for (const char* const* lib = sEnv.DYLD_INSERT_LIBRARIES; *lib != NULL; ++lib)
loadInsertedDylib(*lib);
}

6.链接主程序

1
link(sMainExecutable, sEnv.DYLD_BIND_AT_LAUNCH, true, ImageLoader::RPathChain(NULL, NULL), -1);

这一步调用 link() 函数将实例化后的主程序进行动态修正,让二进制变为可正常执行的状态。link() 函数内部调用了 ImageLoader::link() 函数,从源代码可以看到,这一步主要做了以下几个事情:

  • recursiveLoadLibraries() 根据 LC_LOAD_DYLIB 加载命令把所有依赖库加载进内存。
  • recursiveUpdateDepth() 递归刷新依赖库的层级。
  • recursiveRebase() 由于 ASLR 的存在,必须递归对主程序以及依赖库进行重定位操作。
  • recursiveBind() 把主程序二进制和依赖进来的动态库全部执行符号表绑定。
  • weakBind() 如果链接的不是主程序二进制的话,会在此时执行弱符号绑定,主程序二进制则在 link() 完后再执行弱符号绑定,后面会进行分析。
  • recursiveGetDOFSections()context.registerDOFs() 注册 DOF(DTrace Object Format)节。

7.链接所有插入的库

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
if ( sInsertedDylibCount > 0 ) {
for(unsigned int i=0; i < sInsertedDylibCount; ++i) {
ImageLoader* image = sAllImages[i+1];
link(image, sEnv.DYLD_BIND_AT_LAUNCH, true, ImageLoader::RPathChain(NULL, NULL), -1);
image->setNeverUnloadRecursive();
}
if ( gLinkContext.allowInterposing ) {
// only INSERTED libraries can interpose
// register interposing info after all inserted libraries are bound so chaining works
for(unsigned int i=0; i < sInsertedDylibCount; ++i) {
ImageLoader* image = sAllImages[i+1];
image->registerInterposing(gLinkContext);
}
}
}

8.执行初始化方法initializeMainExecutable

1
initializeMainExecutable(); 

这个函数在我们刚刚的调用堆栈流程跟踪里面讲到过,我们 Objective-C 对象的 load 方法,库中的 load 方法,还有 C++ 的初始化方法都在这里面被执行了

9.查找主程序入口并返回

1
2
3
4
5
6
7
8
9
10
11
12
13
result = (uintptr_t)sMainExecutable->getEntryFromLC_MAIN();
if ( result != 0 ) {
// main executable uses LC_MAIN, we need to use helper in libdyld to call into main()
if ( (gLibSystemHelpers != NULL) && (gLibSystemHelpers->version >= 9) )
*startGlue = (uintptr_t)gLibSystemHelpers->startGlueToCallExit;
else
halt("libdyld.dylib support not present for LC_MAIN");
}
else {
// main executable uses LC_UNIXTHREAD, dyld needs to let "start" in program set up for main()
result = (uintptr_t)sMainExecutable->getEntryFromLC_UNIXTHREAD();
*startGlue = 0;
}

调用 getEntryFromLC_MAIN() 从 Load Command 读取 LC_MAIN 入口。如果没有 LC_MAIN 入口,就读取 LC_UNIXTHREAD(),然后返回给 start 函数,再返回到 _dyld_start 走完剩下的汇编代码,可以看到最后的汇编代码跳转到了我们程序的入口 jump to the program's entry point

dyld2 和 dyld3

在 iOS 13 之前,所有的第三方 App 都是通过 dyld 2 来启动 App 的,主要过程就是上面所讲的 9 大步骤

上面的所有过程都发生在 App 启动时,包含了大量的计算和 I/O,所以苹果开发团队为了加快启动速度,在 WWDC2017 - 413 - App Startup Time: Past, Present, and Future 上正式提出了 dyld3。

dyld 3 并不是 WWDC19 推出来的新技术,早在 2017 年就被引入至 iOS 11,当时主要用来优化系统库。现在,在 iOS 13 中它也将用于启动第三方 APP。dyld 3 最大的特点就是部分是进程外的且有缓存的,在打开 APP 时,实际上已经有不少工作都完成了。

dyld3 包含三个组件

本 APP 进程外的 Mach-O 分析器/编译器;

在 dyld2 的加载流程中,Parse mach-o headers 和 Find Dependencies 存在安全风险(可以通过修改 mach-o header 及添加非法 @rpath 进行攻击),而 Perform symbol lookups 会耗费较多的 CPU 时间,因为一个库文件不变时,符号将始终位于库中相同的偏移位置,这两部分在 dyld3 中将采用提前写入把结果数据缓存成文件的方式构成一个 “lauch closure“(可以理解为缓存文件)

它处理了所有可能影响启动速度的 search path,@rpaths 和环境变量。它解析 mach-o 二进制文件,分析其依赖的动态库,并且完成了所有符号查找的工作。最后它将这些工作的结果创建成了启动闭包,写入缓存,这样,在应用启动的时候,就可以直接从缓存中读取数据,加快加载速度。

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

out-of-process 是一个普通的后台守护程序,因为从各个 APP 进程抽离出来了,可以提高 dyld3 的可测试性。

本进程内执行”lauch closure“的引擎;

验证 ”lauch closures“ 是否正确,把 dylib 映射到 APP 进程的地址空间里,然后跳转到 main 函数。此时,它不再需要分析 mach-o header 和执行符号查找,节省了不少时间。

”lauch closure“的缓存:

iOS 操作系统内置 APP 的 ”lauch closure“ 直接内置在 shared cache 共享缓存中,我们甚至不需要打开一个单独的文件。而对于第三方 APP,将在 APP 安装或更新版本时(或者操作系统升级时?)生成 lauch closure 启动闭包,因为那时候的系统库已经发生更改。这样就能保证 ”lauch closure“ 总是在 APP 打开之前准备好。启动闭包会被写到到一个文件里,下次启动则直接读取和验证这个文件。

总结

dyld3 把很多耗时的查找、计算和 I/O 的事前都预先处理好了,这使得启动速度有了很大的提升。dyld3 在 _main 函数里面和 dyld2 最大的不同之处在于,在 dyld2 的第三步和第四步之间,插入了使用 Closure 启动的逻辑。在最新的 dyld 源码里面可以看到,在第三步加载共享缓存之后,会判断 sClosureMode 模式,并尝试通过 Closure 的方式启动,如果启动成功了就直接 return 了,后面的代码就不执行了,当然 launchWithClosure() 里面会有 dyld3 的新处理逻辑,感兴趣的同学可以自行前往查看源码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
if ( sClosureMode == ClosureMode::Off ) {
if ( gLinkContext.verboseWarnings )
dyld::log("dyld: not using closures\n");
} else {
...
// try using launch closure
if ( mainClosure != nullptr ) {
...
bool launched = launchWithClosure(mainClosure, sSharedCacheLoadInfo.loadAddress, (dyld3::MachOLoaded*)mainExecutableMH,
mainExecutableSlide, argc, argv, envp, apple, diag, &result, startGlue, &closureOutOfDate, &recoverable);
...
if ( launched ) {
gLinkContext.startedInitializingMainExecutable = true;
if (sSkipMain)
result = (uintptr_t)&fake_main;
return result;
}
}

总体来说,dyld3 把很多耗时的操作都提前处理好了,极大提升了启动速度。了解 dyld 对 APP 的启动过程有一个更全面的认识,对 APP 的安全防护,注入手段,启动速度的优化都需要对 dyld 有深入的理解。

这篇文章主要参考了以下几篇文章: