Clang-LLVM下,一个源文件的编译过程

LLVM是什么?

LLVM 是编译器工具链技术的一个集合。而其中的 lld 项目,就是内置链接器。

编译器会对每个文件进行编译,生成 Mach-O(可执行文件);链接器会将项目中的多个Mach-O 文件合并成一个。

Xcode运行的过程就是执行一些命令脚本,下面的截图是Xcode编译main.m的脚本。

在bin目录下找到clang命令,在后面加一些参数,比如什么语言,编译到哪些架构上,追加在Xcode设置的配置的参数,最后输出成.o文件。

Xcode_shell_cmd.png

LLVM 编译器架构

LLVM编译器架构.png

编译器分为三部分,编译器前端、通用优化器、编译器后端,中间的优化器是不会变的

增加一种语言只需要处理好编译器前端就行了

增加一种架构,只需要添加一种编译器后端的架构处理就可以了

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
复制代码

OC源文件的编译过程.png

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
复制代码

以下面这段代码为例:

demo.png

第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
复制代码

AST.png

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的就是无用函数。

总结:一个源文件的编译过程

一个源文件的编译过程.png

代码实践

#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 导入动态库时,先根据记录的库路径找到对应的库,再通过记录的名字符号找到绑定的地址。

优点:

代码共用、易于维护、减少可执行文件的体积

参考资料:

LLVM框架/LLVM编译流程/Clang前端/LLVM IR/LLVM应用与实践

iOS底层学习 - 从编译到启动的奇幻旅程

sunnyxx的clang视频分享

猜你喜欢

转载自juejin.im/post/7036170157357531144