链接和App启动速度优化

  1. 1 链接相关的基础知识
    1. 1.1 从main.c到一个可执行目标文件
    2. 1.2 链接的分类
    3. 1.3 链接的任务
    4. 1.4 动态链接共享库
    5. 1.4 加载可执行目标文件
  2. 2 Apple App Start And Dyld
    1. 2.1 dyld
    2. 2.2 启动调用顺序
  3. 3 dyld3
  4. 4 优化方案
  5. 5 参考

最近看了链接相关的知识,结合 WWDC 2篇Session 和 iOS 启动优化 做一下总结.

1 链接相关的基础知识

1.1 从main.c到一个可执行目标文件

我们的源程序main.c 经过C预处理器 翻译成一个ASCII中间文件 main.i,接下来 驱动程序运行C编译器,它将main.i翻译成一个ASCII汇编语言文件main.s,然后驱动程序运行汇编器,它讲main.s翻译成一个可重定位目标文件main.o,链接器程序将main.o和一些必要的系统目标文件组合起来,创建一个可执行目标文件。在运行时,main.o还可以和一些共享目标文件链接。

预处理

在该阶段,编译器将上述代码中的stdio.h编译进来,并且用户可以使用gcc的选项”-E”进行查看,该选项的作用是让gcc在预处理结束后停止编译过程。预处理阶段主要处理#include和#define,它把

  1. #include包含进来的.h 文件插入到#include所在的位置
  2. 把源程序中使用到的用#define定义的宏用实际的字符串代替

hello.c

gcc -E hello.c -o hello.i

1
2
3
4
5
6
7
8
#include<stdio.h>
int main()
{
printf("Hello world!\n");
return 0;
}

hello.i

编译阶段

接下来进行的是编译阶段,在这个阶段中,Gcc首先要检查代码的规范性、是否有语法错误等,以确定代码的实际要做的工作,在检查无误后,Gcc把代码翻译成汇编语言。用户可以使用”-S”选项来进行查看,该选项只进行编译而不进行汇编,生成汇编代码。

gcc -S hello.c -o hello.s

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
.section __TEXT,__text,regular,pure_instructions
.macosx_version_min 10, 12
.globl _main
.p2align 4, 0x90
_main: ## @main
.cfi_startproc
## BB#0:
pushq %rbp
Lcfi0:
.cfi_def_cfa_offset 16
Lcfi1:
.cfi_offset %rbp, -16
movq %rsp, %rbp
Lcfi2:
.cfi_def_cfa_register %rbp
subq $16, %rsp
leaq L_.str(%rip), %rdi
movl $0, -4(%rbp)
movb $0, %al
callq _printf
xorl %ecx, %ecx
movl %eax, -8(%rbp) ## 4-byte Spill
movl %ecx, %eax
addq $16, %rsp
popq %rbp
retq
.cfi_endproc
.section __TEXT,__cstring,cstring_literals
L_.str: ## @.str
.asciz "Hello world! \n"
.subsections_via_symbols

汇编阶段

汇编阶段把.s文件翻译成二进制机器指令文件.o,如命令gcc -c hello.s -o hello.o,其中-c告诉gcc进行汇编处理。这步生成的文件是二进制文件,直接用文本工具打开看到的将是乱码,我们需要反汇编工具如IDA的帮助才能读懂它

IDAhello.s

链接阶段

在编译之后,就进入到了链接阶段。我们hello.c 中是没有定义printf的。我们需要把hello.o中的printf符号和它的定义相关联,重定位hello.o和相关的模块,生成一个可执行目标文件.此时我们的printf符号就能正常调用了.

链接使用ld工具.如果没有特别的指定,gcc会到系统默认的搜索路径/usr/lib下进行查找,找到合适的函数库,进行链接.

gcc hello.o -o hello.out 生成可执行文件

./hello.out 运行可执行文件

1
Hello world!

1.2 链接的分类

  1. 静态链接
  2. 动态链接

静态链接
像Linux LD程序这样的静态链接器以一组可重定位目标文件和命令行参数作为输入,生成一个完全链接的,可以加载和运行的可执行目标文件为输出。

动态链接
共享库是一个目标模块,在运行或加载时,可以加载到任意的内存地址,并和一个在内存中的程序链接起来。这个过程称为动态链接.由一个叫做动态链接器的程序来执行的。

1.3 链接的任务

  1. 符号解析
    目标文件定义和引用符号,符号解析的目的是将每个符号引用正好和一个符号定义关联起来

  2. 重定位
    编译器和链接器生成从地址0开始的代码和数据节。链接器通过把每个符号定义与一个内存位置关联起来,从而重定位这些节,然后修改所有对这些符号的引用,是他们指向这个内存位置。

可重定位目标文件

链接器是以可重定位目标文件作为输入的,要想理解链接的过程,首先应该了解可重定位目标文件。

setion 解释
ELF头 系统的字的大小,字节顺序,ELF头的大小,目标文件的类型,机器类型,节头部表的文件偏移,节头部表中条目的大小和数量
.text 已编译程序的机器代码
.rodata 只读数据
.data 已初始化的全局和静态C变量
.bss 未初始化的全局和静态C变量,以及所有被初始化为0的全局或静态变量
.symtab 一个符号表,它存放在程序中定义和引用的函数和全局变量的信息。
.rel.text 重定位条目,一个.text节中位置的列表,当链接器把这个目标文件和其他文件组合时,需要修改这个位置。
.rel.data 重定位条目,被模块引用或定义的所有全局变量的重定位信息。
.debug 一个调试符号表,其条目是程序中定义的局部变量和类型定义,程序中定义和引用的全局变量,以及原始的C源文件。
.line 原始C源程序中的行号和.text节中机器指令间的映射。
.strtab 一个字符串表,其内容包括.symtab和.debug节中的符号表,以及节头部中的节名字。字符串表就是以null结尾的字符串的序列。
节头目表 不同节的位置和大小。

我们在链接中做的工作主要是:
符号解析.text,.data段的符号引用,重定位目标文件。

符号和符号表

在我们的一个可重定位目标模块中,通常会使用在当前模块和其他模块中定义的函数和全局变量,我们称之为符号。每个可重定位目标模块都有一个符号表,它包含m定义和引用符号的信息。

ELF符号表条目

1
2
3
4
5
6
7
8
typedef struct {
int name;
char type:4,binding:4;
char reserved;//没用
short section;
long value;
long size;
}Elf64_Symbol;

使用nm命令我们可以看到:
$ nm mymalloc.o

1
2
3
4
5
U ___real_free
U ___real_malloc
0000000000000050 T ___wrap_free
0000000000000000 T ___wrap_malloc
U _printf
1
2
___wrap_free,___wrap_malloc 是已定义的符号,在text段,有相应的内存地址.
___real_free,_printf,___real_malloc 是未定义的符号

符号解析

模块引用了符号,我们需要将符号和一个确定的符号定义关联起来,这样我们的程序才可以正常的执行下去。
对于引用和定义在相同模块中的局部符号,符号解析是很简单明了的。
但是当编译器遇到不是在当前模块中定义的符号时,只能假设该符号已经在其他模块中定义,生成一个链接器符号表条目,把它交给链接器处理。

重定位

一但链接器完成了符号解析这一步,就把代码中的每个符号引用和正好一个符号定义关联起来(即它的一个输入目标模块中的一个),此时,链接器就知道它的输入目标模块中的代码节和数据节的确切大小。现在就可以开始重定位步骤,在这个步骤中重定位将合并输入模块,并为每个符号分配运行时地址。

重定位由两步组成:

  1. 重定位节和符号定义
    • 链接器将所有相同类型的节合并为同一类型的新的聚合节。
    • 链接器将运行时内存地址赋给新的聚合节,赋给输入模块定义的每个节,以及赋给输入模块定义的每个符号。当这一步完成后,程序中的每条指令和全局变量都有唯一的运行时内存地址了。
  2. 重定位节中的符号引用
    • 链接器依赖可重定位条目的数据结构,修改代码节和数据节中对每个符号的引用,使它们指向正确的运行时地址。要执行这一步,链接器依赖于可重定位目标模块中成为可重定位条目的数据结构。

可重定位条目

当汇编器生成一个目标模块时,它并不知道数据和代码最终放在内存中的什么位置。他也不知道这个模块引用的任何外部定义的函数或者全局变量的位置。所以,无论何时汇编器遇到对最终位置未知的目标引用,它就会生成一个重定位条目,告诉链接器在将目标文件合并成可执行文件时如何修改这个引用。

代码的可重定位条目在.rel.text节中,已初始化数据的重定位条目放在.rel.data中.

1
2
3
4
5
typedef struct {
long offset;// 需要被修改引用的节偏移
long type:32,symbol:32;// type:如何修改新的引用,symbol:标示被修改引用应该指向的符号
long addend;// 一些类型的重定位要使用它对被修改引用的值做偏移调整
}Elf64_Rela;

两种基本的重定位类型

  1. PC 相对地址引用 一个PC相对地址就是 距程序计数器(PC)的当前运行值的偏移量
  2. 绝对地址引用

1.4 动态链接共享库

静态库有一些明显的缺点,比如:

  1. 静态库更新后需要显式地将他们的程序与更新了的库重新链接。
  2. 静态库的代码会被复制到每个运行进程的文本段中。

共享库,动态链接
共享库是一个目标模块,在运行或加载时,可以加载到任意的内存地址,并和一个在内存中的程序链接起来。这个过程称为动态链接.由一个叫做动态链接器的程序来执行的。

共享库的共享方式

  1. 给定文件系统中一个库只有一个.so文件。
  2. 内存中,一个共享库的.text节的一个副本可以被不同的正在运行的进程共享。

共享库的链接时机

  1. 在运行时由动态链接器练链接和加载
  2. 在调用程序被加载和开始执行时
  3. 根据需要在程序调用 dlopen 库的函数时

PIC 借助GOT,PLT实现
第一次调用外部函数:

1. 调用外部符号,程序进入相应的PLT条目
2. 把 调用的 ID压入栈后,跳转到PLT[0] 
3. PLT[0]通过GOT[1]间接的把动态链接器的一个参数压入栈中,然后通过GOT[2]间接的跳转进动态链接器中。动态链接器使用两个栈条目来确定 外部函数的 远行时为止,用这个地址重写GOT[4],再把控制权传给 函数。

后续再次调用:

1. 控制传递到PLT相应条目
2. 通过相应的GOT条目直接转移控制到 函数

PLT[0]: 特殊条目,它跳转到动态链接器中
GOT[0]和GOT[1]包含动态链接器在解析函数地址时会使用的信息。
GOT[2]是动态链接器在ld-linux.so模块中的入口点。

1.4 加载可执行目标文件

加载器将可执行目标文件中的代码和数据从磁盘复制到内存中,然后通过跳转到程序的第一条指令或入口点来运行该程序。

需要注意的是:

  1. 由于.data段有对齐要求,所以代码段和数据段之间是有间隙的
  2. 链接器会使用ASLR技术,每次区域运行时区域的地址都会改变,但是相对地址不会改变。

Linux下的加载

  1. 父shell进程生成一个子进程,它是父进程的一个复制
  2. 子进程通过execve系统调用启动加载器
  3. 加载器删除子进程现有的虚拟内存段,并创建一组新的代码,数据,堆和栈段
  4. 将虚拟地址空间中的页映射到可执行文件的页大小的片(chunk) ,新的代码和数据段被初始化为可执行文件的内容
  5. 加载器跳转到_start地址,最终会调用应用程序的main 函数。

注意:除了一些头部信息,在加载过程中没有任何从磁盘到内存的数据复制。直到CPU引用一个被映射的虚拟页时才会进行复制,此时,操作系统利用它的页面调度机制,自动将页面从磁盘传送到内存。

2 Apple App Start And Dyld

2.1 dyld

当内核完成映射进程的工作后会将名字为dyld的Mach-O文件映射到进程中的随机地址,它将PC寄存器设为dyld的地址并运行.

Fix-ups
由于代码签名,我们无法修改__TEXT段的内容,我们可以通过PIC(Position Independent Code)将dyld修改的引用的地址存储到__DATA中. 在Linux中是通过GOT(Global Offset Table)PLT(Procedure Linkage Table)实现的.

dyld的时间线:
Load dylibs -> Rebase -> Bind -> ObjC -> Initalizers

Load

Rebase和Binding

Rebase:在镜像内部调整指针的指向. Binding:将指针指向镜像外部的内容

可以通过命令行查看 rebase 和 bind 相关的资源指针:
xcrun dyldinfo -rebase -bind -lazy_bind xxx.app/xxx
诸如此类的输出

1
2
3
4
5
6
7
8
9
10
11
12
__DATA __la_symbol_ptr 0x1000341C8 0x04C3 libobjc _objc_setAssociatedObject
__DATA __la_symbol_ptr 0x1000341D0 0x04E4 libobjc _objc_setProperty_nonatomic_copy
__DATA __la_symbol_ptr 0x1000341D8 0x050C libobjc _objc_storeStrong
__DATA __la_symbol_ptr 0x1000341E0 0x0525 libobjc _objc_storeWeak
__DATA __la_symbol_ptr 0x1000341E8 0x053C libobjc _objc_unsafeClaimAutoreleasedReturnValue
__DATA __la_symbol_ptr 0x1000341F0 0x056C libSystem _pow
__DATA __la_symbol_ptr 0x1000341F8 0x0578 libSystem _sin
__DATA __la_symbol_ptr 0x100034200 0x0584 libSystem _sinf
__DATA __la_symbol_ptr 0x100034208 0x0591 libswiftCore _swift_getObjCClassMetadata
__DATA __la_symbol_ptr 0x100034210 0x05B4 libswiftCore _swift_getObjectType
__DATA __la_symbol_ptr 0x100034218 0x05D0 libswiftCore _swift_unknownRelease
__DATA __la_symbol_ptr 0x100034220 0x05ED libswiftCore _swift_unknownRetain

ObjC Runtime

Initializers

2.2 启动调用顺序

initialize -> dyld -> main() -> UIApplicationMain()

3 dyld3

在WWDC 2017 上,苹果宣布已经使用 dyld3 来作为系统app的动态链接器

dyld3可以分成三个部分

  1. 一个进程外的 mach-o 分析器和编译器 处理可能影响启动速度的searchpath @rpaths 和 环境变量,解析mach-o二进制文件,完成符号解析的工作.
  2. 一个进程内的引擎 执行启动收尾处理 验证启动收尾,映射动态链接库
  3. 一个启动收尾缓存服务 系统程序收尾被直接加入到共享缓存,使用这个工具在系统中运行和分析每个mach-o文件,将它们放入共享缓存,使它映射到缓存中,所有dylib都使用它来启动. 对于第三方程序,在程序安装或系统更新时,生成启动收尾处理(因为系统库那时已经发生更改)

为什么启动收尾可以提高启动速度?
通过启动收尾,缓存了 符号相对于库中的偏移位置, 这就避免了以后启动程序进程再次进行符号链接的时间消耗。直接从磁盘读取缓存的启动收尾,验证是否正确即可.

4 优化方案

App 的启动时间: dylib和App可执行文件的加载时间 + - (BOOL)Application:(UIApplication )Application didFinishLaunchingWithOptions:(NSDictionary )launchOptions执行时间.

加载images

Rebase/Binding

Initializer
Explict Initializer

Implict Initializer

5 参考

2017 WWDC Session 413

2016 WWDC Session 406

《深入理解计算机系统》

script>