iOS 底层探索系列前端
App
从被用户在主屏幕上点击以后就开启了它的生命周期,那么在这之中,究竟发生了什么呢?让咱们从 App
启动开始探索。在探索以前,咱们须要熟悉一些前导知识点。git
如下参考自 WWDC 2016 Optimizing App Startup Time
:github
Mach-O is a bunch of file types for different run time executables.
Mach-O
是iOS
系统不一样运行时期可执行的文件的文件类型统称。sql
维基百科上关于 Mach-O
的描述:bootstrap
Mach-O 是 Mach object 文件格式的缩写,它是一种用于记录可执行文件、对象代码、共享库、动态加载代码和内存转储的文件格式。做为 a.out 格式的替代品,Mach-O 提供了更好的扩展性,并提高了符号表中信息的访问速度。 大多数基于 Mach 内核的操做系统都使用 Mach-O。NeXTSTEP、OS X 和 iOS 是使用这种格式做为本地可执行文件、库和对象代码的例子。数组
Mach-O
有三种文件类型: Executable
、Dylib
、Bundle
xcode
Executable
类型So the first executable, that's the main binary in an app, it's also the main binary in an app extension.
executable
是app
的二进制主文件,同时也是app extension
的二进制主文件缓存
咱们通常能够在 Xcode
项目中的 Products
文件夹中找到它:安全
如上图箭头所示,App加载流程
就是咱们 App
的二进制主文件。bash
Dylib
类型A dylib is a dynamic library, on other platforms meet, you may know those as DSOs or DLLs.
dylib
是动态库,在其余平台也叫DSO
或者DLL
。
对于接触 iOS
开发比较早的同窗,可能知道咱们在 Xcode 7
以前添加一些好比 sqlite
的库的时候,其后缀名为 dylib
,而 Xcode 7
以后后缀名都改为了 tbd
。
这里引用 StackoverFlow 上的一篇回答。
So it appears that the .dylib file is the actual library of binary code that your project is using and is located in the /usr/lib/ directory on the user's device. The .tbd file, on the other hand, is just a text file that is included in your project and serves as a link to the required .dylib binary. Since this text file is much smaller than the binary library, it makes the SDK's download size smaller. 看起来
.dylib
文件是项目中真正使用到的二进制库文件,它位于用户设备上的/usr/lib
目录下。而.tbd
文件,只是位于你项目中的一个文本文件,它扮演的是连接到真正的.dylib
二进制文件的角色。由于文本文件的大小远远小于二进制文件的大小,因此让Xcode 的
SDK` 的下载大小更小。
这里再插一句,那么有动态库,确定就有静态库,它们的区别是什么呢?
咱们先梳理一下整个的编译过程。
固然,这个过程当中间其实还设计到编译器前端的 词法分析
、语法分析
、语义分析
、优化
等流程,咱们在后面探索 LLVM
和 Clang
的时候会详细介绍。
回到刚才的话题,静态库和动态库的区别:
Static frameworks are linked at compile time. Dynamic frameworks are linked at runtime.
静态库和动态库都是编译好的二进制文件,只是用法不一样。那为何要分动态和静态库呢?
经过上面两幅图咱们能够知道:
Linked Framework and Libraries
设置的一些 share libraries
。【随着程序启动而启动】dlopen
等经过代码或者命令的方式来加载。【在程序启动以后】Bundle
类型Now a bundle's a special kind of dylib that you cannot link against, all you can do is load it at run time by an dlopen and that's used on a Mac OS for plug-ins. 现阶段
Bundle
是一种特殊类型的dylib
,你是没法对其进行连接的。你所能作的是在Runtime
运行时去经过dlopen
来加载它,它能够在macOS
上用于插件。
Image
和 Framework
Image refers to any of these three types. 镜像文件包含了上述的三种文件类型
a framework is a dylib with a special directory structure around it to holds files needed by that dylib. 有不少东西都叫作
Framework
,但在本文中,Framework
指的是一个dylib
,它周围有一个特殊的目录结构来保存该dylib
所需的文件。
Mach-O
镜像文件是由 segments
段组成的。
page size
的倍数。16
字节4
字节这里再普及一下虚拟内存和内存页的知识:
具备
VM
机制的操做系统,会对每一个运行的进程建立一个逻辑地址空间logical address space
或者叫虚拟地址空间virtual address space
;该空间的大小由操做系统位数决定:32
位的操做系统,其逻辑地址空间的大小为4GB
,64位的操做系统为18 exabyes
(其计算方式是2^32
||2^64
)。
虚拟地址空间(或者逻辑地址空间)会被分为相同大小的块,这些块被称为内存页(page)。计算机处理器和它的内存管理单元(MMU - memory management uinit)维护着一张将程序的逻辑地址空间映射到物理地址上的分页表
page table
。
在
masOS
和早版本的iOS
中,分页的大小为4kB
。在以后的基于A7
和A8
的系统中,虚拟内存(64
位的地址空间)地址空间的分页大小变为了16KB
,而物理RAM上的内存分页大小仍然维持在4KB
;基于A9及以后的系统,虚拟内存和物理内存的分页都是16KB
。
在 segment
段内部还有许多的 section
区。section
名称为小写格式。
But sections are really just a subrange of a segment, they don't have any of the constraints of being page size, but they are non-overlapping. 可是
sections
节实际上只是一个segment
段的子范围,它们没有页面大小的任何限制,可是它们是不重叠的。
经过 MachOView
工具查看 app
的二进制可执行文件能够查看到:
segments
__TEXT
:代码段,包括头文件、代码和常量。只读不可修改__DATA
:数据段,包括全局变量, 静态变量等。可读可写。__LINKEDIT
:如何加载程序, 包含了方法和变量的元数据(位置,偏移量),以及代码签名等信息。只读不可修改。Mach-O
通用文件,将多种架构的 Mach-O
文件合并而成。它经过 header
来记录不一样架构在文件中的偏移量,segement
占多个分页,header
占一页的空间。可能有人会以为 header
单独占一页会浪费空间,但这有利于虚拟内存的实现。
虚拟内存是一层间接寻址。
虚拟内存解决的是管理全部进程使用物理 RAM 的问题。经过添加间接层来让每一个进程使用逻辑地址空间,它能够映射到 RAM 上的某个物理页上。这种映射不是一对一的,逻辑地址可能映射不到 RAM 上,也可能有多个逻辑地址映射到同一个物理 RAM 上。
page fault
。mmap()
的方式读取。也就是把文件某个片断映射到进程逻辑内存的某个页上。当某个想要读取的页没有在内存中,就会触发 page fault
,内核只会读入那一页,实现文件的懒加载。也就是说 Mach-O
文件中的 __TEXT
段能够映射到多个进程,并能够懒加载,且进程之间共享内存。__DATA
段是可读写的。这里使用到了 Copy-On-Write
技术,简称 COW
。也就是多个进程共享一页内存空间时,一旦有进程要作写操做,它会先将这页内存内容复制一份出来,而后从新映射逻辑地址到新的 RAM
页上。也就是这个进程本身拥有了那页内存的拷贝。这就涉及到了 clean/dirty page
的概念。dirty page
含有进程本身的信息,而 clean page
能够被内核从新生成(从新读磁盘)。因此 dirty page
的代价大于 clean page
。Mach-O
镜像时 __TEXT
和 __LINKEDIT
由于只读,都是能够共享内存的,读取速度就会很快。__DATA
由于可读写,就有可能会产生 dirty page
,若是检测到有 clean page
就能够直接使用,反之就须要从新读取 DATA page
。一旦产生了 dirty page
,当 dyld
执行结束后,__LINKEDIT
须要通知内核当前页面再也不须要了,当别人须要的使用时候就能够从新 clean
这些页面。ASLR
(Address Space Layout Randomization) 地址空间布局随机化,镜像会在随机的地址上加载。
可能咱们认为 Xcode
会把整个文件都作加密 hash
并用作数字签名。其实为了在运行时验证 Mach-O
文件的签名,并非每次重复读入整个文件,而是把每页内容都生成一个单独的加密散列值,并存储在 __LINKEDIT
中。这使得文件每页的内容都能及时被校验确并保不被篡改。
exec()
Exec is a system call. When you trap into the kernel, you basically say I want to replace this process with this new program.
exec()
是一个系统调用。系统内核把应用映射到新的地址空间,且每次起始位置都是随机的(由于使用ASLR
)。并将起始位置到0x000000
这段范围的进程权限都标记为不可读写不可执行。若是是32
位进程,这个范围至少是4KB
;对于64
位进程则至少是4GB
。NULL
指针引用和指针截断偏差都是会被它捕获。这个范围也叫作PAGEZERO
。
Unix 的前二十年很安逸,由于那时尚未发明动态连接库。有了动态连接库后,一个用于加载连接库的帮助程序被建立。在苹果的平台里是
dyld
,其余Unix
系统也有ld.so
。 当内核完成映射进程的工做后会将名字为dyld
的Mach-O
文件映射到进程中的随机地址,它将PC
寄存器设为dyld
的地址并运行。dyld
在应用进程中运行的工做是加载应用依赖的全部动态连接库,准备好运行所需的一切,它拥有的权限跟应用同样。
从主执行文件的
header
获取到须要加载的所依赖动态库列表,而header
早就被内核映射过。而后它须要找到每一个dylib
,而后打开文件读取文件起始位置,确保它是Mach-O
文件。接着会找到代码签名并将其注册到内核。而后在dylib
文件的每一个segment
上调用mmap()
。应用所依赖的dylib
文件可能会再依赖其余dylib
,因此dyld
所须要加载的是动态库列表一个递归依赖的集合。通常应用会加载100
到400
个dylib
文件,但大部分都是系统dylib
,它们会被预先计算和缓存起来,加载速度很快。
在加载全部的动态连接库以后,它们只是处在相互独立的状态,须要将它们绑定起来,这就是
Fix-ups
。代码签名使得咱们不能修改指令,那样就不能让一个dylib
的调用另外一个dylib
。这时须要加不少间接层。 现代code-gen
被叫作动态 PIC(Position Independent Code),意味着代码能够被加载到间接的地址上。当调用发生时,code-gen
实际上会在__DATA
段中建立一个指向被调用者的指针,而后加载指针并跳转过去。因此dyld
作的事情就是修正(fix-up
)指针和数据。Fix-up
有两种类型,rebasing
和binding
。
Rebasing:在镜像内部调整指针的指向 Binding:将指针指向镜像外部的内容
dyld
的时间线由上图可知为:
Load dylibs -> Rebase -> Bind -> ObjC -> Initializers
在 iOS 13
以前,全部的第三方 App
都是经过 dyld 2
来启动 App
的,主要过程以下:
Mach-O
的 Header
和 Load Commands
,找到其依赖的库,并递归找到全部依赖的库Mach-O
文件dyld3
被分为了三个组件:
MachO
解析器
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
进程,以后就可使用启动闭包启动了。dyld 3 把不少耗时的查找、计算和 I/O
的事前都预先处理好了,这使得启动速度有了很大的提高。
好了,先导知识就总结到这里,接下来让咱们调整呼吸进入下一章~
咱们在探索 iOS
底层的时候,对于对象、类、方法有了必定的认知哦,接下来咱们就一块儿来探索一下应用是怎么加载的。
咱们直接新建一个 Single View App
的项目,而后在 main.m
中打一个断点:
而后咱们能够看到在 main
方法执行前有一步 start
,而这一流程是由 libdyld.dylib
这个动态库来执行的。
这个现象说明了什么呢?说明咱们的 app
在 main
函数执行以前其实还经过 dyld
作了不少事情。那为了搞清楚具体的流程,咱们不妨从 Apple OpenSource 上下载 dyld
的源码来进行探索。
咱们选择最新的 655.1.1
版本:
dyld
源码分析面对 dyld
的源码,咱们不可能一行一行的去分析。咱们不妨在刚才建立的项目中断点一下 load
方法,看下调用堆栈:
这一次咱们发现,load
方法的调用要早于 main
函数的调用,其次,咱们获得了一个很是有价值的线索: _dyld_start
。
咱们直接在 dyld 655.1.1
中全局搜索这个 _dyld_start
,咱们能够来到 dyldStartup.s
这个汇编文件,而后咱们聚焦于 arm64
架构下的汇编代码:
对于这里的汇编代码,咱们确定也不必逐行分析,咱们直接定位到 bl
语句后面(bl
在汇编层面是跳转的意思):
bl __ZN13dyldbootstrap5startEPK12macho_headeriPPKclS2_Pm
复制代码
咱们能够看到这里有一行注释:
// call dyldbootstrap::start(app_mh, argc, argv, slide, dyld_mh, &startGlue)
复制代码
这行注释的意思是调用位于 dyldbootstrap
命名空间下的 start
方法,咱们继续搜索一下这个 start
方法,结果位于 dyldInitialization.cpp
文件(从文件名咱们能够看出该文件主要是用来初始化 dyld
),这里查找 start
的时候可能会有不少结果,咱们其实能够先搜索命名空间,再搜索 start
方法。
start
方法源码以下:
//
// This is code to bootstrap dyld. This work in normally done for a program by dyld and crt.
// In dyld we have to do this manually.
//
uintptr_t start(const struct macho_header* appsMachHeader, int argc, const char* argv[],
intptr_t slide, const struct macho_header* dyldsMachHeader,
uintptr_t* startGlue)
{
// if kernel had to slide dyld, we need to fix up load sensitive locations
// we have to do this before using any global variables
slide = slideOfMainExecutable(dyldsMachHeader);
bool shouldRebase = slide != 0;
#if __has_feature(ptrauth_calls)
shouldRebase = true;
#endif
if ( shouldRebase ) {
rebaseDyld(dyldsMachHeader, slide);
}
// allow dyld to use mach messaging
mach_init();
// kernel sets up env pointer to be just past end of agv array
const char** envp = &argv[argc+1];
// kernel sets up apple pointer to be just past end of envp array
const char** apple = envp;
while(*apple != NULL) { ++apple; }
++apple;
// set up random value for stack canary
__guard_setup(apple);
#if DYLD_INITIALIZER_SUPPORT
// run all C++ initializers inside dyld
runDyldInitializers(dyldsMachHeader, slide, argc, argv, envp, apple);
#endif
// now that we are done bootstrapping dyld, call dyld's main
uintptr_t appsSlide = slideOfMainExecutable(appsMachHeader);
return dyld::_main(appsMachHeader, appsSlide, argc, argv, envp, apple, startGlue);
}
复制代码
咱们刚才探索到了 start
方法,具体流程以下:
dyld
的 Mach-O
文件的 header
判断是否须要对 dyld
这个 Mach-O
进行 rebase
操做mach
,使得 dyld
能够进行 mach
通信。env
指针设置为恰好超出 agv
数组的末尾;内核将 apple
指针设置为恰好超出 envp
数组的末尾app
主二进制文件 Mach-O
的 header
来获得偏移量 appSlide
,而后调用 dyld
命名空间下的 _main
方法。咱们经过搜索来到 dyld.cpp
文件下的 _main
方法:
_main方法
官方的注释以下:
dyld
的入口。内核加载了dyld
而后跳转到__dyld_start
来设置一些寄存器的值而后调用到了这个方法。 返回__dyld_start
所跳转到的目标程序的main
函数地址。
咱们乍一看,这个方法有四五百行,因此咱们不能老老实实的一行一行来看,这样太累了。咱们应该着重于有注释的地方。
cdHash
值。这个哈希值 mainExecutableCDHash
在后面用来校验 dyld3
的启动闭包。dyld
的加载。而后判断当前是否为模拟器环境,若是不是模拟器,则追踪主二进制可执行文件的加载。macOS
执行环境,若是是则判断 DYLD_ROOT_PATH
环境变量是否存在,若是存在,而后判断模拟器是否有本身的 dyld
,若是有就使用,若是没有,则返回错误信息。dyld 启动开始
dyldbootstrap::_main
方法的参数来设置上下文exec
路径的指针dyl
d移除临时 apple [0]
过渡代码exec
路径是否为绝对路径,若是为相对路径,使用 cwd
转化为绝对路径exec
路径中取出进程的名称 (strrchr
函数是获取第二个参数出现的最后的一个位置,而后返回从这个位置开始到结束的内容)App
主二进制可执行文件 Mach-O
的 Header
的内容配置进程的一些限制条件macOS
执行环境,若是是的话,再判断上下文的一些配置属性是否被设置了,若是没有被设置,则再次进行一次 setContext
上下文配置操做。envp
检查环境变量macOS
执行环境,若是是的话,再判断当前 app
的 Mach-O
可执行文件是否为 iOSMac
类型且不为 macOS
类型的话,则重置上下文的根路径,而后再判断 DYLD_FALLBACK_LIBRARY_PATH
和 DYLD_FALLBACK_FRAMEWORK_PATH
这两个环境变量是否都是默认后备路径,若是是的话赋值为受限的后备路径。DYLD_PRINT_OPTS
和 DYLD_PRINT_ENV
来判断是否须要打印app
的 Mach-O
可执行文件的 header
和 ASLR
以后的偏移量来获取架构信息。在这里会判断若是是 GC
的程序则会禁用掉共享缓存。app
的 Mach-O
二进制可执行文件是否有段覆盖了共享缓存区域,若是覆盖了则禁用共享缓存。可是这里的前提是 macOS
,在 iOS
中,共享缓存是必需的。这里为了方便查看,咱们能够折叠一些分支条件。
dyld 2
仍是 dyld 3
的流程dyld3
会建立一个启动闭包,咱们须要来读取它,这里会如今缓存中查找是否有启动闭包的存在,前面咱们已经说过了,系统级的 app
的启动闭包是存在于共享缓存中,而咱们本身开发的 app
的启动闭包是在 app
安装或者升级的时候构建的,因此这里检查 dyld
中的缓存是有意义的。dyld
缓存中没有找到启动闭包或者找到了启动闭包可是验证失败(咱们最开始提到的 cdHash
在这里出现了)
dyld3 启动开始
start()
是以函数指针的方式调用 _main
方法的返回的指针,须要进行签名。至此,dyld3
的流程就处理完毕,咱们再接着往下分析 dyld2
的流程。
dyld
的镜像文件到 UUID
列表中,主要的目的是启用堆栈的符号化。reloadAllImages
ImageLoader
是一个用于加载可执行文件的基类,它负责连接镜像,但不关心具体文件格式,由于这些都交给子类去实现。每一个可执行文件都会对应一个ImageLoader
实例。ImageLoaderMachO
是用于加载Mach-O
格式文件的ImageLoader
子类,而ImageLoaderMachOClassic
和ImageLoaderMachOCompressed
都继承于ImageLoaderMachO
,分别用于加载那些__LINKEDIT
段为传统格式和压缩格式的Mach-O
文件。
接下来就来到重头戏了 reloadAllImages
了:
实例化主程序
这里咱们看到有一行代码:
// instantiate ImageLoader for main executable
sMainExecutable = instantiateFromLoadedImage(mainExecutableMH, mainExecutableSlide, sExecPath);
复制代码
显然,在这里咱们的主程序被实例化了,咱们进入这个方法内部:
这里至关于要为已经映射到主可执行文件中的文件建立一个 ImageLoader*
。
从上面代码咱们不难看出这里真正执行的逻辑是 ImageLoaderMachO::instantiateMainExecutable
方法:
咱们再进入 sniiffLoadCommands
方法内部:
经过注释不难看出:sniiffLoadCommands
会肯定此 mach-o
文件是否具备原始的或压缩的 LINKEDIT
以及 mach-o
文件的 segement
的个数。
sniiffLoadCommands
完成后,判断 LINKEDIT
是压缩的格式仍是传统格式,而后分别调用对应的 instantiateMainExecutable
方法来实例化主程序。
加载任何插入的动态库
连接库
先是连接主二进制可执行文件,而后连接任何插入的动态库。这里都用到了 link
方法,在这个方法内部会执行递归的 rebase
操做来修正 ASLR
偏移量问题。同时还会有一个 recursiveApplyInterposing
方法来递归的将动态加载的镜像文件插入。
运行全部初始化程序
完成连接以后须要进行初始化了,这里会来到 initializeMainExecutable
:
这里注意执行顺序:
在 runInitializers
内部咱们继续探索到 processInitializers
:
而后咱们来到 recursiveInitialization
:
而后咱们来到 notifySingle
:
箭头所示的地方是获取镜像文件的真实地址。
咱们全局搜索一下 sNotifyObjcInit
能够来到 registerObjCNotifiers
:
接着搜索 registerObjCNotifiers
:
此时,咱们打开 libObjc
的源码能够看到:
上面这一连串的跳转,结果很显然:dyld
注册了回调才使得 libobjc
能知道镜像什么时候加载完毕。
在 ImageLoader::recursiveInitialization
方法中还有一个 doInitialization
值得注意,这里是真正作初始化操做的地方。
doInitialization
主要有两个操做,一个是 doImageInit
,一个是 doModInitFunctions
:
doImageInit
内部会经过初始地址 + 偏移量拿到初始化器 func
,而后进行签名的验证。验证经过后还要判断初始化器是否在镜像文件中以及 libSystem
库是否已经初始化,最后才执行初始化器。
通知监听 dyld 的 main
一切工做作完后通知监听 dyld
的 main
,而后为主二进制可执行文件找到入口,最后对结果进行签名。
咱们直接经过 LLDB
大法来断点调试 libObjc
中的 _objc_init
,而后经过 bt
命令打印出当前的调用堆栈,根据上一节咱们探索 dyld
的源码,此刻一切的一切都是那么的清晰明了:
咱们能够看到 dyld
的最后一个流程是 doModInitFunctions
方法的执行。
咱们打开 libSystem
的源码,全局搜索 libSystem_initializer
能够看到:
而后咱们打开 libDispatch
的源码,全局搜索 libdispatch_init
能够看到:
咱们再搜索 _os_object_init
:
完美~,_objc_init
在这里就被调用了。因此 _objc_init
的流程是
dyld -> libSystem -> libDispatch -> libObc -> _objc_init
本文主要探索了 app
启动以后 dyld
的流程,整个分析过程确实比较复杂,但在探索的过程当中,咱们不只对底层源码有了新的认知,同时对于优化咱们 app
启动也是有不少好处的。下一章,咱们会对 objc_init
内部的 map_images
和 load_images
进行更深刻的分析,敬请期待~