对于 App 的启动流程,我们一般对于 main() 函数之后的流程比较熟悉,那么我们来探索一下 main() 函数之前的启动流程
准备知识
MachO 文件
可运行程序会被编译成一个二进制文件,unix 对于二进制文件,有一个标准的格式叫 ELF。苹果系统的格式就叫 Mach-O 格式,全称 Mach-Object。Mach-O 不仅仅只是可执行文件的标准格式,还是 macOS/iOS 中一些其他文件的标准格式。常见的 Mach-O 格式有下面几种:
Executable, 产物为 ipa 包
Dynamic Library, 产物为动态库Bundle, 产物为 bundle 文件Static Library, 产物为静态库Relocatable Object File, 重定向文件
动态库和静态库
在开发中,程序会依赖系统库,这些库就是一些可执行的二进制文件,能被操作系统加载到内存里。库分为两种:
静态库
静态库是一堆 .o 文件的集合,格式为 .a, .lib, .framework 等。链接阶段静态库会被完整地复制,一起打包在可执行文件中,被多次使用就会有冗余拷贝
- 优点:编译完成之后,链接到目标程序中,同时打包到可执行文件里,不会有外部依赖,静态库比动态库加载快得多
- 缺点:因为是复制进去的,所以会存在两份,导致目标程序体积增大,并且相同静态库每个 App 中都会拷贝一份
动态库
动态库是一个已经完全链接后的镜像,格式为 .framework 等。在程序编译时并不会链接到目标程序中,目标程序只会存储指向动态库的引用,在程序运行时才被载入。苹果大部分用的都是动态库,比如 UIKit、Foundation、dispatch 等
- 优点:不需要拷贝到目标程序,可以减少 App 包的体积,多个 App 可以使用同一个动态库共享内存,节约资源;由于只有运行时才会去加载,那么可以在 App 不使用时随时对库进行替换或更新,更加灵活。
- 缺点:动态载入会造成一部分性能损失,同时动态库也会使得程序依赖于外部环境。一旦动态库消失或没有,程序就会出现问题
注意: 平时自己写的 .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 方法打断点了,然后打印出堆栈信息:
rebase 和 binding
Mach-O 文件中的符号地址都是虚拟地址,在程序启动时,系统会生成一个随机数(ALSR),使用虚拟地址加上 ALSR 才是物理地址,也就是程序真正调用的地址。我们把从虚拟地址换算成物理地址的过程称为 rebase。 在编译过程中,可执行文件对动态库的方法调用只声明了符号。在调用 dyld 把这些动态库加载到内存后,需要去相应的动态库中链接对应的方法,找到其指针,然后对可执行文件中的指针进行修复,binding 过程就是把这些指向动态库的指针进行修复。这边不深入讨论
start 函数是 dyld 的开始
从堆栈信息看,dyld 是从 start 函数开始的,源码中找到 start 函数:
结合之前的堆栈信息,发现 start 函数主要作用是调用 prepare 函数,并调用 main 函数
prepare 加载依赖
从源码分析,整个 prepare 是获取 main 函数,并且做了一些初始化的准备工作,如下:
环境、路径、平台信息的准备
创建加载器 mainLoader
这里会去获取 PrebuiltLoaderSet 中的 mainLoader, 如果没有 mainLoader 就会去创建一个 just-in-time 的新 mainLoader
just-in-time
dyld4的一个新特性,dyld4在保留了dyld3的mach-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则可以根据缓存是否有效去选择合适的模式进行解析
加载需要插入动态库的信息
遍历所有的动态库信息,加载需要的信息,这里有可能出现动态库依赖其他动态库的情况,因此是递归加载
插入缓存
运行所有的初始化
返回 main 函数
这回返回
main 函数,然后 prepare 下一步就是调用 main 函数
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),先加载依赖的库再加载自己
notifyObjcInit 函数来源
上述 runInitializersBottomUp 源码中发现加载动态库时会调用 notifyObjCInit 函数,去通知 objc 调用 +load, 下面来追踪一下这个函数:
这个函数会调用
_notifyObjCInit, 本质是 _dyld_objc_notify_init 类型的变量,全局搜索 _notifyObjCInit,发现它只在 setObjCNotifiers 中赋值的:
而 setObjCNotifiers 是在 _dyld_objc_notify_register 中调用的,在 dyld4 源码中已经找不到哪里调用了 _dyld_objc_notify_register,因此是个外部调用,可以通过符号断点(符号断点在第一篇文章中讲过)在项目中查看,打印堆栈信息:
可以看到 _dyld_objc_notify_register 是在 libobjc 中通过 _objc_init 调用的
objc 源码查看 _objc_init:
结合 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 来初始化类的信息
拓展:
为什么 libsystem 要第一个初始化?
从上面的堆栈信息可以看出,在调用 libSystem_initializer 之后还调用了 libdispatch_init 和 _os_object_init, 而且这两个函数都是在 libdispatch 中,也就是说存在以下依赖关系:libobjc -> libdispatch -> libSystem, 所以 libsystem 要第一个初始化
为什么 +load 在 main 函数之前执行?
在 dyld 源码中可以发现 objc_notify_register 传了 load_image,而 objc_notify_register 是 prepare 阶段的操作,prepare 之后就会调用 main 函数
framework 是打包的一种方式,里面可以包含动态库、静态库或资源文件
include、import、@import 的区别
include多次导入同一个文件会导致重复定义的问题import多次导入不会报错,因为import指令会判断之前是否导入过,如果已经导入过就不会再重新导入@import想要提高编译速度,可以把所有头文件引用都放到pch中。但是这样面临的问题是在工程中随处可用本来不应该能访问的东西,而编译器也无法准确给出错误或者警告,无形中增加了出错的可能性。 Modules 会在实际编译时加入了一个用来存放已编译添加过的 Modules 列表。首先在Modules 列表内查找,如果在编译的文件中引用到某个 Module,则直接使用;如果没有,则把引用的头文件编译后加入到这个表中。这样被引用到的 Modules 只会被编译一次,也避免了在工程中随处都能访问可能不该访问的东西。