LLVM 编译器框架与流程分析

Posted 吴建明

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了LLVM 编译器框架与流程分析相关的知识,希望对你有一定的参考价值。

LLVM 编译器框架与流程分析

LLVM 编译器框架

LLVM是Low Level Virtual Machine(低级虚拟机)的简称,是一款编译器框架。但是它本质上并不是虚拟机,核心其实准确点应该是编译器相关支持。主要是支持代码优化、链接、代码生成、机器码生成。当然有的时候内部llc给人感觉确实是类似解释型语言。现在LLVM已经集成非常多的优点,模块化设计让程序员可以绕开繁琐的操作,可以快速实现一个编译器并且运行。

一. 编译器架构

1.0 gcc

    先看看编译器结构:

 

 

 图:传统编译器结构

Frontend:前端,将高级语言的代码转换成编译器所对应的中间代码。其中操作有词法分析、语法分析、语义分析、中间代码生成

Optimizer:中端,对中间代码进行优化。(代码优化其实就是编译器对代码进行简介,整洁等等的操作。

Backend:后端,对中间代码进行转换成机器码Opcode进行运行。本质上编译器到这里已经结束了。

Linker:链接:后续还要对机器码进行链接操作,本质上就是将机器码的文件进行打包与合并成可执行文件也就是经常所说的EXE与ELF。当然这是链接器的问题了。(其实可以与后端进行合并)

 

 

 图:编译器架构

但是这种架构的编译器比如GCC有很多缺点:

  • 后端太统一
  • 不能支持新的语言
  • 因为后端,在新设备(处理器架构)需要重新开发后端包括中端

因为GCC前后端都是耦合在一起,想支持新的语言和处理器架构开发起来非常非常困难。

1.1 LLVM

    为止LLVM以上问题得到很好解决,而且LLVM编译器使用CLANG作为前端速度是GCC速度的三倍。

LLVM与GCC对比,它统一了LLVM IR(中端),如果需要开发新的处理器架构后端,那么只需要开发后端即可。

LLVM,除了后端模块化,连前端也实现了模块化。开发者可以自定义前端规则等等,开发一门自己的编程语言。

该编译器框架被Apple、FaceBook、Google广泛应用,甚至Apple为该编译器框架开发了Clang。

 

 

 图:LLVM架构

前端:LLVM前后端实现了模块化,并不进行耦合。这样子,如果需要开发一门编程语言或者把Python从解释型转换成编译型。那么只需要对应Python的前端进行开发,开发出将Python转换成LLVM IR的前端即可。这样子可以让Python的性能超越目前的编程语言。当然由于Python的设计,这种想法还是算了。

后端:这个目前市面上的芯片架构,在LLVM上均可实现运行,如果进行二开也可以通过原有的基础进行二次开发,而且过程并不繁琐,或者如果发明了新的CPU架构,

中端:中端主要是对IR进行代码优化,实现性能上的效率提高。当然可以将IR独立出来,作为解释型来使用。这样子可以实现高性能解释型语言了(滑稽  速度怎样不清楚,但是个人感觉肯定起飞)

LLVM IR/Bitcode:LLVM IR本质上是LLVM可视化的代码形式,而Bitcode是以二进制数据形式的IR代码。IR是LLVM中端的中间代码,是前端到后端的过渡代码,其功能是为了更好实现一个模块化,并且前端转换为统一格式规定的IR,然后IR转换为

1.2 Clang

    Clang本质上是LLVM衍生出来的前端项目,由Apple开发。它是直接支持了C/C++/ObC++作为LLVM前端将其转化为LLVM IR,再从IR转换为后端机器码。这样子可以一个前端实现多个后端支持。(饶了),这个编译器框架我就不多描述。 

二. LLVM的编译

    LLVM的编译,其实非常简单。当然LLVM因为非常庞大,我不进行本地编译了,其实就是CMAKE Ninja 一键编译。LLVM可以直接下载,已经编译好的二进制文件更为推荐,要是真的对LLVM进行开刀,那么需要编译,储存需要10G-40G (我编译花了30g)。 编译时间也非常漫长。

    当然LLVM-Project默认使用Clang,如果不包含CLang 那么LLVM只有中端与后端。前端需要自己重新编译,一般使用Clang即可。

三. 编译一个Helloworld

首先写一组HelloWorld的C代码

 

 

 图:Helloworld代码

然后使用Clang前端进行编译,编译成IR代码,不加-S会编译成bc代码。

 

 

 图:

编译IR代码

使用llvm-as 将IR代码进行转化为bc代码。并且使用llvm-bcanalyzer进行bc代码分析

 

 

 图:bc代码生成

最后使用lli进行后端运行,这是属于“解释型”的。可以使用llc转换为汇编文件或者对象文件。

 

 

 图:后端运行

并且使用gcc将对象文件编译成ELF。(因为LLVM链接器并不完善,所以使用gcc可以方便快捷。)

LLVM(一)——编译流程

一、编译型语言 VS 解释型语言

我们程序员编写的源代码是人类语言,我们可以很轻松得理解;但是对于计算机硬件(CPU)而言,这些源代码就好比是天书,它根本无法理解,更无法直接执行。计算机只能够识别某些特定的二进制指令,所以在程序真正运行之前,必须要把源代码转换成计算机可以识别的二进制指令。

所谓的二进制指令,也就是机器码,是CPU能够识别的硬件层面的代码,简陋的硬件(比如古老的单片机)只能使用几十个指令,强大的硬件可以使用成百上千个指令。

然而,究竟在什么时候将源代码转换成二进制指令呢?不同的编程语言有不同的规定:

  • 有的编程语言要求必须提前将所有源代码一次性转换成二进制指令,也就是生成可执行程序,比如C、C++、OC、Swift等,这种语言是编译型语言,使用的转换工具是编译器。比如OC的编译器就是Clang。
  • 有的编程语言可以一边执行一边转换,需要那些源代码就转换哪些源代码,不会生成可执行程序,比如Python、JS、shell等,这种语言称为解释型语言,使用的工具是解释器。

 那么解释型语言和编译型语言各有什么特点呢?它们之间又有什么区别呢?

1,编译型语言

对于编译型语言,需要在开发完成后,将所有的源代码都转换成可执行程序,可执行程序里面包含的就是机器码。只要我们拥有可执行程序,就可以立即执行,不需要再重新编译了,也就是说,“一次编译,多次运行”。

在运行的时候,只需要编译生成的可执行程序,不再需要源代码和编译器,所以说编译型语言可以脱离开发环境运行。

编译型语言一般是不能跨平台的,也就是说,不能在不同的操作系统间随意切换。

编译型语言不能跨平台的表现有两个层面:

  1. 可执行程序不能跨平台。这很容易理解,因为不同的操作系统(也可以说是不同的硬件,或者不同的架构)对可执行文件的内部结构有着截然不同的要求,彼此之间也不能兼容,不能跨平台是天经地义。
  2. 源代码不能跨平台。不同平台支持的函数、变量、类型等都可能不同,基于某个平台编写的源代码一般不能拿到另一个平台下编译。

2,解释型语言

对于解释型语言而言,每次执行程序都需要一边转换一边执行,用到哪些源代码就将那些源代码转换成机器码,用不到的不进行任何处理。每次执行程序时可能使用不同的功能,这个时候需要转换的源代码也不一样。

因为每次执行程序都需要重新转换源代码,所以解释型语言的执行效率天生就低于编译型语言,甚至存在数量级的差距。

在运行解释型语言的时候,我们始终都会需要源代码和解释器,所以说它无法脱离开发环境。

当我们说“下载一个程序(软件)”的时候,不同类型的语言会有不同的含义:

  • 对于编译型语言,我们下载到的是可执行文件,源代码被作者保留,所以编译型语言的程序一般都是闭源的。
  • 对于解释型语言,我们下载到的是源代码,因为作者不给源代码就没法运行,所以解释型语言一般都是开源的。

相比于编译型语言,解释型语言几乎都能跨平台,“一份代码,到处运行”是真实存在的。那么,为什么解释型语言就能跨平台呢?这一切都要归功于解释器。

我们所说的跨平台,是指源代码跨平台,并不是解释器跨平台。解释器用来将源代码转换成机器码,它就是一个可执行程序,是绝对不能跨平台的。

官方需要针对不同的平台开发不同的解释器。这些解释器必须要能够遵守同样的语法、识别同样的函数、完成同样的功能,只有这样,同样的代码在不同平台的执行结果才是相同的。

你看,解释型语言之所以能够跨平台,是因为有了解释器这个中间层。在不同的平台下,解释器会将相同的源代码转换成不同的机器码,解释器帮助我们屏蔽了不同平台之间的差异。

JS就是一门解释型语言,它在Android和iOS上的解释器就不一样,我之前写过两篇文章详细介绍过iOS上的js解释器——JSCore,大家可以了解一下:

  1. 深入理解JSCore
  2. 深入理解JSCore后续

最后,我将编译型语言和解释型语言的差异总结为下表:

类型

原理

优点

缺点

编译型语言

通过专门的编译器,将所有源代码一次性转换成特定平台执行的机器码

一次编译后,脱离编译器也可以运行,并且运行效率高

可移植性差,不够灵活

解释型语言

由专门的解释器,根据需要将源代码临时转换成特定平台的机器码

跨平台特性好,通过不同的解释器,将相同的源代码转换成不同平台下的机器码

一边执行一边转换,效率很低

二、LLVM概述

上面我们了解了什么是编译器,了解了OC语言的编译器就是Clang。那么LLVM是什么?Clang跟LLVM又有什么关系呢?

首先来聊一聊传统编译器的设计。

1,传统编译器设计

 

 

 1.1 编译器前端(Frontend)

上图中的SourceCode就是源代码,编译器前端的任务是解析源代码。它会进行:词法分析、语法分析、语义分析、检查源代码是否存在错误,然后构建抽象语法树(Abstract Syntax Tree,AST)。

1.2 优化器(Optimizer)

优化器会负责各种优化,改善代码的运行时间,例如消除冗余计算等。对应下面第三章节的2.4、2.5。

1.3 后端(BackGround)/代码生成器(CodeGenerator)

这一步会将代码映射到目标指令集,生成机器语言,并且会执行机器相关的代码优化。对应下面第三章节的3。

2,LLVM的设计

上面讲了传统的编译器设计,接下来我们就来聊聊LLVM。

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

LLVM计划启动于2000年,最初由美国UIUC大学的Chris Lattner博士主持开展,2006年Chris Lattner加盟苹果公司,并致力于LLVM在Apple开发体系中的应用。Apple也是LLVM计划的主要资助者。

目前LLVM已经被Apple、FaceBook、Google等各大公司采用。

Clang是LLVM项目中的一个子项目,属于LLVM的编译器前端,不过它仅仅是LLVM的众多编译器前端中的一个,它负责编译C/C++/OC语言。针对不同的语言和架构,LLVM的前端是不一样的。比如在iOS架构下,可以使用Objective-C和Swift,Objective-C/C/C++使用的LLVM前端是Clang,Swift使用的LLVM前端是Swift。如下:

 

 

 LLVM相对于传统的编译器,最重要的一个优化就是,它会使用通用的代码表示形式IR。也就是说,LLVM的前端最终都会生成IR,然后将IR传入优化器,优化器优化之后传给后端的也是IR。当编译器决定支持多种源语言或者多种硬件架构的时候,LLVM的这个特性的优势将会体现得淋漓尽致。比如说我现在需要支持一门新的语言,那么就只需要添加一个编译器前端即可;再比如新出了一个硬件架构,那么只需要开发对应的一个编译器后端即可。所以LLVM可以为任意的编程语言独立编写前端,并且可以为任何硬件架构独立编写后端:

 

 

 像其他的编译器,比如GCC,毋庸置疑,它是非常成功的,但是由于它是作为整体应用程序设计的,也就是说,会将编译器前端、优化器和后端统一设计成一个应用程序,结果就是只能用于某一个语言和某一个架构,因此它的用途受到了很大程度的限制。

三、编译流程

接下来我们就走一遍Clang的整个编译流程。

首先,使用Xcode新建一个最简单的MacOS命令行工具工程:

 

 

 创建出来的工程如下:

 

 

 接下来我打开终端,并cd到main.m所在的目录下。

首先通过如下命令来打印源码编译流程中的各个阶段:

clang -ccc-print-phases main.m

 

 

 可以看到,一共有7个阶段,它们分别表示的含义如下:

  • 0:input,输入源代码文件
  • 1:preprocessor,预处理阶段,头文件的导入、宏的替换都是在这个阶段进行处理
  • 2:compiler,编译阶段,词法分析、语法分析、语义分析、检查源代码是否存在错误,最后生成IR代码,并交给下面的后端
  • 3:backend,后端,这里LLVM 会通过一个一个的pass去优化,每个pass做一些事情,最终生成汇编代码
  • 4:assembler,生成object目标文件,也就是我们熟知的.o文件。
  • 5:linker,链接,将各个.o文件以及需要的动态库和静态库链接起来,最终生成可执行文件Mach-o
  • 6:bind-arch,针对不同的架构,会生成对应的Mach-o可执行文件。

1,预处理阶段

首先main.m中输入一些内容:

 

 

 使用如下指令,来对main.m进行预编译,并将预编译的结果重定向到main_pre.m文件中:

clang -E main.m >> main_pre.m

然后相同路径下就会生成一个main_pre.m文件:

 

 

 我们点开main_pre.m文件查看:

 

 

 可以看到将近600行代码,而源代码当中也就20行而已。为什么一下子多出来这么多东西?原因就是在预处理阶段将头文件中的相关内容都导入了进来,并且将宏进行了替换。

接下来我再加一行typedef代码:

 

 

 然后预编译,并将预编译的结果重定向到main_pre.m文件中,结果如下:

 

 

 可以看到,NormanInt并没有被替换为int,这说明typedef命令并没有在预处理阶段进行处理,也就是说,typedef并不属于预处理指令,它只是给一个类型取别名,类似于Swift中的typealias。

实际上,所有前面加了#的命令都是属于编译阶段预处理的指令,只有这些指令才会在预处理阶段处理。

2,编译阶段

2.1 词法分析

预处理完成之后就会进行词法分析,这里会把代码切成一个个的Token。

我们执行如下指令,就会对源代码进行词法分析:

clang -fmodules -fsyntax-only -Xclang -dump-tokens main.m

结果如下:

 

 

 可以看到,词法分析就是将代码都拆解成一个一个的Token。比如int a = 1;这行代码,就是被拆解成了int、a、=、1、;这五个Token。

需要注意的是,注释掉的代码不会被编译的哦~

2.2 语法分析

词法分析结束之后就是语法分析,它的任务就是验证语法是否正确。

我们在词法分析中只是将源代码拆解成一个一个的Token,此时并不会验证Token间的组合是否正确,而语法分析的目的就是验证各个Token间的组合关系是否有问题。我们写的代码的语法是否正确,就是在这个阶段检测出来的。

语法分析会在词法分析的基础上,将单词序列组合成各类语法短语,如“程序”、“语句”、“表达式”等等,然后将所有节点组成抽象语法树(Abstract Syntax Tree,AST)。语法分析程序会判断源程序在结构上是否正确。

终端执行如下命令:

clang -fmodules -fsyntax-only -Xclang -ast-dump main.m

之后会生成语法树的结构:

 

 

 可以看到,这个结构像一棵树-枝-干-叶,所以称之为语法树。我在上图中也做了简单地分析标注,大家可以对比下面的源代码,看看位置是否符合:

 

 

 实际上,语法树是给机器看的,我们程序员不会闲着没事看这个,我上面也只是做了个简单的解析而已,方便诸位理解语法树到底是个什么东西。

我们知道,当代码的语法有问题的时候,Xcode会报错,比如下面:

 

 

 此时我执行词法分析的命令,不会有任何问题,因为词法分析只是将源代码拆解成一个一个的Token,它并不会验证Token间的组合是否正确。

但是但我执行语法的命令的时候,就报错了,如下:

 

 

 通过红框内的信息我们知道,第19行第37个字符上了一个分号,这与Xcode中的报错是一致的。

所以说,我们在写代码的时候,如果语法有错误,那么Xcode会报出警告,这个错误的检查就是在语法分析阶段完成的。

另外还有一点需要说明,如果你需要编译的目标文件中有导入UIKit框架或者Foundation框架下的内容,那么这些头文件可能会找不到,此时,你需要做的是指定SDK:

clang -isysroot /Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator12.2.sdk

以上是关于LLVM 编译器框架与流程分析的主要内容,如果未能解决你的问题,请参考以下文章

转:LLVM与Clang的概述及关系

LLVM 极简教程: 第一章 教程简介与词法分析器

IOS逆向-LLVM代码混淆

ollvm源码分析 - Pass之SplitBaiscBlocks

Xcode的控制台调试命令

编译器中的图论算法