App启动-dyld详解
简单总结
- 系统先读取
App
的可执行文件(Mach-O
文件),从里面获得dyld
的路径,然后加载dyld
,dyld
去初始化运行环境。 - 开启缓存策略,加载程序相关依赖库(其中也包含我们的可执行文件),并对这些库进行链接,最后调用每个依赖库的初始化方法,在这一步,
runtime
被初始化。 - 当所有依赖库的初始化后,轮到最后一位(程序可执行文件)进行初始化,在这时
runtime
会对项目中所有类进行类结构初始化,然后调用所有的load
方法。最后dyld
返回main
函数地址,main
函数被调用,我们便来到程序入口main函数。
1.找到dyld动态链接器并加载
Mach-O 其实是 Mach Object 文件格式的缩写,在 Mac 和 iOS 上,可执行文件的格式通常都是 Mach-O 格式,它的结构如下:
- Header 中保存了一些基本信息,包括了该文件是 32 位还是 64 位、运行该文件对应的处理器架构是什么、文件类型、
LoadCommands
的个数等。 - Load Command 用来告诉内核和 dyld,如何将 APP 运行所需的资源加载入内存中。比如
main
函数的加载地址,动态链接器dyld
的文件路径,以及相关依赖库的文件路径等。 - Data 中包含了具体的代码、数据等。
我们通过 MachOView 来查看一个 Mach-O 文件的具体内容:
可以看到:
dyld
动态链接器的路径在LC_LOAD_DYLINKER
命令里,一般都是在/usr/lib/dyld
路径下。LC_MAIN
指的是 main 函数的加载地址LC_LOAD_DYLIB
指向的都是程序允许所需要加载的依赖库- 我们通过 CocoaPods 引入的库也会被包含在
LC_LOAD_DYLIB
命令中
系统加载 App 可执行文件后,通过分析文件来获得动态链接器 dyld
所在路径来加载 dyld
,然后就将后面的事情交给 dyld
。
2.引导启动dyld并初始化
dyld(the dynamic link editor)是苹果的动态链接器,是苹果操作系统一个重要组成部分,在系统内核做好程序准备工作之后,交由dyld负责余下的工作。dyld 是开源的,任何人都可以通过苹果官网下载它的源码,阅读理解它的运作方式,了解系统加载动态库的细节。
dyld下载地址,笔者下载的是655.1版本,从519版本开始,引入了 dyld3。
现在我们已经有了 dyld 的路径,那么 dyld 是如何加载的呢?后面一系列的加载过程又是如何实现的呢?
main() 函数是我们熟知的程序入口,我们现在从 main() 函数入手,来探索一下在 main() 函数执行前,dyld 加载的具体过程。在 main() 函数打断点,然后运行,调用栈如下图所示:
可以看到,在调用栈中,只有 main() 函数和 start 函数,有没有什么办法可以看到更详细的调用栈呢?有一个方法比 main() 函数调用更早,那就是 +load() 函数,此时在控制器中写一个 +load() 函数,打上断点并运行,调用栈如下图所示:
可以看到,最先调用的是 _dyld_start
函数,我们根据这个线索顺藤摸瓜,在 dyld 源代码 dyldStartup.s
文件中找到了 __dyld_start
函数,此函数由汇编实现,兼容各种平台架构,在 arm64 架构下的汇编代码中可以看到一条 bl 命令,根据注释可以知道是跳转到 dyldbootstrap::start()
函数,正好对应上图调用栈中的情况:
// call dyldbootstrap::start(app_mh, argc, argv, slide, dyld_mh, &startGlue)
bl __ZN13dyldbootstrap5startEPK12macho_headeriPPKclS2_Pm
复制代码
在 dyldInitialization.cpp
文件中可以找到 dyldbootstrap::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
// 根据dyld的可执行文件Header,计算虚拟地址偏移量
slide = slideOfMainExecutable(dyldsMachHeader);
bool shouldRebase = slide != 0;
#if __has_feature(ptrauth_calls)
shouldRebase = true;
#endif
if ( shouldRebase ) {
// dyld重定位,如果内核未在其首选地址加载dyld,我们需要对dyld中的__DATA segment重定位
rebaseDyld(dyldsMachHeader, slide);
}
// allow dyld to use mach messaging
// mach消息初始化
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
// 如果支持DYLD_INITIALIZER,运行所有dyld内部的C++初始化器
runDyldInitializers(dyldsMachHeader, slide, argc, argv, envp, apple);
#endif
// now that we are done bootstrapping dyld, call dyld's main
// 到这里,我们已经完成了dyld的引导启动,下面执行dyld的main函数
// 根据App的可执行文件Header,计算虚拟地址偏移量
uintptr_t appsSlide = slideOfMainExecutable(appsMachHeader);
// 进入dyld::_main()函数
return dyld::_main(appsMachHeader, appsSlide, argc, argv, envp, apple, startGlue);
}
复制代码
dyldbootstrap::start()
函数是用来引导启动 dyld 并初始化运行环境,完成后,此函数调用 dyld::_main()
,再将返回值传递给 __dyld_start
去调用真正的 main()
函数。
在继续探索 dyld::_main()
函数的具体内容之前,先介绍一些概念:
虚拟内存
-
我们开发者开发过程中所接触到的内存均为虚拟内存,虚拟内存使App认为它拥有连续的可用的内存(一个连续完整的逻辑地址空间),这是系统给我们的馈赠,而实际上,它通常是分布在多个物理内存碎片,系统的虚拟内存空间映射 vm_map 负责虚拟内存和物理内存的映射关系。
-
虚拟内存是在物理内存上建立的一个逻辑地址空间,它向上(应用)提供了一个连续的逻辑地址空间,向下隐藏了物理内存的细节。
-
虚拟内存使得逻辑地址可以没有实际的物理地址,也可以让多个逻辑地址对应到一个物理地址。
-
虚拟内存被划分为一个个大小相同的Page(64位系统上是16KB),提高管理和读写的效率。 Page又分为只读和读写的Page。
-
虚拟内存是建立在物理内存和进程之间的中间层。在iOS上,当内存不足的时候,会尝试释放那些只读的Page,因为只读的Page在下次被访问的时候,可以再从磁盘读取。如果没有可用内存,会通知在后台的App(也就是在这个时候收到了memory warning),如果在这之后仍然没有可用内存,则会杀死在后台的App。
-
ARM处理器64bit的架构情况下,也就是0x000000000 – 0xFFFFFFFFF,每个16进制数是4位,即2的36次幂,就是64GB,即App最大的虚拟内存空间为64GB。
Page Fault
在应用执行的时候,它被分配的逻辑地址空间都是可以访问的,当应用访问一个逻辑Page,而在对应的物理内存中并不存在的时候,这时候就发生了一次Page fault。当Page fault发生的时候,会中断当前的程序,在物理内存中寻找一个可用的Page,然后从磁盘中读取数据到物理内存,接着继续执行当前程序。
Dirty Page & Clean Page
-
如果一个 Page 可以从磁盘上重新生成,那么这个 Page 称为 Clean Page,也就是只读 Page。
-
如果一个 Page 包含了进程相关信息,那么这个 Page 称为 Dirty Page,也就是读写 Page。
像代码段这种只读的 Page 就是 Clean Page。而像数据段 __DATA
这种读写的 Page,当写数据发生的时候,会触发 COW(Copy on write),也就是写时复制,Page 会被标记成 Dirty,同时会被复制。
Rebase & Binding
ASLR & Code Sign
ASLR(Address space layout randomization)地址空间布局随机化。App在启动的时候,程序会被映射到虚拟内存中,并分配一个逻辑地址空间,这个逻辑地址空间有一个起始地址,在 iOS4.3 之前,这个地址基本是固定的,这意味着一个给定的程序在给定的架构上的进程初始虚拟内存都是基本一致的,而且在进程正常运行的生命周期中,内存中的地址分布具有非常强的可预测性,这给了黑客很大的施展空间(代码注入,重写内存),黑客很容易就可以由起始地址+偏移量找到函数的地址。而 ASLR 技术使得程序每次启动后起始地址都会随机变化,这样程序里所有的代码、文件、动态库在虚拟内存中的加载地址每次启动也都是不固定的,可以阻挡黑客对地址的猜测 。
Code Sign 相信大家都不陌生,其目的就是保证代码没有被篡改过,但这里要指出的是,Xcode 并没有把整个 Mach-O 文件都做加密 hash 并用做数字签名,而是把每页内容都生成一个单独的加密散列值,并存储在 __LINKEDIT
中,这使得文件每页的内容都能及时被校验确并保不被篡改。
Fix-up
所有的可执行文件、动态库在加载到虚拟内存中之后,它们只是处在相互独立的状态,我们需要把他们连接起来。在动态库加载如内存之前,它们都有一个 preferred_address
,但是由于 ASLR 的存在,导致这些地址都是错的,所以这些动态库也无法正确调用。同时由于 Code Sign 的存在,导致我们无法修改这些错误的调用指令。不过由于现代的 code-gen
采用了PIC(Position Independ code)位置无关代码技术,意味着代码可以被加载到间接的地址上。当调用发生时,code-gen
实际上会在 __DATA
段中创建一个指向被调用者的指针,然后加载指针并跳转过去。
这个修复错误调用,并创建正确调用指针的过程被称作 Fix-up。Fix-up 有两种类型,rebasing(重设地址)和binding(绑定)。
Rebasing指的是调整指向镜像内部的指针,Binding指的是调整指向镜像外部的指针。
我们可以通过 dyldinfo 来查看一个可执行文件需要 Fix-up 的内容:
➜ xcrun dyldinfo -rebase -bind Mach-O-Analysis
rebase information (from compressed dyld info):
segment section address type value
__DATA_CONST __cfstring 0x100008018 pointer 0x100006700
__DATA_CONST __cfstring 0x100008038 pointer 0x100006705
__DATA_CONST __objc_classlist 0x100008048 pointer 0x10000D388
__DATA_CONST __objc_classlist 0x100008050 pointer 0x10000D400
__DATA __la_symbol_ptr 0x10000C000 pointer 0x100006664
__DATA __la_symbol_ptr 0x10000C008 pointer 0x100006670
__DATA __objc_const 0x10000C078 pointer 0x1000074D1
__DATA __objc_const 0x10000C080 pointer 0x100006034
__DATA __objc_const 0x10000D348 pointer 0x10000D2B0
__DATA __objc_selrefs 0x10000D350 pointer 0x100006795
__DATA __objc_selrefs 0x10000D358 pointer 0x1000067A6
__DATA __objc_selrefs 0x10000D360 pointer 0x1000067AB
__DATA __objc_classrefs 0x10000D370 pointer 0x10000D400
__DATA __objc_superrefs 0x10000D378 pointer 0x10000D388
__DATA __objc_data 0x10000D388 pointer 0x10000D3B0
__DATA __objc_data 0x10000D3A8 pointer 0x10000C0F0
__DATA __objc_data 0x10000D3D0 pointer 0x10000C088
__DATA __data 0x10000D488 pointer 0x100007492
__DATA __data 0x10000D498 pointer 0x10000C138
__DATA __data 0x10000D4A8 pointer 0x10000C308
__DATA __data 0x10000D4B8 pointer 0x10000C328
...
bind information:
segment section address type addend dylib symbol
__DATA __objc_data 0x10000D408 pointer 0 UIKit _OBJC_CLASS_$_UIResponder
__DATA __objc_data 0x10000D458 pointer 0 UIKit _OBJC_CLASS_$_UIResponder
__DATA __objc_classrefs 0x10000D368 pointer 0 UIKit _OBJC_CLASS_$_UISceneConfiguration
__DATA __objc_data 0x10000D390 pointer 0 UIKit _OBJC_CLASS_$_UIViewController
__DATA __objc_data 0x10000D3E0 pointer 0 UIKit _OBJC_METACLASS_$_UIResponder
__DATA __objc_data 0x10000D430 pointer 0 UIKit _OBJC_METACLASS_$_UIResponder
__DATA __objc_data 0x10000D3B8 pointer 0 UIKit _OBJC_METACLASS_$_UIViewController
__DATA __objc_data 0x10000D3B0 pointer 0 libobjc _OBJC_METACLASS_$_NSObject
__DATA __objc_data 0x10000D3D8 pointer 0 libobjc _OBJC_METACLASS_$_NSObject
__DATA __objc_data 0x10000D428 pointer 0 libobjc _OBJC_METACLASS_$_NSObject
__DATA __objc_data 0x10000D398 pointer 0 libobjc __objc_empty_cache
__DATA __objc_data 0x10000D3C0 pointer 0 libobjc __objc_empty_cache
__DATA_CONST __got 0x100008000 pointer 0 libSystem dyld_stub_binder
__DATA_CONST __cfstring 0x100008008 pointer 0 CoreFoundation ___CFConstantStringClassReference
__DATA_CONST __cfstring 0x100008028 pointer 0 CoreFoundation ___CFConstantStringClassReference
复制代码
Rebase
Rebasing:在镜像内部调整指针的指向,针对 mach-o 在加载到内存中不是固定的首地址(ASLR)这一现象做数据修正的过程。
在过去,dyld 会把 dylib 加载到指定的地址,所有指针和数据对于代码来说都是对的,dyld 无需做任何 fix-up。如今使用了 ASLR 会将 dylib 加载到新的随机地址,这个随机地址跟代码和数据指向的旧地址会有偏差,dyld 需要修正这个偏差(slide),Rebasing 就是将 dylib 内部的指针地址都加上这个偏移量,计算方法如下:
slide = actual_address - preferred_address
复制代码
然后就是重复不断地对 __DATA
段中需要 rebase 的指针加上这个偏移量。这就又涉及到 page fault 和 COW(写入时复制)。这可能会产生 I/O 瓶颈,但因为 rebase 的顺序是按地址排列的,所以从内核的角度来看这是个有次序的任务,它会预先读入数据,减少 I/O 消耗。
rebase步骤先进行,需要把镜像读入内存,并以page为单位进行加密验证,保证不会被篡改,所以这一步的瓶颈在IO。bind在其后进行,由于要查询符号表,来指向跨镜像的资源,加上在rebase阶段,镜像已被读入和加密验证,所以这一步的瓶颈在于CPU计算。
Binding
Binding:是处理那些指向 dylib 外部的指针,也就是将这个 dylib 调用的外部符号进行绑定的过程。比如我们 objc 代码中需要使用到 NSObject,即符号 _OBJC_CLASS_$_NSObject
,但是这个符号又不在我们的二进制文件中,在系统库 Foundation.framework
中,因此就需要 binding 这个操作将对应关系绑定到一起。
lazyBinding 就是在加载动态库的时候不会立即 binding,当时当第一次调用这个方法的时候再实施 binding。 做到的方法也很简单: 通过 dyld_stub_binder
这个符号来做。lazyBinding的方法第一次会调用到 dyld_stub_binder
,然后 dyld_stub_binder
负责找到真实的方法,并且将地址 bind 到桩上,下一次就不用再 bind 了。
Binding 是处理那些指向 dylib 外部的指针,它们实际上被符号(symbol)名称绑定,也就是个字符串。__LINKEDIT
段中也存储了需要 bind 的指针,以及指针需要指向的符号。dyld 需要找到 symbol 对应的实现,这需要很多计算,去符号表里查找。找到后会将内容存储到 __DATA
段中的那个指针中。Binding 看起来计算量比 Rebasing 更大,但其实需要的 I/O 操作很少,Binding的时间主要是耗费在计算上,因为IO操作之前 Rebasing 已经替 Binding 做过了,所以这两个步骤的耗时是混在一起的。
rebaseDyld
在 dyldbootstrap::start()
函数中,计算偏移量用到了 slideOfMainExecutable()
函数,源码如下:
static uintptr_t slideOfMainExecutable(const struct macho_header* mh)
{
const uint32_t cmd_count = mh->ncmds;
const struct load_command* const cmds = (struct load_command*)(((char*)mh)+sizeof(macho_header));
const struct load_command* cmd = cmds;
for (uint32_t i = 0; i < cmd_count; ++i) {
if ( cmd->cmd == LC_SEGMENT_COMMAND ) {
const struct macho_segment_command* segCmd = (struct macho_segment_command*)cmd;
if ( (segCmd->fileoff == 0) && (segCmd->filesize != 0)) {
return (uintptr_t)mh - segCmd->vmaddr;
}
}
cmd = (const struct load_command*)(((char*)cmd)+cmd->cmdsize);
}
return 0;
}
复制代码
可以看到,该函数是获取到可执行文件的所有 load_command
,然后遍历查找到所有的 LC_SEGMENT_COMMAND
命令,再从中找到 File Offset
为 0 ,File Size
不为 0 的 segCmd
,用 (uintptr_t)mh
减去该 segCmd
的 vmaddr
,就得到了偏移量 slide
。根据偏移量的计算公式可知,(uintptr_t)mh
就是该文件的实际加载地址,而 segCmd 的 vmaddr 就是该文件的首选加载地址。
根据 dyldbootstrap::start()
函数的源码可以看到,一开始计算的是 dyld 的 slide,因为 dyld 也是一个 Mach-O 文件,所以在加载它之后也需要进行 Fix-up 才能正常使用,但是在接下来的代码中我们可以看到,只有 rebaseDyld(dyldsMachHeader, slide);
,并没有看到 binding 的方法,这是为什么呢?大胆猜测一下,可能 dyld 内部并没有对外部符号的调用,也就不需要进行 bind 了,那怎么验证我们的猜测呢?我们打开终端,输入以下命令:
➜ ~ cd /usr/lib
➜ ~ xcrun dyldinfo -rebase -bind dyld
for arch x86_64:
rebase information (from local relocation records and indirect symbol table):
segment section address type
__DATA_CONST __got 0x0008F000 pointer
__DATA_CONST __got 0x0008F008 pointer
__DATA_CONST __got 0x0008F010 pointer
__DATA_CONST __got 0x0008F018 pointer
__DATA_CONST __got 0x0008F020 pointer
binding information (from external relocations and indirect symbol table):
segment section address type weak addend dylib symbol
for arch i386:
rebase information (from local relocation records and indirect symbol table):
segment section address type
__DATA_CONST __got 0x00072000 pointer
__DATA __nl_symbol_ptr 0x000750B8 pointer
__DATA __nl_symbol_ptr 0x000750BC pointer
__DATA __nl_symbol_ptr 0x000750C0 pointer
__DATA __nl_symbol_ptr 0x000750C4 pointer
__DATA __nl_symbol_ptr 0x000750C8 pointer
... // 省略
binding information (from external relocations and indirect symbol table):
segment section address type weak addend dylib symbol
复制代码
可以看到,在 dyld 可执行文件内部的确是没有需要 bind 的指针。