iOS Command + R 编译全过程详解

版权声明:本文为博主原创文章,未经博主允许不得转载。转载请Email我....... https://blog.csdn.net/Deft_MKJing/article/details/82929014

目录

编译器介绍

Clang+LLVM编译过程

记录Xcode编译一次全过程

iOS客户端启动优化分析

总结项目Build到加载应用到手机展示出首页的大概过程如下


前言

这几天看了下编译过程,就想到了头条的技术博客写了启动时间优化,把几个博客的知识点整理验证了下。国庆期间,晚上有空把知识点重新梳理下,方便以后查阅,毕竟看懂了不是真的懂,可能睡了一觉就乱了思路,很有必要把思路整理写出来。因此就有了以下知识点

1.Xcode编译器简单介绍(GCC和Clang+LLVM)

2.Xcode build全过程

3.iOS文件编译过程

4.客户端启动时间优化思路

5.main()之前发生了什么?或者介绍下编译过程做了什么?

编译器介绍

编译器的作用无非就是把我们的高级语言转换成机器可以识别的语言,一般经典的设计就分为三段式,如下

前端(Frontend)--优化器(Optimizer)--后端(Backend)

  • 其中前端负责分析源代码,可以检查语法级错误,并构建针对该语言的抽象语法树(AST)
  • 抽象语法树可以进一步转换为优化,最终转为新的表示方式, 然后再交给让优化器和后端处理
  • 最终由后端生成可执行的机器码

理论上一个语言对应一个编译器,这样就引入了中间优化器,可以接受多种语言,然后输出对应的机器语言,这样前端只负责输入,后端负责输出即可,新增一种语言,就在中间优化层增加逻辑即可。

前端

编译器前端的任务是进行:语法分析,语义分析,生成中间代码(intermediate representation )。在这个过程中,会进行类型检查,如果发现错误或者警告会标注出来在哪一行。

后端(优化中间层和后端)

编译器后端会进行机器无关的代码优化,生成机器语言,并且进行机器相关的代码优化。iOS的编译过程,后端的处理如下

  • LVVM优化器会进行BitCode的生成,链接期优化等等
  • LLVM机器码生成器会针对不同的架构,比如arm64等生成不同的机器码

这里先明白一个概念,Xcode编译过程中,从Cocoapods开始到对应的每个m文件,都会进行各自的编译过程,抛开其他编译发生的事情,每个编译流程大致如下

1. 源代码(source code) 
2. 预处理器(preprocessor) 
3. 编译器(compiler) 
4. 汇编程序(assembler) 
5. 目标代码(object code) 
6. 链接器(Linker) 
7. 可执行文件(executables)

Xcode3之前都是GCC作为编译器前端,由于Apple的快速发展需要GCC开发团队更快的支持,由于更新跟不上,就自己开发了Clang来做自己的编译器前端,而且性能是GCC的几倍,功能更人性化,

clang用途:输出代码对应的抽象语法树(Abstract Syntax Tree, AST),并将代码编译成LLVM Bitcode。接着在后端(back-end)使用LLVM编译成平台相关的机器语言。

LLVM包含LLVM中介码(LLVM IR)、LLVM除错工具、LLVM C++标准库等一套工具,作为编译器后端

Clang+LLVM编译过程

先看一下简单的Demo了解大概流程

#import <Foundation/Foundation.h>

int main (int argc, const char * argv[])
{

    @autoreleasepool
    {	
    	// insert code here...
    	NSLog(@"Hello, Mikejing");
    }
	return 0;
}
mintoudeMacBook-Pro-2:buildDemo mintou$ clang -ccc-print-phases -framework Foundation main.m -o main 
0: input, "Foundation", object
1: input, "main.m", objective-c
2: preprocessor, {1}, objective-c-cpp-output  预处理 编译器前端
3: compiler, {2}, ir 编译生成中间码IR
4: backend, {3}, assembler LLVM后端生成汇编
5: assembler, {4}, object 生成机器码
6: linker, {0, 5}, image  链接器
7: bind-arch, "x86_64", {6}, image 生成image,最后生成可执行二进制文件(image这里是镜像文件)

接着执行./main,就能输出上面的Log了。


1.预处理阶段

import头文件替换

macro宏展开

处理预编译指令   # 或者 __attribute__

2.词法分析(输出Token流)

预处理完成后就会进行词法分析,这里会把代码切成一个个 Token,比如大小括号,等于号还有字符串等。

clang -fmodules -fsyntax-only -Xclang -dump-tokens main.m

简单截取部分代码如下
int main (int argc, const char * argv[])
{

    @autoreleasepool
    {	
    	// insert code here...
    	NSLog(@"H'		Loc=<main.m:9:1>
int 'int'	 [StartOfLine]	Loc=<main.m:11:1>
identifier 'main'	 [LeadingSpace]	Loc=<main.m:11:5>
l_paren '('	 [LeadingSpace]	Loc=<main.m:11:10>
int 'int'		Loc=<main.m:11:11>
identifier 'argc'	 [LeadingSpace]	Loc=<main.m:11:15>
comma ','		Loc=<main.m:11:19>
const 'const'	 [LeadingSpace]	Loc=<main.m:11:21>
char 'char'	 [LeadingSpace]	Loc=<main.m:11:27>
star '*'	 [LeadingSpace]	Loc=<main.m:11:32>
identifier 'argv'	 [LeadingSpace]	Loc=<main.m:11:34>
l_square '['		Loc=<main.m:11:38>
r_square ']'		Loc=<main.m:11:39>
r_paren ')'		Loc=<main.m:11:40>

预处理完成了以后,开始词法分析。词法分析其实是编译器开始工作真正意义上的第一个步骤,其所做的工作主要为将输入的代码转换为一系列符合特定语言的词法单元,这些词法单元类型包括了关键字,操作符,变量等等

词法分析,只需要将源代码以字符文本的形式转化成Token流的形式,不涉及交验语义,不需要递归,是线性的。可以理解为告诉计算机,这是一个括号,这是一个大括号,这是一个星号等,i和f字母组合就是if关键字

什么是token流呢?可以这么理解:就是有"类型",有"值"的一些小单元。

 3.语法分析,输出抽象树(AST)

上面是词法分析,只是负责告诉计算机这是什么意思,先翻译出来,然后进入语法分析,这里的输出结构为抽象语法树,期间会校验语法是否错误

Static Analysis 静态分析

  • 通过语法树进行代码静态分析,找出非语法性错误   
  • 模拟代码执行路径,分析出control-flow graph(CFG) 【MRC时代会分析出引用计数的错误】
  • 预置了常用Checker(检查器)
clang -fmodules -fsyntax-only -Xclang -ast-dump main.m

4.codegen生成IR中间代码

上述三个步骤之后就开始告别前端

CodeGen 会负责将语法树自顶向下遍历逐步翻译成 LLVM IR,IR 是编译过程的前端的输出后端的输入。

clang -S -fobjc-arc -emit-llvm main.m -o main.ll

至此前端工作完成,把IR中间码输入到优化器

5.Optimize - 优化IR

这里 LLVM 会去做些优化工作,在 Xcode 的编译设置里也可以设置优化级别-01,-03,-0s,还可以写些自己的 Pass。Pass 是 LLVM 优化工作的一个节点,一个节点做些事,一起加起来就构成了 LLVM 完整的优化和转化。

6.LLVM Bitcode - 生成字节码

如果开启了 bitcode 苹果会做进一步的优化,有新的后端架构还是可以用这份优化过的 bitcode 去生成。

clang -emit-llvm -c main.m -o main.bc

7.生成汇编

生成Target相关Object(Mach-O)

clang -S -fobjc-arc main.m -o main.s

8.Link生成目标文件并执行

clang -fmodules -c main.m -o main.o
clang main.o -o main
./main

以下是实际输出 通过自己编译一步步生成可执行文件并且输出
mintoudeMacBook-Pro-2:buildDemo mintou$ ls
Package			main			main.m
PackageVersion.plist	main.bc			main.o
buildDemo-Prefix.pch	main.ll
mintoudeMacBook-Pro-2:buildDemo mintou$ ./main 
2018-10-02 23:20:02.630 main[14890:190383] Hello, Mikejing

记录Xcode编译一次全过程

当你在Xcode使用command + b 或者 +r的时候,都会进行如下过程,查看具体log可以看Xcode最右边的tab

1.优先编译cocopods里面的所有依赖文件

2.编译信息写入辅助信息,创建编译后的文件架构

3.处理打包信息,例如development环境下处理xxxx.entitlements的打包信息

4.执行cocopods编译前脚本 checkPods Manifest.lock

5.编译包内所有m文件 (使用Compilec和Clang的几个主要命令)

这里就不贴图了,看编译信息应该看到最多的就是m文件编译信息,具体无非就是一堆命令,如果赋值到文本里面单独看,可以很清晰的分隔读懂。

最重要的一些编译介绍

clang是实际的编译命令
-x      objective-c 指定了编译的语言
-arch   x86_64制定了编译的架构,类似还有arm7等
-fobjc-arc 一些列-f开头的,指定了采用arc等信息。这个也就是为什么你可以对单独的一个.m文件采用非ARC编程。
-Wno-missing-field-initializers 一系列以-W开头的,指的是编译的警告选项,通过这些你可以定制化编译选项
-DDEBUG=1 一些列-D开头的,指的是预编译宏,通过这些宏可以实现条件编译
-iPhoneSimulator10.1.sdk 制定了编译采用的iOS SDK版本
-I 把编译信息写入指定的辅助文件
-F 链接所需要的Framework
-c ClassName.c 编译文件
-o ClassName.o 编译产物

6.链接需要的framework,例如AFNetworking.framework,Masonry.framework等信息

上面省略了编译m文件的截图,下面开始就是编译资源图片,这个link framework就是承上启下的作用

7.编译xib文件

8.copy Xib文件,图片等资源文件放到结果目录

9.编译imageAsserts

10.处理infoplist

11.执行Cocoapods脚本

12.copy标准库

13.创建.app文件和签名

10-13的过程

生成一个可执行文件(自身app所有.o文件的集合)

iOS客户端启动优化分析

total(App总时间) = t1(main()之前加载时间) + t2(main()之后的加载时间)

t1 = 系统dylib(动态链接库) + 自身App可执行文件的加载

t2 = main方法执行之后到Appdelegate类中的 didFinishLaunchingWithOptions 执行方法结束前这段时间,这里主要构件第一个页面并且渲染出来,也就是tabbar的初始化和首页渲染

main()函数之前加载过程

App开始启动后,系统首先加载可执行文件(自身所有.o文件的集合),然后加载动态链接器dyld,dyld是专门用来加载动态链接的库。执行从dyld开始,dyld可执行文件的依赖开始,递归加载所有依赖动态链接库。

动态链接库包括:iOS 中用到的所有系统 framework,加载OC runtime方法的libobjc,系统级别的libSystem,例如libdispatch(GCD)和libsystem_blocks (Block)。

其实无论对于系统的动态链接库还是对于App本身的可执行文件而言,他们都算是image(镜像),而每个App都是以image(镜像)为单位进行加载的,那么image究竟包括哪些呢?

什么是image

1.executable可执行文件  比如所有的.o文件 
2.dylib 动态链接库 framework就是动态链接库和相应资源包含在一起的一个文件夹结构。 
3.bundle 资源文件 只能用dlopen加载,不推荐使用这种方式加载。

除了我们App本身的可行性文件,系统中所有的framework比如UIKit、Foundation等都是以动态链接库的方式集成进App中的。

动态链接和静态链接对比

动态链接,代码共用:很多程序都动态链接了这些 lib,但它们在内存和磁盘中中只有一份。 易于维护:由于被依赖的 lib 是程序执行时才链接的,所以这些 lib 很容易做更新。 减少可执行文件体积:相比静态链接,动态链接在编译时不需要打进去,所以可执行文件的体积要小很多。

理解:根据文章上方介绍的编译过程,如果是.a静态文件,那么资源和文件都会编译进去,打包成.app文件的时候就会包含静态资源。如果每个程序都这样吧需要的静态文件打进去,那么可执行文件(所有.o文件的集合)就会大一点,而且代码都重复。因此就有了动态链接的方式,在.app最终打出来的包启动的时候,首先会加载可执行文件,然后动态链接器(dyld)启动,这里就会把所有的依赖库递归链接起来,

例如framework中的UIKit和Foundation框架,

runtime libobjc以及系统libSystem,GCD,Block

都是通过dyld连接集成进app的

所有动态链接库和我们App中的静态库.a和所有类文件编译后的.o文件最终都是由dyld(the dynamic link editor),Apple的动态链接器来加载到内存中。每个image都是由一个叫做ImageLoader的类来负责加载(一一对应),那么ImageLoader又是什么呢?

ImageLoader

image 表示一个二进制文件(可执行文件或 so 文件),里面是被编译过的符号、代码等,所以 ImageLoader 作用是将这些文件加载进内存,且每一个文件对应一个ImageLoader实例来负责加载。 
在程序运行时它先将动态链接的 image 递归加载 (也就是上面测试栈中一串的递归调用的时刻)。 再从可执行文件 image 递归加载所有符号。

当然所有这些都发生在我们真正的main函数执行前。

Xcode设置打印Main函数之前的调用

项目中打开上述窗口,然后把DYLD_PRINT_STATISTICS变量写入,标记为1即可,然后运行真机调试测试如下打印

Total pre-main time: 388.62 milliseconds (100.0%)
         dylib loading time:  45.41 milliseconds (11.6%)
        rebase/binding time:  30.10 milliseconds (7.7%)
            ObjC setup time: 135.39 milliseconds (34.8%)
           initializer time: 177.57 milliseconds (45.6%)
           slowest intializers :
             libSystem.B.dylib :   7.66 milliseconds (1.9%)
    libMainThreadChecker.dylib :  39.82 milliseconds (10.2%)
                      MinTouJF : 229.16 milliseconds (58.9%)

1.dylib loading

在每个动态库的加载过程中, dyld需要:

  1. 分析所依赖的动态库
  2. 找到动态库的mach-o文件
  3. 打开文件
  4. 验证文件
  5. 在系统核心注册文件签名
  6. 对动态库的每一个segment调用mmap()

理解:这里的意思可以理解为分析动态需要添加的依赖库,动态添加镜像库文件

2.rebase/binding

由于ASLR(address space layout randomization)的存在,可执行文件和动态链接库在虚拟内存中的加载地址每次启动都不固定,所以需要这2步来修复镜像中的资源指针,来指向正确的地址。 rebase修复的是指向当前镜像内部的资源指针; 而bind指向的是镜像外部的资源指针。 
rebase步骤先进行,需要把镜像读入内存,并以page为单位进行加密验证,保证不会被篡改,所以这一步的瓶颈在IO。bind在其后进行,由于要查询符号表,来指向跨镜像的资源,加上在rebase阶段,镜像已被读入和加密验证,所以这一步的瓶颈在于CPU计算。 
通过命令行可以查看相关的资源指针:

xcrun dyldinfo -rebase -bind -lazy_bind myApp.App/myApp

优化该阶段的关键在于减少__DATA segment中的指针数量。我们可以优化的点有:

  1. 减少Objc类数量, 减少selector数量
  2. 减少C++虚函数数量
  3. 转而使用swift stuct(其实本质上就是为了减少符号的数量)

理解:第一步会加载所依赖的 dylibs(镜像库文件),修正地址偏移,因为 iOS 会用 ASLR 来做地址偏移避免攻击,可执行文件和动态链接库在虚拟内存中的加载地址每次都会变,所以需要rebase和bind来确定 Non-Lazy Pointer 地址进行符号地址绑定

3.ObjC setup

这一步主要工作是:

  1. 注册Objc类 (class registration)
  2. 把category的定义插入方法列表 (category registration)
  3. 保证每一个selector唯一 (selctor uniquing)

理解:这一步的其实第二步已经做了,因此不做太多,主要还是加载所有的类和Category

4.initializers

以上三步属于静态调整(fix-up),都是在修改__DATA segment中的内容,而这里则开始动态调整,开始在堆和堆栈中写入内容。 在这里的工作有:

  1. Objc的+load()函数
  2. C++的构造函数属性函数 形如attribute((constructor)) void DoSomeInitializationWork()  __attribute__
  3. 非基本类型的C++静态全局变量的创建(通常是类或结构体)(non-trivial initializer) 比如一个全局静态结构体的构建,如果在构造函数中有繁重的工作,那么会拖慢启动速度

Objc的load函数和C++的静态构造函数采用由底向上的方式执行,来保证每个执行的方法,都可以找到所依赖的动态库。

上图是在自定义的类XXViewController的+load方法断点的调用堆栈,清楚的看到整个调用栈和顺序:

  1. dyld 开始将程序二进制文件初始化
  2. 交由 ImageLoader 读取 image,其中包含了我们的类、方法等各种符号
  3. 由于 runtime 向 dyld 绑定了回调,当 image 加载到内存后,dyld 会通知 runtime 进行处理
  4. runtime 接手后调用 mapimages 做解析和处理,接下来 loadimages 中调用 callloadmethods 方法,遍历所有加载进来的 Class,按继承层级依次调用 Class 的 +load 方法和其 Category 的 +load 方法

至此,可执行文件中和动态库所有的符号(Class,Protocol,Selector,IMP,…)都已经按格式成功加载到内存中,被 runtime 所管理,再这之后,runtime 的那些方法(动态添加 Class、swizzle 等等才能生效)。

整个事件由 dyld 主导,完成运行环境的初始化后,配合 ImageLoader 将二进制文件按格式加载到内存, 动态链接依赖库,并由 runtime 负责加载成 objc 定义的结构,所有初始化工作结束后,dyld 调用真正的 main 函数。

对于main()调用之前的耗时我们可以优化的点有:

1.可执行文件的启动会链接动态库,因此精简动态库是第一步

2.check framework应当设为optional和required,如果该framework在当前App支持的所有iOS系统版本都存在,那么就设为required,否则就设为optional,因为optional会有些额外的检查

3.精简OC的类,删除不必要的类文件,能合并尽量合并

main()调用之后的加载时间

在main()被调用之后,App的主要工作就是初始化必要的服务,显示首页内容等。而我们的优化也是围绕如何能够快速展现首页来开展。 App通常在AppDelegate类中的didFinishLaunchingWithOptions:方法中创建首页需要展示的view,然后在当前runloop的末尾,主动调用CA::Transaction::commit完成视图的渲染。 
而视图的渲染主要涉及三个阶段:

  1. 准备阶段 这里主要是图片的解码
  2. 布局阶段 首页所有UIView的- (void)layoutSubViews()运行
  3. 绘制阶段 首页所有UIView的- (void)drawRect:(CGRect)rect运行 
    再加上启动之后必要服务的启动、必要数据的创建和读取,这些就是我们可以尝试优化的地方

因此,对于main()函数调用之前我们可以优化的点有:

  1. 不使用xib,直接视用代码加载首页视图
  2. NSUserDefaults实际上是在Library文件夹下会生产一个plist文件,如果文件太大的话一次能读取到内存中可能很耗时,这个影响需要评估,如果耗时很大的话需要拆分(需考虑老版本覆盖安装兼容问题)
  3. 每次用NSLog方式打印会隐式的创建一个Calendar,因此需要删减启动时各业务方打的log,或者仅仅针对内测版输出log
  4. 梳理应用启动时发送的所有网络请求,是否可以统一在异步线程请求

总结项目Build到加载应用到手机展示出首页的大概过程如下

1.优先编译cocopods里面的所有依赖文件

2.编译信息写入辅助信息,创建编译后的文件架构

3.处理打包信息,例如development环境下处理xxxx.entitlements的打包信息

4.执行cocopods编译前脚本 checkPods Manifest.lock

5.编译包内所有m文件 (使用Compilec和Clang的几个主要命令)

  1. 预处理 import,宏和#编译期间语法
  2. 词法分析 (,),{,},*等语义转换
  3. 语法分析    语义转换之后进行语法错误判断
  4. codegen生成IR中间代码 不同语言在中间层转换,等待输出对应给编译器后端的代码
  5. Opimize优化中间码
  6. LLVM Bitcode - 生成字节码 链接期优化等  后端架构生成之前的代码
  7. 汇编机器码生成器生成对应架构的机器码  例如x86_64,arm64,armv7等 后端架构对应机器输出二进制
  8. Link生成可执行文件 image 每一个可执行文件都在这里是image镜像文件
  9. 1-3为编译器前端,4-5是编译器中间优化层  6-7是后端

6.链接需要的framework,例如AFNetworking.framework,Masonry.framework等信息

7.编译xib文件

8.copy Xib文件,图片等资源文件放到结果目录

9.编译imageAsserts

10.处理infoplist

11.执行Cocoapods脚本

12.copy标准库

13.创建.app文件和签名

14.启动app,首先加载可执行文件(所有.o文件的集合)

15.加载动态链接器dyld,初始化二进制文件,加载开始递归链接所有依赖库进行连接 dylib loading

16.rebase/binding,因为 iOS 会用 ASLR 来做地址偏移避免攻击,可执行文件和动态链接库在虚拟内存中的加载地址每次都会变,所以需要rebase和bind来确定 Non-Lazy Pointer 地址进行符号地址绑定,修正地址偏移

17.注册类信息和Category信息,15-17这三个阶段都是静态修正

18.initializers开始动态在堆栈写信息,加载进内存,这里runtime向dyld注册了回调,当dyld把image镜像文件加载到内存后回调,安继承递归调用Class信息执行类的+load函数和Category的+load方法以及一些__attribute__的方法函数

19.至此可执行文件和动态库的符号(SEL,IMP,Class,Protocol)都被加载进内存,后续就由runtime管理,加载定义的objc结构,一切完成后再调用main(),启动runloop环境

20.main函数第一个方法didFinishLaunchingWithOptions从开始到第一个页面的显示,就是整个启动过程,这里会有些资源文件,网络请求,配置文件等,主要还是在runloop周期尾渲染出第一个页面

参考文献

德莱文大神

Leo大神编译原理

编译器介绍

掘金深入婆媳编译

Objc编译过程

好想进的公司头条的技术博客

猜你喜欢

转载自blog.csdn.net/Deft_MKJing/article/details/82929014