LLVM是一种非常强大的编译器基础架构框架,专门为使用您喜爱的编程语言编写的程序的编译时、链接时和运行时优化而设计。LLVM可运行于若干个不同的平台之上,它以能够生成快速运行的代码而著称。
LLVM 框架是围绕着代码编写良好的中间表示 (IR)而构建的。本文将深入讲解 LLVM IR的基础知识以及它的一些微妙之处。在这里,您将构建一个可以自动为您生成LLVM IR 的代码生成器。拥有一个LLVM IR生成器意味着您所需要的是一个前端以供插入您所喜爱的编程语言,而且这还意味着您拥有一个完整的流程(前端解析器 + IR 生成器 + LLVM 后端)。创建一个自定义编译器会变得更加简单。
开始使用LLVM
在开始之前,在您的开发计算器上必须已经拥有已编译好的 LLVM,对于 LLVM 代码的后期生成和安装,最重要的两个工具是llc
和lli
。
llc 和 lli
因为 LLVM是一个虚拟机,所以它可能应该拥有自己的中间字节代码表示,不是吗?最后,您需要将LLVM字节代码编译到特定于平台的汇编语言中。然后您才能通过本机汇编程序和链接器来运行汇编代码,从而生成可执行的共享库等。您可以使用llc将 LLVM 字节代码转换成特定于平台的汇编代码。对于LLVM字节代码的直接执行部分,不要等到在本机执行代码崩溃后才发现您的程序中有一个或两个bug。这正是 lli
的用武之地,因为它可以直接执行字节代码。lli可以通过解释器或使用高级选项中的即时 (JIT) 编译器执行此工作。
llvm-gcc
llvm-gcc 是 GNU Compiler Collection (gcc) 的修改版本,可以在使用-S -emit-llvm
选项运行时会生成 LLVM 字节代码。然后您可以使用 lli
来执行这个已生成的字节代码(也称为 LLVM 汇编语言)。如果您没有在自己的系统中预先安装llvm-gcc,那么您应该能够从源代码构建它
使用 LLVM 编写 Hello World
要更好地理解 LLVM,您必须了解 LLVM IR及其微妙之处。这个过程类似于学习另一种编程语言。但是,如果您熟悉C语言和C++语言以及它们的一些语法怪现象,那么在了解 LLVM IR方面您应该没有太大的障碍。清单1给出了您的第一个程序,该程序将在控制台输出中打印 “Hello World”。要编译此代码,您可以使用 llvm-gcc。
清单1 看起来非常熟悉的 Hello World 程序
#include <stdio.h> |
要编译此代码,请输入此命令:
llvm-gcc helloworld.cpp -S -emit-llvm |
完成编译后,llvm-gcc 会生成 helloworld.s 文件,您可以使用 lli来执行该文件,将消息输出到控制台。lli 的用法如下:
$ lli helloworld.s |
现在,先看一下 LLVM 汇编语言。清单2 给出了该代码。
清单2. Hello World 程序的 LLVM 字节代码
@.str = private constant [13 x i8] c"Hello World!\00", align 1 ; |
理解 LLVM IR
LLVM 提供了一个详细的汇编语言表示。在开始编写我们之前讨论的自己的 Hello World程序版本之前,有几个需知事项:
- LLVM 汇编语言中的注解以分号 (
;
) 开始,并持续到行末。 - 全局标识符要以
@
字符开始。所有的函数名和全局变量都必须以@
开始。 - LLVM 中的局部标识符以百分号 (
%
) 开始。标识符典型的正则表达式是[%@][a-zA-Z$._][a-zA-Z$._0-9]*
。 - LLVM 拥有一个强大的类型系统,这也是它的一大特性。LLVM将整数类型定义为
iN
,其中 N 是整数占用的字节数。您可以指定 1 到 223- 1 之间的任意位宽度。 - 您可以将矢量或阵列类型声明为
[no. of elements X size of each element]
。对于字符串”Hello World!”,可以使用类型[13 x i8]
,假设每个字符占用 1个字节,再加上为 NULL 字符提供的 1 个额外字节。 - 您可以对 hello-world字符串的全局字符串常量进行如下声明:
@hello = constant [13 x i8] c"Hello World!\00"
。使用关键字constant
来声明后面紧跟类型和值的常量。我们已经讨论过类型,所以现在让我们来看一下值:您以c
开始,后面紧跟放在双引号中的整个字符串(其中包括\0
并以0
结尾)。不幸的是,关于字符串的声明为什么需要使用c
前缀,并在结尾处包含 NULL 字符和 0,LLVM文档未提供任何解释。 - LLVM 允许您声明和定义函数。而不是仔细查看 LLVM函数的整个特性列表,我只需将精力集中在基本要点上即可。以关键字
define
开始,后面紧跟返回类型,然后是函数名。返回 32 字节整数的main
的简单定义类似于:define i32 @main() { ; some LLVM assembly code that returns i32 }
。 - 函数声明,顾名思义,有着重大的意义。这里提供了
puts
方法的最简单声明,它是printf
:declare i32 puts(i8*)
的 LLVM等同物。该声明以关键字declare
开始,后面紧跟着返回类型、函数名,以及该函数的可选参数列表。该声明必须是全局范围的。 - 每个函数均以返回语句结尾。有两种形式的返回语句:
ret <type> <value>
或ret void
。对于您简单的主例程,使用ret i32 0
就足够了。 - 使用
call <function return type> <function name> <optional function arguments>
来调用函数。注意,每个函数参数都必须放在其类型的前面。返回一个6位的整数并接受一个 36位的整数的函数测试的语法如下:call i6 @test( i36 %arg1 )
。
这只是一个开始。您还需要定义一个主例程、一个存储字符串的常量,以及处理实际打印的puts
方法的声明。清单显示第一次尝试创建的程序。
清单3. 第一次尝试创建手动编写的 Hello World 程序
declare i32 @puts(i8*) |
这里提供了来自 lli
的日志:
lli: test.s:5:29: error: global variable reference must have pointer type |
程序并未按预期的运行。发生了什么?如之前所提及的,LLVM拥有一个强大的类型系统。因为 puts
期望提供一个指向 i8
的指针,并且您能传递一个 i8
矢量,这样 lli
才能快速指出错误。该问题的常用解决方法(来自 C
编程背景)是使用类型转换。这将您引向了 LLVM 指令getelementptr
。请注意,您必须将 清单3中的 puts
调用修改为与 call i32 @puts(i8* %t)
类似,其中 %t
是类型i8*
,并且是 [13 x i8] to i8*
的类型转换结果。在进一步探讨之前,清单4提供了可行的代码。
清单4. 使用 getelementptr
正确地将类型转换为指针
declare i32 @puts (i8*) |
getelementptr
的第一个参数是全局字符串变量的指针。要单步执行全局变量的指针,则需要使用第一个索引,即i64 0
。因为 getelementptr
指令的第一个参数必须始终是 pointer
类型的值,所以第一个索引会单步调试该指针。0 值表示从该指针起偏移 0元素偏移量。我的开发计算机运行的是 64 位 Linux®,所以该指针是 8字节。第二个索引 (i64 0
) 用于选择字符串的第 0 个元素,该元素是作为puts
的参数来提供的。
创建一个自定义的 LLVM IR 代码生成器
了解 LLVM IR 是件好事,但是您需要一个自动化的代码生成系统,用它来转储LLVM 汇编语言。谢天谢地,LLVM 提供了强大的应用程序编程接口 (API)支持,让您可以查看整个过程。在您的开发计算机上查找LLVMContext.h 文件;如果该文件缺失,那么可能是您安装 LLVM 的方式出错。
现在,让我们创建一个程序,为之前讨论的 Hello World 程序生成 LLVM IR。该程序不会处理这里的整个 LLVM API,但是接下来的代码样例会证明,适量位数的 LLVM API很直观而且易于使用。
针对 LLVM 代码的链接
LLVM 提供了一款出色的工具,叫做 llvm-config
。运行llvm-config –cxxflags
,获取需要传递至 g++ 的编译标志、链接器选项的llvm-config –ldflags
以及 llvm-config –ldflags
,以便针对正确的 LLVM库进行链接。在 清单 5的样例中,所有的选项均需要传递至 g++。
清单 5. 通过 LLVM API 使用 llvm-config 构建代码
$ llvm-config --cxxflags --ldflags --libs \ |
LLVM 模块和上下文环境等
LLVM 模块类是其他所有 LLVM IR 对象的顶级容器。LLVM模块类能够包含全局变量、函数、该模块所依赖的其他模块和符号表等对象的列表。这里将提供了LLVM 模块的构造函数:
explicit Module(StringRef ModuleID, LLVMContext& C); |
要构建您的程序,必须从创建 LLVM模块开始。第一个参数是该模块的名称,可以是任何虚拟的字符串。第二个参数称为LLVMContext
。LLVMContext
类有些晦涩,但用户足以了解它提供了一个用来创建变量等对象的上下文环境。该类在多线程的上下文环境中变得非常重要,您可能想为每个线程创建一个本地上下文环境,并且想让每个线程完全独立于其他上下文环境运行。目前,使用这个默认的全局上下文来处理LLVM 所提供的代码。这里给出了创建模块的代码:
llvm::LLVMContext& context = llvm::getGlobalContext(); |
您要了解的下一个重要类是能实际提供 API 来创建 LLVM指令并将这些指令插入基础块的类:IRBuilder
类。IRBuilder
提供了许多华而不实的方法,但是我选择了最简单的可行方法来构建一个 LLVM指令,即使用以下代码来传递全局上下文:
llvm::LLVMContext& context = llvm::getGlobalContext(); |
准备好 LLVM 对象模型后,就可以调用模块的 dump
方法来转储其内容。清单6 给出了该代码。
清单 6. 创建一个转储模块
#include "llvm/LLVMContext.h" |
运行 清单6 中的代码之后,控制台的输出如下:
; ModuleID = 'top' |
然后,您需要创建 main
方法。LLVM 提供了 llvm::Function
类来创建一个函数,并提供了 llvm::FunctionType
将该函数与某个返回类型相关联。此外,请记住,main
方法必须是该模块的一部分。清单 7给出了该代码。
清单 7. 将 main 方法添加至顶部模块
#include "llvm/LLVMContext.h" |
请注意,您需要让 main
返回 void
,这就是您调用 builder.getVoidTy()
的原因;如果 main
返回 i32
,那么该调用会是builder.getInt32Ty()
。在编译并运行清单7中的代码后,出现的结果如下:
; ModuleID = 'top' |
您还尚未定义 main
要执行的指令集。为此,您必须定义一个基础块并将其与main
方法关联。基础块 是 LLVM IR中的一个指令集合,拥有将标签(类似于 C
标签)定义为其构造函数的一部分的选项。builder.setInsertPoint
会告知LLVM 引擎接下来将指令插入何处。清单 8 给出了该代码。
清单 8. 向 main 添加一个基础块
#include "llvm/LLVMContext.h" |
这里提供了清单 8] 的输出。请注意,由于现在已经定义了main
的基础块,所以 LLVM 转储将 main
看作为是一个方法定义,而不是一个声明。非常酷!
; ModuleID = 'top' |
现在,向代码添加全局 hello-world 字符串。清单给出了该代码。
清单 9. 向 LLVM 模块添加全局字符串
#include "llvm/LLVMContext.h" |
在 清单 9 的输出中,注意 LLVM 引擎是如何转储字符串的:
; ModuleID = 'top' |
现在您需要做的就是声明 puts
方法,并且调用它。要声明 puts
方法,则必须创建合适的 FunctionType*
。从您的 Hello World源始代码中,您知道 puts
返回了 i32
并接受 i8*
作为输入参数。清单10给出了创建 puts
的正确类型的代码。
清单 10. 声明 puts 方法的代码
std::vector<llvm::Type *> putsArgs; |
FunctionType::get
的第一个参数是返回类型;第二个参数是一个LLVM::ArrayRef
结构,并且最后的 false
指明了后面未跟可变数量的参数。ArrayRef
结构与矢量相似,只是它不包含任何基础数据,并且主要用于包装诸如阵列和矢量等数据块。由于这个改变,输出显示将如清单 11 所示。
清单 11. 声明 puts 方法
; ModuleID = 'top' |
剩下要做的是调用 main
中的 puts
方法,并从 main
中返回。LLVM API非常关注转换等操作:您需要做的是调用 puts
来调用builder.CreateCall
。最后,要创建返回语句,请调用builder.CreateRetVoid
。清单 12提供了完整的运行代码。
清单12. 输出 Hello World 的完整代码
#include "llvm/ADT/ArrayRef.h" |
结束语
在这篇初步了解 LLVM 的文章中,了解了诸如 lli
和 llvm-config
等 LLVM工具,还深入研究了 LLVM 中间代码,并使用 LLVM API来为您自己生成中间代码。本系列的第二部分(也是最后一部分)将探讨可以使用LLVM 完成的另一项任务,即毫不费力地添加额外的编译传递。