iOS底层探索之LLVM(一)——初识LLVM
1. 写在前面
现在出去面试,启动优化是绕不开的,到底我们的 APP
该如何去进行优化呢 ?在优化之前我们必须要先了解 LLVM
,那什么是 LLVM
呢?
在介绍LLVM
之前,先来认识一下解释型语言
和编译型语言
。
我们编写的
源代码
是偏向于我们人类直接的语言,我们非常轻松的就理解了,但是对于计算机硬件
(CPU)而言,简直就是个天书,计算机是无法直接运行的。计算机只能识别某些特定的二进制指令
,所以我们的代码在程序真正运行之前必须将源代码转换
成二进制指令。源代码转换成二进制指令,不同的编程语言有不同的规定。
解释型语言
有的编程语言可以一边执行一边转换,不会生成可执行文件再去执行,这种编程语言称为解释型语言
,使用的转换工具称为解释器
,比如 Python
、JavaScript
、PHP
等。
运行结果下面就举个例子,使用
vim
命令新建立一个python
文件,后缀为.py
,写入代码print("hello world!")
,通过python
命令,解释这段代码,打印一下hello world !
这句话。
我看可以看到解释型语言,它是边解释边执行,不可脱离解释器环境运行。
MAC
电脑自带了Python
环境,无需另外手动配置环境。
编译型语言
有的编程语言要转换成二进制指令
,也就是生成一个可执行程序这种编程语言称为编译型语言
,使用的转换工具称为编译器
,比如C
语言、C++
、OC
等。
编译型语言也同样举个例子,新建立一个
C
文件,写入如下代码:
#include<stdio.h>
int main (int argc,char *agrv[])
{
printf("hello world\n");
return 0;
}
通过clang hello.c
命令,进行编译处理,会生成一个可执行文件,如下图中红色的a.out
文件。
这个可执行文件,可以直接运行,通过
./a.out
即可运行,如图中也可以正常输出hello world
这句话。
编译型语言是先整体编译,再执行,运行速度快,任意改动需重新编译,可脱离编译环境运行。
小结:
-
解释型语言:读到相应代码就直接执行。
-
编译型语言:先将代码编译成计算机可以识别的二进制文件,才能执行。
扩展:
通过
open /usr/bin
命令可以查看,电脑上安装的一些系统软件。
/usr
不是user
的缩写,其实us
r是Unix Software Resource
的缩写, 也就是Unix
操作系统软件资源所放置的目录,而不是用户的数据;所有系统默认的软件都会放置到/us
r, 系统安装完时,这个目录会占用最多的硬盘容量。
Python在该目录下可以看到,有我们的
clang
编译器,还有Python
解释器,如下图所示:
MacOS
系统 默认安装的是python2
的环境,输入python
,按下enter
回车键,可以查看:查看python环境
警告:不推荐使用
Python 2.7
,为了与旧软件兼容macOS
中才包含了此版本。macOS
的未来版本将不包含Python 2.7
。
相反,建议您从终端内过渡到使用“python3”
。
如果你是python
的开发者,那么日常使用的是python3
,可以在终端中输入python3
查看是否支持:
可以看到我的电脑是支持的,我这里的版本是
Python 3.7.7
的版本,如果你的电脑没有支持,可以去python
官网下载。
2. LLVM
LLVM简介
LLVM
是构架编译器(compiler
)的框架系统,以C++
编写而成,用于优化以任意程序语言编写的程序的编译时间
(compile-time
)、链接时间
(link-time
)、运行时间
(run-time
)以及空闲时间
(idle-time
),对开发者保持开放,并兼容已有脚本。
LLVM
计划启动于2000
年,最初由美国UIUC
大学的 ChrisLattner
博士主持开展。2006
年ChrisLattner
加盟AppleInc
并致力于LLVM
在Apple
开发体系中的应用。 Apple
也是LLVM
计划的主要资助者。目前LLVM
已经被苹果IOS
开发工具、Xilinx Vivado
、Facebook
、Google
等各大公司采用。
传统编译器设计
我们先来看看传统编译器设计是怎么样的,如下图所示:
传统编译器设计
- 编译器前端(Frontend)
编译器前端的任务是解析源代码
。它会进行:词法分析
,语法分析
,语义分析
,检查源代码是否存在错误,然后构建抽象语法树
(Abstract Syntax Tree, AST),LLVM
的前端还会生成中间代码
(intermediate representation,IR)。
- 优化器(Optimizer)
优化器负责进行各种优化,改善代码的运行时间,例如消除冗余计算等。
- 后端(Backend)/代码生成器(CodeGenerator)
将代码映射到目标指令集,生成机器语言,并且进行机器相关的代码优化。
iOS的编译器架构
ObjectiveC/C/C++
使用的编译器前端是Clang
,Swift
是Swift
,后端都是LLVM
。
LLVM的设计
当编译器决定支持多种源语言或多种硬件架构时,LLVM
最重要的地方就来了。
其他的编译器如GCC
是非常成功的一款编译器,但由于它是作为整体应用程序设计的,因此它的用途受到了很大的限制。
LLVM
设计的最重要方面是,使用通用的代码表示形式(IR)
,它是用来在编译器中表示代码
的形式。所以LLVM
可以为任何编程语言独立编写前端,并且可以为任意硬件架构独立
编写后端。
- Clang
对于我们的开发人员来说,看得见摸得着的,接触最多的就是我们的Clang
。
Clang
是LLVM
项目中的一个子项目。它是基于LLVM
架构的轻量级编译器
,诞生之初是为了替代GCC
,提供更快的编译速度。它是负责编译C
、C++
、Objecte- C
语言的编译器,它属于整个LLVM
架构中的,编译器前端。对于开发者来说,研究Clang
可以给我们带来很多好处。
3. 编译流程
那么我们写一段代码,来测试一下,看看编译流程是什么样子的。
int main(int argc, const char * argv[]) {
@autoreleasepool {
}
return 0;
}
编译的各个阶段
通过一下命令,可以打印源码的编译阶段。
编译阶段clang -ccc-print-phases main.m
- 0:
输入文件
:找到源文件。 - 1:
预处理阶段
:这个过程处理包括宏的替换,头文件的导入。 - 2:
编译阶段
:进行词法分析、语法分析、检测语法是否正确,最终生成IR
。 - 3:
后端
:这里LLVM
会通过一个一个的Pass
(可以理解为一个节点)去优化,每个Pass
做一些事情,最终生成汇编代码
。 - 4:汇编代码
生成目标文件
。 - 5:
链接
:链接需要的动态库和静态库,生成相应的镜像
可执行文件。 - 6:根据不同的系统架构,生成对应的可执行文件。
上面已经知道了编译的流程了,那么我们一步一步去看看各个阶段是什么样子的。
#import <stdio.h>
#define B 50
int main(int argc, const char * argv[]) {
int a = 10;
int c = 20;
printf("%d",a + c + B);
return 0;
}
预处理阶段
执行如下命令
预处理阶段clang -E main.m >> main1.m
执行完毕后,我们可以在
main1.m
的文件中,可以看到头文件的导入和宏的替换
。
词法分析
编译阶段-词法分析
预处理
完成后就会进行词法分析
,这里会把代码切成一个个Token
,比如大小括号,等于号还有字符串等。
#import <stdio.h>
#define B 50
typedef int JP_INT;
int main(int argc, const char * argv[]) {
JP_INT a = 10;
JP_INT c = 20;
printf("%d",a + c + B);
return 0;
}
编译阶段clang -fmodules-fsyntax-only -Xclang -dump-tokens main.m
命令运行之后,进行了词法分析,每一行的代码都分开了,切成一个个
Token
。
语法分析
词法分析完成之后就是语法分析
,它的任务是验证语法是否正确。在词法分析
的基础上将单词序列组合成
各类语法短语
,如“程序
”,“语句
”,“表达式
”等等,然后将所有节点组成抽象语法树
(AbstractSyntaxTree,AST)。语法分析
其目的就是对源程序进行分析判断,在结构上是否正确。
抽象语法树--语法分析clang -fmodules -fsyntax-only -Xclang -ast-dump main.m
-
FunctionDecl
函数方法声明,范围是第10
行第1
个字符开始 到第15
行第1
个字符结束。第10
行第5
个字符开始,名称叫main
,返回值是int
类型,第一个参数的类型是int
,第二个参数的类型是const char **
。这里为什么是const char **
呢?因为数组的名称就是一个指针,const char ** argv
等于const char * argv[]
。
代码 -
ParmVarDecl
参数,当前行的第10
个字符到第14
个字符是int
类型所占有,第14
个字符是参数argc
。 -
CompoundStmt
复合语句,当前行第41
个字符到,第15
行代码的第1
个字符,也就是{}
包裹的范围。 - 这两句代码
JP_INT a = 10; JP_INT c = 20;
对应的是下面这个 `JP_INT a = 10; JP_INT c = 20;对应语法分析 -
CallExpr
调用表达式, 代码中的printf
函数的打印语法分析如下图 printf 函数
包括printf
函数的指针,告诉我们函数的类型和返回值的类型;第一个参数"%d",第二个参数是一个+
加运算的结果,是由a
和c
相加之和,再与50
进行相加得到。 -
ReturnStmt
返回 -
VarDecl
变量声明 -
StringLiteral
字符串字面量 -
IntegerLiteral
整型字面量 -
BinaryOperator
二元运算符
补充:如果导入的头文件找不到,可以指定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
中间代码IR
完成以上步骤后就开始生成中间代码IR
(intermediate representation)了,代码生成器(Code Generation
)会将语法树
自顶向下遍历逐步翻译成LLVM IR
。
#import <stdio.h>
//#define B 50
//typedef int JP_INT;
int JPTest(int a,int b) {
return a + b + 1;
}
int main(int argc, const char * argv[]) {
int c = JPTest(1, 2);
printf("%d",c);
return 0;
}
通过下面命令可以生成.ll
的文本文件,查看IR
代码,如下。
IR 文件clang -S -fobjc-arc -emit-llvm main.m
从图中可以看到,生成了一个
.ll
的文件,使用 VS Code
打开如下:.ll 代码
JPTest
方法的生成的IR
代码解读如下:JPTest的 IR代码解读
ObjectiveC
代码在这一步会进行runtime
的桥接:property
合成,ARC
处理等。
IR
的基本语法
@: 全局标识
% : 局部标识
alloca: 开辟空间
align: 内存对齐
i32: 32个bit,4个字节
store: 写入内存
load: 读取数据
call: 调用函数
ret: 返回
以上生成的代码是没有经过优化的,我们可以手动的开启编译器的优化,在 XCode
里面可以进行设置的。
IR的优化
LLVM
的优化级别分别是-O0-O1-O2-O3-Os(第一个是大写英文字母O
)
使用终端的命令,也是可以优化的,那么现在去优化一下,刚刚的代码。
IR 优化前后对比clang -Os -S -fobjc-arc -emit-llvm main.m -o main1.ll
从上面的对比图,可以看出优化之后,
JPTest
和 mian
代码都少了很多,在mian
函数里面并没有看到调用JPTest
函数,而是printf
直接打印了c
的结果 4
,这就是优化的强大之处,如下:IR 优化
优化之后,直接就算出来结果了,这优化还是很给力的哈!优化等级也不是越高就越好。在
XCode
里面的优化选项里面release
环境下默认的优化就是最好的了,苹果肯定是给你最好的优化啊。
release下的 IR优化等级
- 小结:
编译流程:首先是预处理
,对输入代码的宏进行展开;然后是词法分析
,会分成一个一个的 token
;再是语法分析
,会生成 AST语法树
;再就会生成IR
代码,交给优化器去处理优化代码。
- bitCode
这是xcode7
以后开启bitcode
苹果会做进一步的优化,生成bc
的中间代码。我们通过优化后的IR
代码生成bc
代码,这也是一个中间代码,目的是会根据 CPU
的不同架构生成不同大小的包(App Store 商店下载)。
clang -emit-llvm -c main.ll -o main.bc
生成汇编代码
- 生成汇编代码
我们通过最终的.bc
或者.ll
代码生成汇编代码
生成汇编clang -S -fobjc-arc main.bc -o main.s
clang -S -fobjc-arc main.ll -o main.s
- 生成的汇编比较
图中是三种不同后缀生成的汇编代码
-
IR
直接生成的汇编是55
行,计算优化了 -
IR
生成的bc
在生成汇编,在IR
的基础上没有进一步的优化了,还是55
行 - 原始的
main
代码直接生成的汇编就是62
行了
生成汇编代码的时候也是可以再次进行优化的,那么我们用上面生成的.bc
试一下,开启优化最大,看看生成的汇编是有多少行呢?
生成汇编代码也可以进行优化clang -Os -S -fobjc-arc main.bc -o main3.s
我们把优化等级调到最高,生成的汇编代码就
47
行了,比上面的55
行少了8
行,也就是说生成的IR
或者bc
的时候,优化并没有停止,每一个节点上面都有可能再次优化。
生成目标文件(汇编器)
目标文件的生成,是汇编器
以汇编代码作为输入
,将汇编代码转换为机器代码
,最后输出目标文件
(object-file),这个阶段就是属于编译器后端的工作了。
main.oclang -fmodules -c main.s -o main.o
查看下main.o中符号通过
nm
命令,查看下main.o
中的符号xcrun nm -nm main.o
-
_printf
是一个是undefined external
的。 -
undefined
表示在当前文件暂时找不到符号_printf
-
external
表示这个符号是外部可以访问的。
生成可执行文件(链接)
连接器把编译产生的.o
文件和(dylib .a)文件,生成一个mach-o
文件(可执行文件)。
可执行文件clang main.o -o main
- 查看链接之后的符号
- 现在打印的信息就多了,
_JPTest
和_main
也还在,偏移地址也有了,也就是说在执行文件中的位置就确定了。 - 现在的外部函数除了
_printf
还有dyld_stub_binder
,这是为什么呢? -
dyld_stub_binder
是在dyld
里面,当我们的执行文件mach-o
进入的内存之后,外部的符号就会立刻马上和dyld_stub_binder
进行绑定,这个过程是dyld
强制绑定的。 - 链接和绑定是两个概念:链接是我要知道你外部的符号在哪个动态库里面,就是做个标记,我要知道去哪个动态库里面找到你。
- 绑定是在执行的时候,把动态库
libSystem
里面的和你这个外部调用的_printf
进行绑定,绑定是在执行期,链接是在编译期。
以上就是 LLVM
大致的工作流程,接下来将介绍如何写一个自己的Clang
插件。
4. 写在后面
关注我,更多内容持续输出
🌹 喜欢就点个赞吧👍🌹
🌹 觉得有收获的,可以来一波 收藏+关注,以免你下次找不到我😁🌹
🌹欢迎大家留言交流,批评指正,
转发
请注明出处,谢谢合作!🌹