从APP的启动说起

iOS里面APP的启动,过程有些复杂,今天我们来抽丝剥茧,一步步探讨一下APP的启动会经历哪些过程。

首先,用户点击iPhone里面的某个APP的icon,Kernel内核会开始初始化空间并创建进程, 在调用exec_active_image后,开始加载Mach-O文件。

这里我们简要说一下Mach-O文件。

Mach-O

Mach-O是iPhone下的可执行文件格式,我们的APP对应的ipa文件,解压缩以后就会看到这个Mach-O文件,我们可以用MachOView这个软件来查看一下,如图:

(注:这里使用的是x86架构下的mach-o文件,也就是模拟器生成的,如果是arm架构的话会有一些区别,不过区别不大,整体结构差不多)

 我们拿其中几个比较重要的来讲解一下。

Mach64 Header:描述了Mach-O的CPU架构、文件类型以及加载命令等信息。

Load Commands:一系列的加载的命令集合,在Mach-O文件加载的时候用于给kernel和dyld调用,如图:

LC_SEGMENT_64(__PAGEZERO):映射虚拟内存的第一页地址和大小,一般是4G(0x1000000)大小。

LC_SEGMENT_64(__TEXT):代码段的Header,里面记录了__TEXT的各种类型的偏移地址,如图:

表明了__stubs的偏移地址以及一些相关的头信息,其他的Header也类似。

LC_SEGMENT_64(__DATA):数据段,里面记录的信息也是偏移地址和一些相关头信息。

LC_SEGMENT_64(__LINKEDIT):记录的是动态链接相关的偏移地址和头信息(主要是dyld),动态链接十分重要,我们在后面会说到。

LC_DYLD_INFO_ONLY:记录了动态链接的rebase,binding,lazy binding等的头信息和偏移地址。

LC_SYMTAB:符号表的信息,记录符号表的位置,偏移量,数据个数等。通常跟Symbol Table还有String Table一起来查找符号地址,如下图:

在__Text代码段找到代码-[XFCorrelationNewsJSExport onload]的符号地址:0x1000014E0,通过LC_SYMTAB中的Symbol Table Offset找到地址 0x0012C218,然后根据此地址找到Symbols -[XFCorrelationNewsJSExport onload] 的偏移地址 0x00006D70 与 String Table的起始地址相加后计算出符号地址为:0x0017DB7C,然后就可以找到我们符号对应的字符串,如果要收集crash,也就可以拿到符号地址对应的符号的名字了。

LC_LOAD_DYLINKER:该Mach-O使用的链接器信息,记录了具体使用哪个链接器接管内核后续的加载工作,以及链接器的位置信息。

LC_LOAD_DYLIB:依赖库信息,dyld会通过这个段去加载动态库。列出了所有依赖的动态库。

Mach-O文件就暂时介绍到这里,后续提到动态链接器(dyld),动态库(dylib),动态库的延迟绑定问题时,还会继续介绍Mach-O相关的Section。

这里分享一点关于Mach-O的小感悟,一开始我在看Mach-O文件的各个section和segment的时候,觉得这么多的section,这么多的segment,我怎么可能搞清楚每一个都是干什么的,就算搞清楚了,时间长了也会忘记。后来我仔细想了一下,觉得Mach-O只是一种操作系统认识的可执行文件格式,所以他的各个section或者segment都是为了在不同的时候和不同的阶段提供不同的信息给操作系统使用的,所以,我个人认为,只需要了解他的大致结构(MachHeader)和比较核心的几个点(Load Commands,动态库和动态链接相关)就可以了。

在加载了Mach-O后,会开始载入动态链接器。

我们来简要说一下动态链接器。

动态链接器

在介绍动态链接器之前,我们有必要先介绍一下什么是链接,什么是动态链接。

链接

链接就是通过链接器将执行文件中引用的其他符号(变量和方法)做地址重定位的过程。链接分为:静态链接和动态链接。

静态链接

现在假设文件A,里面有方法 a(),方法a()里面引用了文件B里面的方法b(),那么在编译器编译的时候,会将方法a里面调用的方法b的地址以0x0,0x2等这些来暂时代替,然后输出可执行文件C,等到调用静态链接器的时候,由静态链接器来将真实的方法b的地址(这里的真实地址其实是指的虚拟地址)修改到C对应的位置上。

这里有个问题就是静态链接器如何知道哪些符号的地址需要重定位呢?

因为在编译A的时候,会生成一个重定位表,里面记录了哪些符号需要被重定位。

动态链接

动态链接区别于静态链接在于链接的时机不同,静态链接是编译的时候做链接,而动态链接是在APP启动时做链接,而且对于动态库而言,里面的方法并不会做链接操作,只有当第一次运行到这个方法时,才会去做链接操作,从而得到真正的地址,这也叫:延迟绑定。

动态链接主要是针对动态库(dylib,或者也可以叫共享库)的链接操作,在系统的/usr/lib目录下,存放了大量供系统与应用程序调用的动态库文件。动态库不能直接运行,而是需要通过系统的动态链接器(dyld)进行加载到内存后执行,当dyld加载完动态库以后,不同的APP可以使用同样的动态库(跨进程共享代码和部分数据)。但是需要注意的是,对于各进程共享的部分,只包括代码和不需要修改的数据部分,对于会变动的数据部分,是会被分离出来,每个进程一个副本。

这里有一个问题,就是如何才能在各个进程间共享可以共享的动态库的代码和无需修改的数据呢?

因为各进程调用动态库的地址都是各个进程的虚拟地址,彼此独立,所以你没办法修正动态库的代码的地址来适应所有进程调用,于是有人想到了用绝对地址,虽然可以满足这一要求的,但是会带来新的问题,即:

- 程序每引入一个共享库或者共享库更新后占用空间更大,就需要预留更大的虚拟空间(但是事实上并不是每个函数都会被调用到),可执行文件或许就要重新编译。
- 共享对象更新时,内部的符号地址可能变化,可执行文件又得重新编译。

所以用到了地址无关代码 (PIC, Position-independent Code) 技术:

无论目标模块(包括共享目标模块)被加载到内存中的什么位置,数据段总是紧跟着地址段的。因此,代码段中的任意指令与数据段中的任意变量之间的距离在运行时都是一个常量,而与代码和数据加载的绝对内存位置无关。

例子:

 1 //动态库代码 Person.h
 2 extern const NSString * _Nonnull str;
 3 
 4 extern int add(int a, int b);
 5 
 6 NS_ASSUME_NONNULL_BEGIN
 7 
 8 @interface Person : NSObject
 9 
10 - (void)printStr:(NSString *)str;
11 
12 @end
13 
14 //动态库代码 Person.m
15 const NSString * _Nonnull str  = @"abc";
16 
17 int add(int a, int b) {
18     return a + b;
19 }
20 
21 @implementation Person
22 
23 - (void)printStr:(NSString *)str {
24     
25     NSLog(@"sss:%@", str);
26 }
27 
28 @end
29 
30 //另一个项目引入动态库后调用的代码
31 - (void)viewDidLoad {
32     [super viewDidLoad];
33     // Do any additional setup after loading the view.
34     Person *person = [[Person alloc] init];
35     [person printStr:@"ttt"];
36     
37     NSLog(@"%@", str);
38     
39     NSLog(@"%d", add(3, 5));
40 }

动态链接对于数据引用和方法引用,处理的方式有些区别。

数据引用:

编译器在代码段和数据段之间创建了一个GOT(Global Offset Table,全局偏移表),里面存储的是目标模块引用的动态库中的变量,如图:

 

初始状态下,这些GOT中的地址都是0x0,到了app启动的时候,在Binding阶段(后面会讲到)动态链接器会将GOT中的数据地址都做一次修正。因为GOT是一个数组,所以修正的方式比较简单,即:GOT[n] = 代码段的地址 + 代码段与数据段的固定偏移 + GOT数据大小

方法引用(延迟绑定):

编译器在编译的时候会在__TEXT,__stubs里面将动态库的add方法生成一个占位,这个占位主要用来指向__DATA,____la_symbol_ptr里面对应的项,如图:

当运行到上面的代码第39行,目标函数调用动态库中的add方法,对应汇编如图:

bl是汇编指令,跳转到子程序的意思,使用Hopper Disassembler查看一下汇编,如图:

ldr:将内存中的值存入到寄存器x16中,此时0x10000c018正好对应__DATA,____la_symbol_ptr中的项,

br:x16  跳转到x16指向的地址,如图:

第一次调用add方法的时候,__DATA,____la_symbol_ptr里面尚未记录add的地址,而是指向__TEXT,__stub_helper里面相关的内容(0x0000001000065E4),如图:

w16:寄存器x16的低32位

.long 0x0000003f 找寻Dynamic Loader Info 中Lazy Binding Info的偏移3f的符号

上述代码的意思就是:跳转到__TEXT,__stub_helper头部(65CC),然后调用 dyld_stub_binder(动态链接器的入口) 进行符号绑定,最后会将 add 的地址放到 __la_symbol_ptr 处,下次再调用就可以直接取add的地址调用了。

绕了这么大一圈终于完成了方法的绑定,简化一下:

生产stub占位 -> 运行时调用 -> 指向la_symbol_ptr -> 如果有地址则返回地址,如果没有地址则指向stub_helper -> 调用dyld_stub_binder来绑定方法地址并修正la_symbol_ptr的地址。

这里会产生一个问题,为什么需要la_symbol_ptr,直接在stub里面修改地址不就完了吗?

因为stub是代码段,而代码段是只读的,动态库的指导思想就是共享代码段,分离出可变数据段,所以需要la_symbol_ptr。

综上所述,我们可以简单罗列一下静态链接库和动态链接库的区别:

1、静态链接库在编译后,库里的方法及变量地址就确定了(虚拟地址),动态链接库则是在运行时才能确定,而动态库中的方法则需要到调用到的时候才能确定。

2、静态链接库会打包进APP中,而动态链接库则在系统的/usr/lib目录下,如果是自己制作的动态库,也会随着APP一起打包进去。

动态链接器(dyld)
苹果操作系统的重要组成部分,负责链接和装载动态库,当xnu内核(开源的系统底层代码,下载地址)加载了动态链接器以后,APP将从内核态过度到用户态。

dyld本身也是mach-o格式的文件,但是dyld中不会再引用其他动态库的东西,所以就不存在动态绑定这个过程了,拿MachOView看看如图:

动态链接器也是开源的,下载地址

接下来App的启动就进入Rebase,Binding阶段了。

这几个阶段都是由dyld来控制的,我们来简单分析一下他的这几个过程

Rebasing

在过去,会把 dylib 加载到指定地址,所有指针和数据对于代码来说都是对的,dyld 就无需做任何 fix-up 了。如今用了 ASLR 后会将 dylib 加载到新的随机地址(actual_address),这个随机的地址跟代码和数据指向的旧地址(preferred_address)会有偏差,dyld 需要修正这个偏差(slide),做法就是将 dylib 内部的指针地址都加上这个偏移量,偏移量的计算方法如下:

Slide = actual_address - preferred_address

Binding

主要是针对那些外部符号做的绑定操作,比如我们上面说的GOT中的内容。

剩余启动事件

App启动到这里接下来就是进入到Runtime环节,会初始化Runtime环境并初始化,处理category和调用+load()方法。

initializers 调用所有动态库的initializer方法,初始化动态库。

调用App的main函数,正式进入App的生命周期。

小结

App的启动我们来回顾一下,主要分为:加载Mach-O、加载dyld、rebase、binding、加载dylib,Runtime、Initializer、main这几个过程,我们主要讲解了一下Mach-O的文件结构,动态链接的GOT和动态绑定过程,还简单介绍了rebase和binding。

可以看出来,App的启动过程十分复杂,还有很多细节和知识点需要我们仔细深入研究和学习。

posted on 2020-08-31 10:28  ZHLee  阅读(873)  评论(2编辑  收藏  举报

导航