what is LLVM:
LLVM是构架编译器(compiler)的框架系统,以C++编写而成,用于优化以任意程序语言编写的程序的编译时间(compile-time)、链接时间(link-time)、运行时间(run-time)以及空闲时间(idle-time),对开发者保持开放,并兼容已有脚本。LLVM计划启动于2000年,最初由University of Illinois at Urbana-Champaign的Chris Lattner主持开展。2006年Chris Lattner加盟Apple Inc.并致力于LLVM在Apple开发体系中的应用。Apple也是LLVM计划的主要资助者。
目前LLVM已经被苹果iOS开发工具、Google、Facebook等各大公司采用。
传统编译器的设计
Frontend 编译器前端的任务是解析源代码。它会进行词法分析、语法分析、语义分析检查源代码是否存在错误,然后构建成语法抽象树(language-specific Abstract Syntax Tree)。而LLVM的前端还会把AST树转换为中间代码IR(LLVM Intermedaite Representaion), 后面的2个阶段都使用这个中间表示。
Optimizer 优化器的任务优化代码,比如去除无用的变量或者无用的计算,来提高代码运行效率。
Backend 后端 是把优化后的代码转换为目标机器码(target instruction set)。Backend目标是生成充分可以利用目标机器体系结构的native code。
中间代码IR:IR是起到一个桥接作用的。LLVM的第一个阶段输出结果为IR,第二个阶段接收IR进行操作,然后输出IR给第三个阶段。这样针对不同语言,不同架构的机器,就可以单独去设计前端或后端了。
注:OC代码的编译器前端是Clang,swift代码的编译器前端是Swift,他俩的编译器后端都是llvm(这里的llvm是编译器后端的名称,不是整个LLVM架构)。
Clang是LLVM的一个子项目。它是基于LLVM架构的轻量级编译器。它的诞生就是为了替代GCC,提供更快的编译速度。它是负责编译C、C++、OC代码的编译器,它属于整个LLVM架构中的编译器前端。对于开发者来说,研究Clang可以给我们带来很多好处。
编译流程:
通过这个命令可以打印出源码的编译阶段:clang -ccc-print-phases main.m
- 输入文件:找到源文件
- 预处理阶段:这个过程包括处理宏的替换,头文件的导入。
- 编译阶段:进行词法分析、语法分析、语义分析等构件语法树(AST)最终生成IR。
- 后端:这里LLVM会通过一个个的Pass去优化,没个Pass做一些优化事情,最终生成汇编代码。
- 生成目标文件(.o文件)
- 链接:链接需要的动态库和静态库,生成可执行文件。
- 通过不同的架构,生成对应的可执行文件。(mach-o文件)
注:xcode7以后开启bitCode,苹果会进一步优化,生成.bc的中间代码。也就是在第二个阶段的Optimizer优化过程中会做进一步优化。
启动优化:
查看App的冷启动时间
pre-main阶段的耗时:(main函数之前)
- dylib loading time是指动态库的加载时间(dyld要链接动态库),对于这个耗时的建议是能使用苹果自身的framework就用原生的(苹果自身的framework有做了各种优化,链接会快很多),如果是自己封装的framework的话,建议不要多余6个(超过太多个的可以考虑进行合并)。
- rebase/binding、Objc setup 这两个是系统本身做一下类的加载注册的耗时。这一块能优化的空间比较小。除非你项目中的oc类少一些咯,比如随着项目需求的迭代,可能有一下类是弃用了的,最好就删掉。
- initializer 这只要是指类的load方法的耗时。我们知道如果一个类有写了load方法,那么这个类的创建就会被提前到main之前了。所以项目中的oc类能不写load方法就不写咯。
main函数之后阶段的耗时:(从main函数开始到第一个界面这阶段)
main函数之后的耗时主要就和项目的业务息息相关了。主要的优化方式有:
- 在main函数到页面展示出来这个过程中最好不要有耗时的操作在主线程中。尽可能利用多线程。(通过小工具可以检测时间)
- 类的加载尽可能用懒加载的方式。
- storyboard和xib是比较耗时的。尽可能在启动阶段的页面用代码来写,不要用storyboard或xib。(因为storyboard和xib也是要解析成代码去执行的,这中间就多了一个解析的操作)
二进制重排(pre-main阶段优化)
1、概念
众所周知操作系统有虚拟内存与物理内存的概念。在很久以前,还没有虚拟内存概念的时候,程序寻址用的都是物理地址。程序能寻址的范围是有限的,这取决于CPU的地址线条数。比如在32位平台下,寻址的范围是2^32也就是4G。并且这是固定的,如果没有虚拟内存,且每次开启一个进程都给4G的物理内存,就可能会出现很多问题:(1) 因为我的物理内存时有限的,当有多个进程要执行的时候,都要给4G内存,很显然你内存小一点,这很快就分配完了,于是没有得到分配资源的进程就只能等待。当一个进程执行完了以后,再将等待的进程装入内存。这种频繁的装入内存的操作是很没效率的。(2)由于指令都是直接访问物理内存的,那么我这个进程就可以修改其他进程的数据,甚至会修改内核地址空间的数据,这是不安全的。(3)因为内存时随机分配的,所以程序运行的地址也是不确定的。
于是针对上面会出现的各种问题,虚拟内存就出来了。
虚拟内存可以认为是每个进程都认为自己拥有4G的空间,这只是每个进程认为的,但是实际上,在虚拟内存对应的物理内存上,可能只对应的一点点的物理内存,实际用了多少内存,就会对应多少物理内存。进程得到的这4G虚拟内存是一个连续的地址空间(这也只是进程认为),而实际上,它通常是被分隔成多个物理内存碎片,还有一部分存储在外部磁盘存储器上,在需要时进行数据交换。
那么进程开始要访问一个地址,它可能会经历下面的过程:
- 每次我要访问地址空间上的某一个地址,都需要把地址翻译为实际物理内存地址
- 所有进程共享这整一块物理内存,每个进程只把自己目前需要的虚拟地址空间映射到物理内存上
- 进程需要知道哪些地址空间上的数据在物理内存上,哪些不在(可能这部分存储在磁盘上),还有在物理内存上的哪里,这就需要通过页表来记录(分页技术)
- 页表的每一个表项分两部分,第一部分记录此页是否在物理内存上,第二部分记录物理内存页的地址(如果在的话)(在iOS系统上一个页表16k大小。)
- 当进程访问某个虚拟地址的时候,就会先去看页表,如果发现对应的数据不在物理内存上,就会发生缺页异常(page fault)
-
缺页异常的处理过程,操作系统立即阻塞该进程,并将硬盘里对应的页换入内存,然后使该进程就绪,如果内存已经满了,没有空地方了,那就找一个页覆盖,至于具体覆盖的哪个页,就需要看操作系统的页面置换算法是怎么设计的了。
注:macho文件中内存地址是虚拟内存。程序lldb断点的时候打印出来的内存地址是物理内存地址。
ASLR
ASLR(Address space layout randomization)地址空间布局随机化 是一种针对缓冲区溢出的安全保护技术,通过对堆、栈、共享库映射等线性区布局的随机化,通过增加攻击者预测目的地址的难度,防止攻击者直接定位攻击代码位置,达到阻止溢出攻击的目的。接上一个虚拟地址和物理地址的内容,因为虚拟地址是固定的,所以黑客就很容易的根据虚拟地址去访问内存。为了解决这个问题,ASLR就出现了。虚拟地址不在是直接映射到物理内存的,而是会加上一个随机的地址偏移值。这样就没办法只通过虚拟地址去访问内存了,可以理解为加密算法中的加盐的概念。
在理解了虚拟内存、物理内存、分页技术和ASLR后,二进制重排就是在这种情况下诞生了。
pageFault现象
在上面虚拟内存映射到物理内存的过程中,在第4步,会出现缺页中断(pagefault)现象。当出现缺页中断的时候,操作系统立即阻塞该进程,并将硬盘里对应的页换入内存,然后使该进程就绪,如果内存已经满了,没有空地方了,那就找一个页覆盖。这是个耗时的操作,因为会阻塞进程。而在iOS系统中,当出现pagefault的时候,系统还会对该页进行签名验证,意味着在iOS系统中这个耗时更久了。那么如果我们在启动的过程中出现了很多pagefault的话,那么启动的时间就会大大的延长。so如何在启动的过程中去减少pagefault的出现呢?如果我们App在启动的过程中需要的函数都排在了一个(多个)页表中,这样启动的时候就可以一次加载这些页表,那么是不是就可以减少pagefault的情况呢。将启动代码重新排列在可执行文件的前面,这个重新排列的操作就叫二进制重排。
那么如何进行二进制重排?(下面将已一个我之前写的app项目为例来演示这个过程。)
-
如何查看App启动的时间 (添加环境变量DYLD_PRINT_STATISTICS)
-
如何查看App的启动的page fault(缺页终端)次数
从上图中可以看出,总共140.08ms中,page fault占据了121.42ms,将近80%。说明这一块有很大的优化空间。
从这个符号表文件可以看出,默认的方法和函数排列的顺序是这样的,比如我们的_main函数就被排到了很后面(6132行)。如下图
正如上面说的,二进制重排是我们人为的来排列这些符号的先后顺序,尤其是那些启动的时候就要调用的方法函数,直接排到前面去,分在前一张或前几张页表里,那么就可以减少page falut的次数了。那么问题来了哪些符号是app启动的时候就要调动的呢?他们的顺序是啥?这个怎么确定呢?
Clang插桩 来hook所有的符号和他们的顺序。 别逼逼,直接showCode:
-
首先在Xcode中添加配置(-fsanitize-coverage=func,trace-pc-guard)
添加函数,就是通过这2个函数来hook所有的符号的。
void __sanitizer_cov_trace_pc_guard_init(uint32_t *start,
uint32_t *stop) {
static uint64_t N; // Counter for the guards.
if (start == stop || *start) return; // Initialize only once.
printf("INIT: %p %p\n", start, stop);
for (uint32_t *x = start; x < stop; x++)
*x = ++N; // Guards should start from 1.
}
void __sanitizer_cov_trace_pc_guard(uint32_t *guard) {
if (!*guard) return; // Duplicate the guard check.
// If you set *guard to 0 this code will not be called again for this edge.
// Now you can get the PC and do whatever you want:
// store it somewhere or symbolize it and print right away.
// The values of `*guard` are as you set them in
// __sanitizer_cov_trace_pc_guard_init and so you can make them consecutive
// and use them to dereference an array or a bit vector.
void *PC = __builtin_return_address(0);
char PcDescr[1024];
// This function is a part of the sanitizer run-time.
// To use it, link with AddressSanitizer or other sanitizer.
__sanitizer_symbolize_pc(PC, "%p %F %L", PcDescr, sizeof(PcDescr));
printf("guard: %p %x PC %s\n", guard, *guard, PcDescr);
}
-
最后得到的符号顺序,输出到一个xxx.order 的文件中。
-
把我们重排的xxx.order 文件放到项目的根目录下,并在xcode中Order File 里配置order的执行路径。这样xcode就会把我们自己排序好的符号打包进去,App启动时虚拟内存到物理内存中间的页表里的符号就会按这个order先后顺序来。如下图
-
最后编译一下,再来看一下启动时间和page fault 的耗时。如图
注:当然上面的所有操作都是为了拿到order文件,所以这个操作只要在发包之前做一次就可以了。
总结:
二进制重排解决的是对pre-main之前的优化,通过对比可以看出优化的效果看出是毫秒级别的优化,其实肉眼根本看不出来效果的差异的....
相比较于二进制重排的优化,还不如项目里垃圾代码写少一些,效果可能更好,哈哈哈哈哈!
故二进制重排是在业务代码已经优化得不能再优化了(没优化空间)的情况下进行的,把启动再优化几十上百毫秒。
不要指望着它能给你带来多么明显的优化效果,你代码本来写的就垃圾,上帝也救不了你的,哈哈哈哈哈,没错,说的就是我!
最后安利一下抖音团队关于二进制重排的文章,也就是抖音把这个技术带火了🔥 。抖音研发实践:基于二进制文件重排的解决方案 APP启动速度提升超15%