LLVM是什么?
LLVM 是编译器工具链技术的一个集合。而其中的 lld 项目,就是内置链接器。
编译器会对每个文件进行编译,生成 Mach-O(可执行文件);链接器会将项目中的多个Mach-O 文件合并成一个。
Xcode运行的过程就是执行一些命令脚本,下面的截图是Xcode编译main.m的脚本,在bin目录下找到clang命令 在后面加一些参数 比如什么语言 编译到哪些架构上,追加在Xcode设置的配置的参数,最后输出成.o文件。
LLVM 编译器架构
编译器分为三部分,编译器前端、通用优化器、编译器后端,中间的优化器是不会变的
增加一种语言只需要处理好编译器前端就行了
增加一种架构,只需要添加一种编译器后端的架构处理就可以了
clang在编译器架构中表示 C、C++、Objective-C的前端,在命令行中也作为一个“黑盒”的Driver,封装了编译管线、前端命令、LLVM命令、Toolchain命令等。
LLVM会执行上述的整个编译流程,大体流程如下:
- 你写好代码后,LLVM会预处理你的代码,比如把宏嵌入到对应的位置。
- 预处理完后,LLVM 会对代码进行词法分析和语法分析,生成 AST 。AST 是抽象语法树,结构上比代码更精简,遍历起来更快,所以使用 AST 能够更快速地进行静态检查,同时还能更快地生成 IR(中间表示)
- 最后 AST 会生成 IR,IR 是一种更接近机器码的语言,区别在于和平台有关,通过 IR 可以生成多份适合不同平台的机器码。对于 iOS 系统,IR 生成的可执行文件就是 Mach-O。
OC源文件的编译过程
使用以下命令,查看OC源文件的编译过程
clang -ccc-print-phases main.m
0:先找到main.m文件
1:预处理器,就是把include、import、宏定义给替换掉
2:编译成IR中间代码
3:把中间代码给后端,生成汇编代码
4:汇编生成目标代码
5:链接静态库、动态库
6:适合某个架构的代码
预处理
使用以下命令,可以查看预处理阶段所做的工作
clang -E main.m
预处理主要做了以下几件事情:
1、删除所有的#define,代码中使用宏定义的地方会进行替换
2、将#include包含的文件插入到文件的位置,这个插入的过程是递归的
3、删除掉注释符号及注释
4、添加行号和文件标识,便于调试
编译
编译的过程就是把预处理后的文件进行 词法分析、语法分析、语义分析及优化后产生相应的汇编代码
1、词法分析
这一步把源文件中的代码转化为特殊的标记流,源码被分割成一个一个的字符和单词,在行尾Loc中都标记出了源码所在的对应源文件和具体行数,方便在报错时定位问题。
使用以下命令来进行词法分析
clang -Xclang -dump-tokens main.m
以下面这段代码为例:
第11行的这段源码
int main(int argc, char * argv[]) {
通过词法分析,会转化为以下的特殊标记
int 'int' [StartOfLine] Loc=<main.m:11:1>
identifier 'main' [LeadingSpace] Loc=<main.m:11:5>
l_paren '(' Loc=<main.m:11:9>
int 'int' Loc=<main.m:11:10>
identifier 'argc' [LeadingSpace] Loc=<main.m:11:14>
comma ',' Loc=<main.m:11:18>
char 'char' [LeadingSpace] Loc=<main.m:11:20>
star '*' [LeadingSpace] Loc=<main.m:11:25>
identifier 'argv' [LeadingSpace] Loc=<main.m:11:27>
l_square '[' Loc=<main.m:11:31>
r_square ']' Loc=<main.m:11:32>
r_paren ')' Loc=<main.m:11:33>
l_brace '{' [LeadingSpace] Loc=<main.m:11:35>
2、语法分析
这一步就是根据词法分析的标记流,解析成一个语法树,在Clang中由Parser和Sema两个模块配合完成
在这里面每一个节点也都标记了自己在源码中的位置
验证语法是否正确,比如少一个;报一个错误提示
根据当前语言的语法,生成语义节点,并将所有的节点组合成抽象语法树
使用以下命令来进行语法分析
clang -Xclang -ast-dump -fsyntax-only main.m
会解析成以下的语法树
-FunctionDecl 0x7ffe251a8ce0 <main.m:11:1, line:20:1> line:11:5 main 'int (int, char **)'
|-ParmVarDecl 0x7ffe251a8b00 <col:10, col:14> col:14 argc 'int'
|-ParmVarDecl 0x7ffe251a8bc0 <col:20, col:32> col:27 argv 'char **':'char **'
`-CompoundStmt 0x7ffe251a9200 <col:35, line:20:1>
|-ObjCAutoreleasePoolStmt 0x7ffe251a91b8 <line:13:5, line:18:5>
| `-CompoundStmt 0x7ffe251a9188 <line:13:22, line:18:5>
| |-DeclStmt 0x7ffe251a8e30 <line:14:9, col:32>
| | `-VarDecl 0x7ffe251a8da8 <col:9, line:9:21> line:14:13 used eight 'int' cinit
| | `-IntegerLiteral 0x7ffe251a8e10 <line:9:21> 'int' 8
| |-DeclStmt 0x7ffe251a8ee8 <line:15:9, col:20>
| | `-VarDecl 0x7ffe251a8e60 <col:9, col:19> col:13 used six 'int' cinit
| | `-IntegerLiteral 0x7ffe251a8ec8 <col:19> 'int' 6
| |-DeclStmt 0x7ffe251a9010 <line:16:9, col:31>
| | `-VarDecl 0x7ffe251a8f18 <col:9, col:28> col:13 used rank 'int' cinit
| | `-BinaryOperator 0x7ffe251a8ff0 <col:20, col:28> 'int' '+'
| | |-ImplicitCastExpr 0x7ffe251a8fc0 <col:20> 'int' <LValueToRValue>
| | | `-DeclRefExpr 0x7ffe251a8f80 <col:20> 'int' lvalue Var 0x7ffe251a8da8 'eight' 'int'
| | `-ImplicitCastExpr 0x7ffe251a8fd8 <col:28> 'int' <LValueToRValue>
| | `-DeclRefExpr 0x7ffe251a8fa0 <col:28> 'int' lvalue Var 0x7ffe251a8e60 'six' 'int'
| `-CallExpr 0x7ffe251a9128 <line:17:9, col:30> 'void'
| |-ImplicitCastExpr 0x7ffe251a9110 <col:9> 'void (*)(id, ...)' <FunctionToPointerDecay>
| | `-DeclRefExpr 0x7ffe251a9028 <col:9> 'void (id, ...)' Function 0x7ffe20b20e88 'NSLog' 'void (id, ...)'
| |-ImplicitCastExpr 0x7ffe251a9158 <col:15, col:16> 'id':'id' <BitCast>
| | `-ObjCStringLiteral 0x7ffe251a9068 <col:15, col:16> 'NSString *'
| | `-StringLiteral 0x7ffe251a9048 <col:16> 'char [8]' lvalue "rank-%d"
| `-ImplicitCastExpr 0x7ffe251a9170 <col:26> 'int' <LValueToRValue>
| `-DeclRefExpr 0x7ffe251a9088 <col:26> 'int' lvalue Var 0x7ffe251a8f18 'rank' 'int'
`-ReturnStmt 0x7ffe251a91f0 <line:19:5, col:12>
`-IntegerLiteral 0x7ffe251a91d0 <col:12> 'int' 0
3、静态分析(通过语法树进行代码静态分析,找出非语法性错误)
1、错误检查
如出现方法被调用但是未定义、定义但是未使用的变量
2、类型检查
一般会把类型分为两类:动态的和静态的。动态的在运行时做检查,静态的在编译时做检查。编写代码时可以向任意对象发送任何消息,在运行时,才会检查对象是否能够响应这些消息。
4、CodeGen - IR代码生成
1、CodeGen 负责将语法树从顶至下遍历,翻译成 LLVM IR
2、LLVM IR是Frontend的输出,也是LLVM Backend的输入,前后端的桥接语言
3、与Objective-C Runtime 桥接
与Objective-C Runtime 桥接的应用
1、在Objective-C中的 Class / Meta Class / Protocol /Category 这些结构体的内存结构就是在这一步生成的,并放在了Mach-O指定的Section中(如 Class: _DATA, _objc _classrefs),这个 DATA段也会存放一些static变量
2、objct对象发送一个消息最终会编译成什么样子啊,会编译成objc_msgSend调用就发生在这一步,将语法树中的ObjCMessageExpr翻译成相应版本的objc_msgSend,对super关键字的调用翻译成objc_msgSendSuper
3、根据修饰符strong / weak /copy /atomic 合成@property自动实现的getter / setter、处理@synthesize也是这一步做的
4、生成block_layout的数据结构、变量的capture(
__block
/ 和__weak
),生成_block_invoke
函数都发生在这一步5、之前总说ARC是编译器帮我们插入一些内存管理的代码,具体也是在这一步完成的
ARC: 分析对象的引用关系,将objc_StoreStrong / Objc_StoreWeak等ARC代码的插入
将ObjCAutotreleasePoolStmt转译成objc_autoreleasePoolPush/Pop
实现自动调用[super dealloc]
为每个拥有ivar的Class 合成.cxx_destructor 方法来自动释放类的成员变量,代替MRC时代的 “self.xxx = nil”
LLVM的中间产物及优化
使用以下命令,生成LLVM中间产物IR(Intermediate Representation),把这个过程打印出来
clang -O3 -S -emit-llvm main.m -o main.ll
使用以下命令,会使用LLVM对代码进行优化。
//针对全局变量优化、循环优化、尾递归优化等。
//在 Xcode 的编译设置里也可以设置优化级别-01,-03,-0s,还可以写些自己的 Pass。
clang -emit-llvm -c main.m -o main.bc
生成汇编代码
使用以下命令,生成相对应的汇编代码。
clang -S -fobjc-arc main.m -o main.s
至此,编译阶段完成,将书写代码转换成了机器可以识别的汇编代码,汇编器是将汇编代码转变成机器可以执行的指令,每一个汇编语句几乎都对应一条机器指令。根据汇编指令和机器指令的对照表一一翻译就可以了。
使用以下命令,生成对应的目标文件。
clang -fmodules -c main.m -o main.o
后来的Xcode新建的工程里并没有pch文件,为什么呢?
pch文件就是把UIKit、Foundation这些库用pch文件import一下,这样就不用在每个源文件中去解析这么多东西了,现在iOS这边乱搞把一些全局的变量,自己模块的一些东西都放在里面。
Xcode里面出了一个modules的概念,各个setting里面也是打开的,默认把库打成一个modules的形式,尤其是UIKit、Foundation这些库全部都是modules,好处就是我加这个参数(fmodules)以后它就会自动把#import变成@import,现在的编译就会比最早的那种连pch都没有的快很多,因为它的出现pch就不会默认出现了
$clang -E -fmodules main.m //加入fmodules参数生成可执行文件
链接
这一阶段是将上个阶段生成的目标文件和引用的静态库链接起来,最终生成可执行文件,链接器解决了目标文件和库之间的链接。
编译时链接器做了什么?
1、Mach-O里面主要是代码和数据,代码是函数的定义,数据是全局变量的定义,不管是代码还是数据都是通过符号关联起来的。
2、Mach-O里面的代码,要操作的变量和函数要绑定到各自的地址上,链接器的作用就是完成变量和函数的符号和其地址的绑定。
为什么要做符号绑定?
1、如果地址和符号不做绑定的话,要让机器知道你在操作什么地址,就需要写代码的时候设置好内存地址。
2、可读性差,修改代码后要重新对地址进行维护
3、需要针对不同平台写多份代码,相当于直接写汇编
为什么还要把项目中的多个Mach-O合并成一个?
1、多个文件之间的变量和接口是相互依赖的,就需要链接器把项目中多个Mach-O文件符号和地址绑定起来。
2、不绑定的话单个文件生成的Mach-O就是无法运行的,运行时遇到调用其他文件的函数实现时,就会找不到函数地址。
3、链接多个目标文件就会创建一个符号表,记录所有已定义和未定义的符号,如果出现相同符号的情况,就会出现“ld: dumplicate symbols”的错误信息,如果在目标文件中没有找到符号,就会提示“Undefined symbols”的错误信息。
链接器对代码主要做了哪几件事?
1、去代码文件中查找没有定义的变量
2、将所有符号定义和引用地址收集起来,并放到全局符号表中
3、计算合并后的长度及位置,生成同类型的段进行合并,建立绑定
4、对项目中不同文件里的变量进行地址重定位
链接器如何去除无用的函数,保证Mach-O的大小?
链接器在整理函数的调用关系时,会以main函数为源头跟随每个引用并将其标记为live,跟随完成后那些未被标记为live的就是无用函数。
总结:一个源文件的编译过程
代码实践
#import <Foundation/Foundation.h>
int main() {
NSLog(@"hello world!");
return 0;
}
1、生成Mach-O可执行文件
clang -fmodules main.m -o main
2、生成抽象语法树
clang -fmodules -fsyntax-only -Xclang -ast-dump main.m
3、生成汇编代码
clang -S main.m -o main.s
装载与链接
一个App从可执行文件到真正启动运行代码,基本需要经过装载和动态库链接两个步骤。
程序运行起来会拥有独立的虚拟地址空间,在操作系统上会同时运行多个进程,彼此之间的虚拟地址空间是隔离的。
装载就是把可执行文件映射到虚拟内存中的过程,由于内存资源稀缺,只将程序最常用的部分驻留在内存里,不太常用的数据放在磁盘里,这也是动态装载的过程。
装载的过程就是进程建立的过程,操作系统主要做了3件事:
1、创建一个独立的虚拟地址
2、读取可执行文件头,并且建立虚拟空间与可执行文件的映射关系
3、将CPU的寄存区设置成可执行文件的入口地址,启动运行
静态库
静态库是编译时链接的库,需要链接进你的Mach-O文件里,如果需要更新就重新编译一次,无法动态的加载和更新。
动态库
动态库是运行时链接的库,使用dyld就可以实现动态加载,iOS中的系统库都是动态链接的。
共享缓存
Mach-O是编译后的产物,而动态库在运行时才会被链接,所有Mach-O中并没有动态库的符号定义。
Mach-O中动态库中的符号是未定义的,但他们的名字和对应的库的路径会被记录下来。
运行时dlopen 和 dlsym 导入动态库时,先根据记录的库路径找到对应的库,再通过记录的名字符号找到绑定的地址。
优点:
代码共用、易于维护、减少可执行文件的体积