本节,我们给大家介绍一个伟大的架构编译器LLVM
。
- 什么是编译器
- LLVM概述
- LLVM案例体验
1 什么是编译器?
1.1 Python案例
- 创建
python
文件夹,新建helloDemo.py
文件,内容如下:
print("hello")
- 调用
python helloDemo.py
执行文件,打印出python
1.2 C 案例
-
vim
创建helloDemo.c
文件:
#include <stdio.h>
int main(int a, char * argv[]) {
printf("hello \n");
return 0;
}
-
clang helloDemo.c
编译,生成a.out
文件。file a.out
查看文件:
发现.out
文件是:64位的Mach-O
可执行文件,当前clang
出来的是x86_64
架构, mac
电脑可读。 所以可以./a.out
直接执行:
Q:
解释型
语言与编译型
语言
python
是解释型语言
,一边翻译
一边执行
。和js
一样,机器可直接执行。C
语言是编译型语言
,不能直接执行,需要编译器
将其转换
成机器识别语言
。
编译型语言
:编译后
输出的是指令
(0、1组合),cpu可直接执行指令
解释性语言
:生成的是数据
,不是0、1组合
,机器也能直接识别
编译器
的作用,就是将高级语言
转化为机器
能够识别
的语言
(可执行文件
)。
Q:汇编有指令吗?
早期科学家,使用
0、1编码
。 比如00001111
对应call
,00000111
对应bl
。有了对应关系
后。 再手敲
0和1就有点难受
了。于是写个中间解释器
,我们只用输入call
、bl
这样的标记指令
,经过解释器
,变成0和1的组合,再交给机器去执行。 这就是汇编的由来
。而基于汇编往上,再
映射
和封装
相关对应关系
。就跨时代性
的c
语言,再往上
层封装,就出现了高级语言oc
、swift
等语言。所以汇编执行快
,因为它是直接转换
为机器语言
的。但
汇编
的指令集
,是针对同一操作系统
而言,它不
支持跨平台
。机器指令
是cpu
的在识别
。早期的计算机厂家
非常多
,虽然都用0
和1
的组合
,但相同组合背后却是相应不同
的指令
。所以汇编无法跨平台
,不同操作系统
下,汇编指令
是不同
的。
2. LLVM概述
-
LLVM
是架构编译器
(compiler
)的框架系统
,以c++
编写而成,用于优化
以任意程序语言
编写的程序的编译时间
(compile-time
)、链接时间
(link-time
)、运行时间
(run-time
)以及空闲时间
(idle-time
),对开发者保持开放,并兼任已有脚本。 - 2006年
Chris Lattner
加盟Apple Inc.
并致力于LLVM
在Apple开发体系
中的应用。Apple
也是LLVM计划
的主要资助者
。
目前LLVM
已经被苹果iOS开发工具
、Xilinx Vivado
、Facebook
、Google
等各大公司采用。
2.1 传统编译器的设计
- 编译器前端(Frontend):
编译器的前端任务
是解析源代码
。 会进行词法分析
、语法分析
、语义分析
。检查源代码
是否存在错误
,然后构建抽象语法树
(Abstract Syntax Tree AST),LLVM前端
还会生成中间代码
(intermediate representation, IR)
- 优化器(Optimizer)
优化器负责各种优化
。改善
代码的运行时间
,如消除冗余计算
等
- 后端(Backkend)/ 代码生成器(CodeGenerator)
将代码映射
到目标指令集
,生成机器语言
,并进行机器相关
的代码优化
(目标指不同操作系统
)
iOS的编译器架构:
Objective C
/C
/C++
使用的编译器前端
是Clang
,Swift
是swift
,后端都是LLVM
。
2.2 LLVM的设计
GCC
是一个非常成功
的编译器
,但由于它作为整体应用程序
设计的,用途
受到了限制
。LLVM
最重要的地方:支持多种语言
或多种硬件架构
。使用通用代码
表示形式:IR
(用来在编译器中表示代码的形式)LLVM
可以为任何编程语言
独立编写前端
,也可以为任何硬件架构
独立编写后端
.所以LLVM
不是
一个简单的编译器
,而是架构编译器
,可以兼容
所有前端
和后端
。
2.3 Clang
Clang
是LLVM项目
的一个子项目
。基于LLVM架构
的轻量级编辑器
,诞生之初
就是为了替代GCC
,提供更快
的编译速度
。 他是负责编译C
、C++
、Objecte-C
语言的编译器
,它属于
整个LLVM架构
中的编译器前端
。
- 对于开发者而言,
研究Clang
可以给我们带来很多好处
。
3. LLVM案例体验
- 新建一个
Mac OS
的命令行
工程:
-
没有改动代码
3.1 编译流程
- cd到
main.m
的文件夹。使用下面命令查看main.m
的编译步骤:
clang -ccc-print-phases main.m
编译流程
分为以下7步
:
-
0: input, "main.m", objective-c
:
输入文件:找到源文件 -
1: preprocessor, {0}, objective-c-cpp-output
:
预处理:宏的展开,头文件的导入 -
2: compiler, {1}, ir
:
编译:词法、语法、语义分析,最终生成IR -
3: backend, {2}, assembler ()
:
汇编: LLVM通过一个个的Pass去优化,每个Pass做一些事,最后生成汇编代码 -
4: assembler, {3}, object
:
目标文件 -
5: linker, {4}, image
:
链接: 链接需要的动态库和静态库,生成可执行文件 -
6: bind-arch, "x86_64", {5}, image
:
架构可执行文件:通过不同架构,生成对应的可执行文件
optimizer优化
并没有
作为一个独立阶段
,在编译阶段
内部完成
的
3.2 预处理阶段
-
main.m
中准备测试代码
:
#import <stdio.h>
#define C 30
int main(int argc, const char * argv[]) {
@autoreleasepool {
int a = 10;
int b = 20;
printf("%d", a + b + C);
}
return 0;
}
-
clang
预编译输出main2.m
文件:
clang -E main.m >> main2.m
-
打开
main2.m
,有575行
。其中大部分是stdio
库的代码:
我们发现测试代码中的
宏C
,在预编译阶段
完成了替换
,变成了30
预编译阶段: 1.
导入头文件
2.替换宏
- 修改测试代码,给
int类型
取个别名HT_INT_64
,再次预编译处理
:
#define C 30
typedef int HT_INT_64;
int main(int argc, const char * argv[]) {
@autoreleasepool {
HT_INT_64 a = 10;
HT_INT_64 b = 20;
printf("%d", a + b + C);
}
return 0;
}
- 发现
typedef
不会被替换
安全拓展:
- 使用
define
将重要方法
名称进行替换
。比如#define Pay XXXTest
这样开发者使用宏Pay
开发舒服,但是被hank
时,实际代码是XXXTest
,不容易被察觉。
(#define
的真实内容
,不应该
写成乱码
,会让人有此地无银三百两
的感觉,最好
弄成系统类似名称
或其他不经意
的名称
。这样才容易
被忽视
,安全级别
才更高
😃)
typedef
没有这个偷梁换柱的效果。define
只影响预处理期。
3.3 编译阶段
3.3.1 词法分析
- 编译
main.m
文件:
clang -fmodules -fsyntax-only -Xclang -dump-tokens main.m
-词法分析
,就是根据空格
和括号
这些将代码拆分
成一个个Token
。标注了位置
是第几行
的第几个字符
开始的。
3.3.2 语法分析
-
语法分析
是验证语法
是否正确
。
在词法分析的基础上,将单词
序列组合
成各类语法短语
,如“程序”,“语句”,“表达式”等,然后将所有节点组成抽象语法树
(Abstract Syntax Tree,AST)。语法分析程序
判断源程序
在结构
上是否正确
。
clang -fmodules -fsyntax-only -Xclang -ast-dump main.m
-
作用域
、类型
、运算方式
都十分清晰
。( 语法树一次只能处理一次计算。两次运算,就得多分一层级。)
语法分析
,就是在生成语法树
时完成检测
的。
- 头文件找不到时,可以指定SDK:
clang -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator12.2.sdk(自己SDK路径) -fmodules -fsyntax-only -Xclang -ast-dump main.m
3.4 生成中间代码IR(Intermediate representation)
3.4.1 生成中间代码
完成以上步骤后,就开始生成
中间代码IR
,代码生成器(Code Generation)会将语法树自顶向下
遍历逐步翻译成LLVM
的IR
。便于理解,我们简化代码:
#import <stdio.h>
int test(int a, int b) {
return a + b + 3;
}
int main(int argc, const char * argv[]) {
int a = test(1,2);
printf("%d",a);
return 0;
}
通过下面命令生成.ll
文本文件,查看IR代码:
clang -S -fobjc-arc -emit-llvm main.m
- IR基本语法
@
全局标识
%
局部标识
alloca
开辟空间
align
内存对齐
i32
32个bit,4个字节
store
写入内存
load
读取数据
call
调用数据
ret
返回
- 使用
VSCode
或Sublime Text
可以打开代码:(可以指定文件
的语言
,让代码
有高亮色
)
- Q:图中为何
多创建
那么多局部变量
?(如test函数内的a5、a6)- 因为在上一阶段(
编译阶段
),我们将代码
编译成了语法树结构
。而此时,我们只是沿
着语法树
进行读取
。 语法树每一个层级
,都需要
一个临时变量
来承接
。再返回上一层级处理
。- 所以会
产生
那么多局部变量
。
3.4.2 IR优化
- 我们可以在
Xcode
的Build Settings
中搜索Optimization
,可以看到优化级别。
(Debug模式
默认None [O0]
无优化,Release模式
默认Fastest,Smallest [Os]
最快最小)
LLVM的优化级别分为
-O0
、-O1
、-O2
、-O3
、-Os
(第一个字母是Optimization的O)。分别选择
O0
和Os
两个优化等级进行中间代码的生成比较:
clang -S -fobjc-arc -emit-llvm main.m -o mainO0.ll // O0 无优化
clang -Os -S -fobjc-arc -emit-llvm main.m -o mainOs.ll // Os 最快最小
-
优化后
的代码,舒服
多了。之前那些冗余
的临时局部变量
,也都被优化
,代码量减少
很多。
3.4.3 bitCode再优化
-
Xcode7之后
,开启bitCode
苹果会再进一步优化
,生成.bc
的中间代码
。
优化体现
:上传APPstore的包
,针对不同型号手机
做了区分
,不同型号手机下载
时,包
的大小不同
。
clang -emit-llvm -c main.ll -o main.bc
3.5 生成汇编代码
完成
中间代码
的生成后,可以将代码转变
为汇编代码
了。-
此刻我们有
4种
不同程度的代码(源代码
->无优化IR代码
->Os优化IR代码
->bitcode优化代码
):
分别对
4种程度
的代码输出汇编
文件:
clang -S -fobjc-arc main.m -o main.s
clang -S -fobjc-arc main.ll -o mainO0.s
clang -S -fobjc-arc mainOs.ll -o mainOs.s
clang -S -fobjc-arc main.bc -o mainbc.s
可以看到在生成汇编代码
时,只有选择
了优化等级
,才能减少
汇编代码量
。
【拓展】在
生成中间代码
的前后
,都可以
进行优化
。
- [尝试一] 将
main.m
直接选择Os级别
优化生成.s
汇编文件clang -Os -S -fobjc-arc main.m -o mainOs.s
- [尝试二] 将
main.m
生成无优化
的main.s
,再main.s
选择Os级别
优化生成.s
汇编文件clang -S -fobjc-arc -emit-llvm main.m -o mainO0.ll clang -Os -S -fobjc-arc mainO0.ll -o mainOoOs.s
- [尝试三] 将
main.m
选择Os级别
优化生成main.s
,再main.s
选择无优化
级别生成.s
汇编文件clang -Os -S -fobjc-arc -emit-llvm main.m -o mainOs.ll clang -S -fobjc-arc mainOs.ll -o mainOsOo.s
- [尝试四] 将
main.m
选择Os级别
优化生成main.s
,再main.s
选择Os级别
优化生成.s
汇编文件clang -Os -S -fobjc-arc -emit-llvm main.m -o mainOs.ll clang -Os -S -fobjc-arc mainOs.ll -o mainOsOs.s
- 内容比较:
3.6 生成目标文件(机器代码)
-
生成汇编文件
后,汇编器
以汇编代码
作为输入
,将汇编代码转换
为机器代码
,输出
目标文件(object file
)
clang -fmodules -c main.s -o main.o
file
对比一下main.s
汇编代码和main.o
机器代码:file main3.m file main.o
-
xcrun
执行nm
命令查看main.o
文件中的符号
:
xcrun nm -nm main.o
- 此时只是把
当前文件
编译为了机器码
,外部符号
(如printf
)无法识别。
undefined:
表示当前文件
暂时找不到符号
。
external:
表示这个符号
是外部可以访问
的。(实现
不在我这,在外部
的某个地方
)
所以当前虽转换
成了机器代码
。但是只是目标文件
,并不能
直接执行
,需要将
所有资源链接
起来,才可以执行
。
3.7 生成可执行文件(链接)
- 通过
链接器
把编译产生的.o
文件和.dylib
、.a
文件链接关联
起来,生成真正的mach-o可执行文件
clang main.o -o main // 将目标文件转成可执行文件
file main // 查看文件
xcrun nm -nm main // 查看main的符号
- 对比
main.o
目标文件,此时生成的main
文件:
- 从
object
文件变成了executable
可执行文件- 虽然都有
undefined
,但是可执行文件
中指定了该符号
的来源库
。机器在运行时
,会从相应的库
中取读取
该符号
(printf
)
至此,我们已完整分析:源代码
到可执行文件
的整个流程
:
-
下一节
,我们尝试玩LLVM
。(创建插件
,增加代码规范
,有效智能提示
)
(ps:LLVM源码下载
和编译教程
,都在OC底层原理三十二:LLVM插件(Copy修饰符检测)中)