iOS 之LLVM 了解

193 阅读10分钟

1. 解释型语言编译型语言

在介绍 LLVM之前,先来认识一下解释型语言编译型语言

我们编写的源代码是偏向于我们人类直接的语言,我们相对轻松的就可以理解了,但是对于计算机硬件(CPU)而言,它是无法直接运行的。计算机只能识别某些特定的二进制指令,所以我们的代码在程序真正运行之前必须将源代码转换成二进制指令。

解释型语言

有的编程语言可以一边执行一边转换,不会生成可执行文件再去执行,这种编程语言称为解释型语言,使用的转换工具称为解释器,比如 PythonJavaScriptPHP等。

111.png

下面就举个例子,使用vim命令新建立一个 python文件,后缀为.py,写入代码print("hello world!!!"),通过python命令,解释这段代码,打印一下 hello world ! 这句话。

Xnip2022-08-01_13-27-01.png

我看可以看到解释型语言,它是边解释边执行,不可脱离解释器环境运行。 MAC 电脑自带了 Python 环境,无需另外手动配置环境。

编译型语言

有的编程语言要转换成二进制指令,也就是生成一个可执行程序这种编程语言称为编译型语言,使用的转换工具称为编译器,比如C语言、C++OC等。

编译器

编译型语言也同样举个例子,新建立一个 C文件,写入如下代码:

#include<stdio.h>
int main (int argc,char *agrv[])
{
    printf("hello world!!!\n");
    return 0;
}

通过clang hello.c -o targetname.o命令,进行编译处理,会生成一个可执行文件,如下图中红色的helloc.out文件。

Xnip2022-08-01_13-33-03.png

这个可执行文件,可以直接运行,通过./helloc.out即可运行,如图中也可以正常输出hello world!!!这句话。

编译型语言是先整体编译,再执行,运行速度快,任意改动需重新编译,可脱离编译环境运行。

总结

  • 解释型语言:读到相应代码就直接执行。
  • 编译型语言:先将代码编译成计算机可以识别的二进制文件,才能执行。

2. LLVM

LLVM简介

LLVM是构架编译器(compiler)的框架系统,以C++编写而成,用于优化以任意程序语言编写的程序的编译时间(compile-time)、链接时间(link-time)、运行时间(run-time)以及空闲时间(idle-time),对开发者保持开放,并兼容已有脚本。

LLVM计划启动于2000年,最初由美国UIUC大学的 ChrisLattner博士主持开展。2006ChrisLattner加盟AppleInc并致力于LLVMApple开发体系中的应用。 Apple也是LLVM计划的主要资助者。目前LLVM已经被苹果iOS开发工具、Xilinx VivadoFacebookGoogle等各大公司采用。

传统编译器设计

我们先来看看传统编译器设计是怎么样的,如下图所示:

传统编译器设计

  • 编译器前端(Frontend)

    编译器前端的任务是解析源代码。它会进行:词法分析语法分析语义分析,检查源代码是否存在错误,然后构建抽象语法树(Abstract Syntax Tree, AST)。LLVM的前端还会生成中间代码(intermediate representation,IR)。

  • 优化器(Optimizer)

    优化器负责进行各种优化,改善代码的运行时间,例如消除冗余计算等。

  • 后端(Backend)/代码生成器(CodeGenerator)

    将代码映射到目标指令集,生成机器语言,并且进行机器相关的代码优化。

iOS的编译器架构

ObjectiveC/C/C++使用的编译器前端是ClangSwiftSwift,后端都是LLVMimage.png

LLVM的设计

当编译器决定支持多种源语言或多种硬件架构时,LLVM最重要的地方就来了。

其他的编译器如GCC是非常成功的一款编译器,但由于它是作为整体应用程序设计的,因此它的用途受到了很大的限制。

LLVM设计的最重要方面是,使用通用的代码表示形式(IR),它是用来在编译器中表示代码的形式。所以LLVM可以为任何编程语言独立编写前端,并且可以为任意硬件架构独立编写后端。

image.png

Clang

对于我们的开发人员来说,看得见摸得着的,接触最多的就是我们的Clang

ClangLLVM项目中的一个子项目。它是基于LLVM架构的轻量级编译器,诞生之初是为了替代GCC,提供更快的编译速度。它是负责编译CC++Objecte- C语言的编译器,它属于整个LLVM架构中的,编译器前端。对于开发者来说,研究Clang可以给我们带来很多好处。

3. 编译流程

编译的各个阶段

通过 clang -ccc-print-phases main.m 命令,可以打印源码的编译阶段

Xnip2022-08-01_14-15-35.png

  • 0:输入文件:找到源文件。
  • 1:预处理阶段:这个过程处理包括宏的替换,头文件的导入。
  • 2:编译阶段:进行词法分析、语法分析、检测语法是否正确,最终生成IR
  • 3:后端:这里LLVM会通过一个一个的Pass(可以理解为一个节点)去优化,每个Pass做一些事情,最终生成汇编代码
  • 4:汇编器:生成目标文件
  • 5:链接:链接需要的动态库和静态库,生成相应的镜像可执行文件。
  • 6:根据不同的系统架构,生成对应的可执行文件。

上面已经知道了编译的流程了,那么我们一步一步去看看各个阶段是什么样子的。 修改 main.m 的代码为:

#import <stdio.h>
#define C 30
int main(int argc, const char * argv[]) {
    int a = 10;
    int b = 20;
    printf("%d", a + b + C);
    return 0;
}

预处理阶段

执行命令: clang -E main.m >> main1.m

Xnip2022-08-01_14-20-11.png

执行完毕后,我们可以在 main1.m 的文件中,可以看到头文件的导入和宏的替换

词法分析

编译阶段-词法分析

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

使用 clang -fmodules -fsyntax-only -Xclang -dump-tokens main.m 命令来生成 词法分析后的代码:

Xnip2022-08-01_14-25-25.png

命令运行之后,进行了词法分析,每一行的代码都分开了,切成一个个Token

语法分析

词法分析完成之后就是语法分析,它的任务是验证语法是否正确。在词法分析的基础上将单词序列组合成各类语法短语,如“程序”,“语句”,“表达式”等等,然后将所有节点组成抽象语法树(AbstractSyntaxTree,AST)。语法分析其目的就是对源程序进行分析判断,在结构上是否正确。

使用 clang -fmodules -fsyntax-only -Xclang -ast-dump main.m 命令来生成 抽象语法树

Xnip2022-08-01_14-30-06.png

  • FunctionDecl函数方法声明,范围是第 3 行第 1 个字符开始 到第 8行第 1个字符结束。第 3行第 5个字符开始,名称叫 main,返回值是int类型,第一个参数的类型是int,第二个参数的类型是const char **。这里为什么是const char **呢?因为数组的名称就是一个指针,const char ** argv 等于const char * argv[]Xnip2022-08-01_14-33-14.png

  • ParmVarDecl参数,当前行的第 10 个字符到第 14个字符是int类型所占有,第 14个字符是参数argc

  • CompoundStmt复合语句,当前行第 41个字符到,第 8行代码的第1个字符,也就是{ }包裹的范围。

  • 这两句代码int a = 10; int c = 20;对应的是下面这个 Xnip2022-08-01_14-39-38.png

  • CallExpr调用表达式, 代码中的printf 函数的打印语法分析如下图 Xnip2022-08-01_14-45-06.png

    包括printf函数的指针,告诉我们函数的类型和返回值的类型;第一个参数%d,第二个参数是一个+加运算的结果,是由ab相加之和,再与30进行相加得到。

  • ReturnStmt返回

  • VarDecl 变量声明

  • StringLiteral字符串字面量

  • IntegerLiteral整型字面量

  • BinaryOperator二元运算符

中间代码IR

完成以上步骤后就开始生成中间代码IR(intermediate representation)了,代码生成器(Code Generation)会将语法树自顶向下遍历逐步翻译成LLVM IR

#import <stdio.h>
int YJTest(int a,int b) {
    return a + b + 1;
}
int main(int argc, const char * argv[]) {
    int c = YJTest(1, 2);
    printf("%d", c);
    return 0;
}

通过clang -S -fobjc-arc -emit-llvm main.m命令可以生成.ll的文本文件,查看IR代码,如下。

Xnip2022-08-01_14-53-38.png

YJTest方法的生成的IR代码解读如下: Xnip2022-08-01_15-04-11.png

ObjectiveC代码在这一步会进行runtime的桥接:property合成,ARC处理等。

IR的基本语法

  • @: 全局标识 % : 局部标识
  • alloca: 开辟空间\
  • align: 内存对齐 i32: 32个bit,4个字节\
  • store: 写入内存\
  • load: 读取数据\
  • call: 调用函数\
  • ret: 返回

以上生成的代码是没有经过优化的,我们可以手动的开启编译器的优化,在 XCode里面可以进行设置的。

IR的优化

LLVM的优化级别分别是-O0-O1-O2-O3-Os(第一个是大写英文字母O)

Xnip2022-08-01_15-07-57.png

使用终端的命令,也是可以优化的,那么现在去优化一下,刚刚的代码,使用命令:clang -Os -S -fobjc-arc -emit-llvm main.m -o main1.ll

Xnip2022-08-01_15-15-00.png

从上面的对比图,可以看出优化之后,YJTest和 mian代码都少了很多,在 mian函数里面并没有看到调用YJTest函数,而是printf直接打印了c的结果 4,这就是优化的强大之处,如下:

Xnip2022-08-01_15-19-18.png

优化之后,直接就算出来结果了,这优化还是很给力的哈!优化等级也不是越高就越好。在XCode 里面的优化选项里面release 环境下默认的优化就是最好的了,如图:

Xnip2022-08-01_15-20-33.png

总结: 编译流程:

  1. 首先是预处理,对输入代码的宏进行展开;
  2. 然后是词法分析,会分成一个一个的 token
  3. 再是语法分析,会生成 AST语法树
  4. 再就会生成 IR代码,交给优化器去处理优化代码。

生成汇编代码

我们通过最终的.ll代码生成汇编代码,使用clang -S -fobjc-arc main.ll -o main.s 命令

Xnip2022-08-01_15-34-50.png

生成的汇编代码比较、 Xnip2022-08-01_15-40-07.png

  • 未优化IRmain.ll 和 源码main.m直接生成的汇编都是 52 
  • 优化后的IRmain1.ll生成的 main_1ll.s汇编,变成了47

生成目标文件(汇编器)

目标文件的生成,是汇编器以汇编代码作为输入,将汇编代码转换为机器代码,最后输出目标文件(object-file),这个阶段就是属于编译器后端的工作了

使用clang -fmodules -c main.s -o main.o命令

Xnip2022-08-01_15-51-10.png

通过xcrun nm -nm命令,查看下main.o中的符号

Xnip2022-08-01_15-52-32.png

  • _printf是一个是undefined external的。
  • undefined表示在当前文件暂时找不到符号_printf
  • external表示这个符号是外部可以访问的。

生成可执行文件(链接器)

连接器把编译产生的.o文件和(dylib .a)文件,生成一个mach-o文件(可执行文件)。

使用 clang main.o -o main 命令

Xnip2022-08-01_15-55-06.png

  • 查看链接之后的符号 Xnip2022-08-01_15-56-10.png

    • 现在打印的信息就多了,_JPTest_main 也还在,偏移地址也有了,也就是说在执行文件中的位置就确定了。
    • 现在的外部函数除了_printf还有dyld_stub_binder,这是为什么呢?
    • dyld_stub_binder是在 dyld里面,当我们的执行文件 mach-o 进入的内存之后,外部的符号就会立刻马上和dyld_stub_binder进行绑定,这个过程是 dyld 强制绑定的。
    • 链接和绑定是两个概念:链接是我要知道你外部的符号在哪个动态库里面,就是做个标记,我要知道去哪个动态库里面找到你。
    • 绑定是在执行的时候,把动态库libSystem里面的和你这个外部调用的_printf进行绑定,绑定是在执行期,链接是在编译期。