应用程序的加载流程

309 阅读9分钟

对于 App 的启动流程,我们一般对于 main() 函数之后的流程比较熟悉,那么我们来探索一下 main() 函数之前的启动流程

准备知识

MachO 文件

可运行程序会被编译成一个二进制文件,unix 对于二进制文件,有一个标准的格式叫 ELF。苹果系统的格式就叫 Mach-O 格式,全称 Mach-ObjectMach-O 不仅仅只是可执行文件的标准格式,还是 macOS/iOS 中一些其他文件的标准格式。常见的 Mach-O 格式有下面几种:

  • Executable, 产物为 ipa 包

image.png

  • Dynamic Library, 产物为动态库
  • Bundle, 产物为 bundle 文件
  • Static Library, 产物为静态库
  • Relocatable Object File, 重定向文件

动态库和静态库

在开发中,程序会依赖系统库,这些库就是一些可执行的二进制文件,能被操作系统加载到内存里。库分为两种:

静态库

静态库是一堆 .o 文件的集合,格式为 .a, .lib, .framework 等。链接阶段静态库会被完整地复制,一起打包在可执行文件中,被多次使用就会有冗余拷贝

  • 优点:编译完成之后,链接到目标程序中,同时打包到可执行文件里,不会有外部依赖,静态库比动态库加载快得多
  • 缺点:因为是复制进去的,所以会存在两份,导致目标程序体积增大,并且相同静态库每个 App 中都会拷贝一份

image.png

动态库

动态库是一个已经完全链接后的镜像,格式为 .framework 等。在程序编译时并不会链接到目标程序中,目标程序只会存储指向动态库的引用,在程序运行时才被载入。苹果大部分用的都是动态库,比如 UIKitFoundationdispatch

  • 优点:不需要拷贝到目标程序,可以减少 App 包的体积,多个 App 可以使用同一个动态库共享内存,节约资源;由于只有运行时才会去加载,那么可以在 App 不使用时随时对库进行替换或更新,更加灵活。
  • 缺点:动态载入会造成一部分性能损失,同时动态库也会使得程序依赖于外部环境。一旦动态库消失或没有,程序就会出现问题

image.png

注意: 平时自己写的 .framework 严格意义上来说不是动态库

生成可执行文件的流程

预编译

处理源代码文件中以 # 开头的预编译指令(#import / #include 等),把包含的文件插入到指定位置;替换代码中的宏定义,删除代码中的注释等,同时产生 .i 文件

编译

进行词法分析,语法分析,语义分析,生成相应的汇编代码文件。产生 .s 文件(汇编文件)

汇编

将汇编代码翻译成机器指令,机器指令会产生 .o 文件。

链接

把之前所有操作的文件链接到程序里面来,对 .o 文件中引用其他库的地方进行引用,生成最后的可执行文件也就是 Mach-O

注意: 动态库与静态库区别就是链接的区别。静态链接时链接器对 .o 文件进行符号收集、解析和重定位,把所有的 .o 结合在一起从而形成可执行文件。(在静态链接之后就没有静态库了),在动态链接时把动态库的信息写入 Mach-O 文件,然后在启动过程中通过 dyld 来加载对应的动态库。

App 启动流程

点击 App 后,系统调用 exec() 函数,系统将对应的 Mach-O 文件加载进内存,同时再将 dyld 加载进内存。dyld 就会进行动态链接,这个步骤需要重点关注,最后调用 main 函数启动。

dyld 的源码是开源的,可以在苹果官网 openSource 里面找到,通过阅读 dyld 来了解 main 函数之前的启动流程。注意 iOS 15 及之后是 dyld4, 之前是 dyld3,下面以 dyld-941.5 版本来分析

想了解 main 函数前的信息,需要通过 load 方法打断点了,然后打印出堆栈信息:

image.png

rebasebinding

Mach-O 文件中的符号地址都是虚拟地址,在程序启动时,系统会生成一个随机数(ALSR),使用虚拟地址加上 ALSR 才是物理地址,也就是程序真正调用的地址。我们把从虚拟地址换算成物理地址的过程称为 rebase。 在编译过程中,可执行文件对动态库的方法调用只声明了符号。在调用 dyld 把这些动态库加载到内存后,需要去相应的动态库中链接对应的方法,找到其指针,然后对可执行文件中的指针进行修复,binding 过程就是把这些指向动态库的指针进行修复。这边不深入讨论

start 函数是 dyld 的开始

从堆栈信息看,dyld 是从 start 函数开始的,源码中找到 start 函数:

image.png

结合之前的堆栈信息,发现 start 函数主要作用是调用 prepare 函数,并调用 main 函数

prepare 加载依赖

从源码分析,整个 prepare 是获取 main 函数,并且做了一些初始化的准备工作,如下:

环境、路径、平台信息的准备

image.png

创建加载器 mainLoader

image.png

这里会去获取 PrebuiltLoaderSet 中的 mainLoader, 如果没有 mainLoader 就会去创建一个 just-in-time 的新 mainLoader

just-in-time

dyld4 的一个新特性,dyld4 在保留了 dyld3mach-o 解析器基础上,同时也引入了 just-in-time 的加载器来优化。 dyld3 出于对启动速度优化的目的,增加了预构建(闭包)。App 第一次启动或者 App 发生变化时会将部分数据创建为闭包存到本地,那么 App 下次启动将不再重新解析数据,而是直接读取闭包内容。当然前提是应用程序和系统应很少发生变化,但如果这两者经常变化,就会导致闭包丢失或失效。

dyld4 采用了 pre-built + just-in-time 的双解析模式,预构建 pre-built 对应的就是 dyld3 中的闭包,just-in-time 可以理解为实时解析。当然 just-in-time 也是可以利用 pre-built 的缓存的,所以性能可控。有了 just-in-time,应用首次启动、系统版本更新、普通启动,dyld4 则可以根据缓存是否有效去选择合适的模式进行解析

加载需要插入动态库的信息

image.png

遍历所有的动态库信息,加载需要的信息,这里有可能出现动态库依赖其他动态库的情况,因此是递归加载

插入缓存

image.png

运行所有的初始化

image.png

返回 main 函数

image.png 这回返回 main 函数,然后 prepare 下一步就是调用 main 函数

image.png

runAllInitializersForMain

对于上述 prepare 中的流程,我们只要看重 runAllInitializersForMain 即可,它是 App 初始化所有信息的位置,也就是 App 启动最核心的部分,下面来分析一下:

// This is factored out of dyldMain.cpp to support old macOS apps that use crt1.o 
void APIs::runAllInitializersForMain()
{
    // run libSystem's initializer first
    const_cast<Loader*>(this->libSystemLoader)->beginInitializers(*this);
    this->libSystemLoader->runInitializers(*this);
    gProcessInfo->libSystemInitialized = true;

    // after running libSystem's initializer, tell objc to run any +load methods on libSystem sub-dylibs
    this->notifyObjCInit(this->libSystemLoader);
    // <rdar://problem/32209809> call 'init' function on all images already init'ed (below libSystem)
    // Iterate using indices so that the array doesn't grow underneath us if a +load dloopen's
    for ( uint32_t i = 0; i != this->loaded.size(); ++i ) {
        const Loader* ldr = this->loaded[i];
        if ( ldr->analyzer(*this)->isDylib() && (strncmp(ldr->analyzer(*this)->installName(), "/usr/lib/system/lib", 19) == 0) ) {
            // check install name instead of path, to handle DYLD_LIBRARY_PATH overrides of libsystem sub-dylibs
            const_cast<Loader*>(ldr)->beginInitializers(*this);
            this->notifyObjCInit(ldr);
        }
    }

    // run all other initializers bottom-up, running inserted dylib initializers first
    // Iterate using indices so that the array doesn't grow underneath us if an initializer dloopen's
    for ( uint32_t i = 0; i != this->loaded.size(); ++i ) {
        const Loader* ldr = this->loaded[i];
        ldr->runInitializersBottomUpPlusUpwardLinks(*this);
        // stop as soon as we did main executable
        // normally this is first image, but if there are inserted N dylibs, it is Nth in the list
        if ( ldr->analyzer(*this)->isMainExecutable() )
            break;
    }
}

可以看出以下几点:

  • 第一步加载 libSystem
  • libSystem 加载完毕后,通知 objc/runtime 去调用 libSystem 所有子动态库的 +load 方法
  • 最后递归地加载其他的的动态库(runInitializersBottomUpPlusUpwardLinks),先加载依赖的库再加载自己

image.png image.png

notifyObjcInit 函数来源

上述 runInitializersBottomUp 源码中发现加载动态库时会调用 notifyObjCInit 函数,去通知 objc 调用 +load, 下面来追踪一下这个函数:

image.png

image.png 这个函数会调用 _notifyObjCInit, 本质是 _dyld_objc_notify_init 类型的变量,全局搜索 _notifyObjCInit,发现它只在 setObjCNotifiers 中赋值的:

image.png

setObjCNotifiers 是在 _dyld_objc_notify_register 中调用的,在 dyld4 源码中已经找不到哪里调用了 _dyld_objc_notify_register,因此是个外部调用,可以通过符号断点(符号断点在第一篇文章中讲过)在项目中查看,打印堆栈信息:

image.png

可以看到 _dyld_objc_notify_register 是在 libobjc 中通过 _objc_init 调用的

objc 源码查看 _objc_init

image.png

结合 dyld4 里面的 setObjCNotifiers,对应一下参数:

_notifyObjCMapped = map_images
_notifyObjCInit = load_images
_notifyObjCUnmapped = unmap_image

也就是说先调用 map_images 再调用 load_images

总结

App 的启动过程从 dyld 库中的 start 函数开始,prepare 函数中加载平台、环境等信息,随后创建加载器,加载需要的动态库信息,再将动态库的信息插入缓存中,然后 runAllInitializersForMain 开始初始化所有的信息,返回 main 函数地址开始调用

runAllInitializersForMain 中,先加载 libSystem, 在初始化 libSystem 时会初始化 libdispatch 加载多线程环境,然后初始化 libobjc, 通过 libobjc_objc_init 来注册 notifyObjCInit 函数,也就是 load_images 函数。libSystem、libdispatch、libobjc 库加载完毕后,会调用 notifyObjCInit 来调用 +load 来初始化类的信息

image.png

拓展:

为什么 libsystem 要第一个初始化?

从上面的堆栈信息可以看出,在调用 libSystem_initializer 之后还调用了 libdispatch_init_os_object_init, 而且这两个函数都是在 libdispatch 中,也就是说存在以下依赖关系:libobjc -> libdispatch -> libSystem, 所以 libsystem 要第一个初始化

为什么 +loadmain 函数之前执行?

dyld 源码中可以发现 objc_notify_register 传了 load_image,而 objc_notify_registerprepare 阶段的操作,prepare 之后就会调用 main 函数

framework 是打包的一种方式,里面可以包含动态库、静态库或资源文件

includeimport@import 的区别

  • include 多次导入同一个文件会导致重复定义的问题
  • import 多次导入不会报错,因为 import 指令会判断之前是否导入过,如果已经导入过就不会再重新导入
  • @import 想要提高编译速度,可以把所有头文件引用都放到 pch 中。但是这样面临的问题是在工程中随处可用本来不应该能访问的东西,而编译器也无法准确给出错误或者警告,无形中增加了出错的可能性。 Modules 会在实际编译时加入了一个用来存放已编译添加过的 Modules 列表。首先在Modules 列表内查找,如果在编译的文件中引用到某个 Module,则直接使用;如果没有,则把引用的头文件编译后加入到这个表中。这样被引用到的 Modules 只会被编译一次,也避免了在工程中随处都能访问可能不该访问的东西。