天天看点

为什么人人都该懂点LLVM

只要你和程序打交道,了解编译器架构就会令你受益无穷——无论是分析程序效率,还是模拟新的处理器和操作系统。通过本文介绍,即使你对编译器原本一知半解,也能开始用llvm,来完成有意思的工作。

为什么人人都该懂点LLVM

<a target="_blank"></a>

llvm是一个好用、好玩,而且超前的系统语言(比如c和c++语言)编译器。

当然,因为llvm实在太强大,你会听到许多其他特性(它可以是个jit;支持了一大批非类c语言;还是app store上的一种新的发布方式等等)。这些都是真的,不过就这篇文章而言,还是上面的定义更重要。

下面是一些让llvm与众不同的原因:

llvm的“中间表示”(ir)是一项大创新。llvm的程序表示方法真的“可读”(如果你会读汇编)。虽然看上去这没什么要紧,但要知道,其他编译器的中间表示大多是种内存中的复杂数据结构,以至于很难写出来,这让其他编译器既难懂又难以实现。

然而llvm并非如此。其架构远比其他编译器要模块化得多。这种优点可能部分来自于它的最初实现者。

尽管llvm给我们这些狂热的学术黑客提供了一种研究工具的选择,它还是一款有大公司做后台的工业级编译器。这意味着你不需要去在“强大的编译器”和“可玩的编译器”之间做妥协——不像你在java世界中必须在hotspot和jikes之间权衡那样。

是,llvm是一款酷炫的编译器,但是如果不做编译器研究,还有什么理由要管它?

答:只要你和程序打交道,了解编译器架构就会令你受益,而且从我个人经验来看,非常有用。利用它,可以分析程序要多久一次来完成某项工作;改造程序,使其更适用于你的系统,或者模拟一个新的处理器架构或操作系统——只需稍加改动,而不需要自己烧个芯片,或者写个内核。对于计算机科学研究者来说,编译器远比他们想象中重要。建议你先试试llvm,而不用hack下面这些工具(除非你真有重要的理由):

架构模拟器;

动态二进制分析工具,比如pin;

源代码变换(简单的比如sed,复杂一些的比如抽象语法树的分析和序列化);

修改内核来干预系统调用;

任何和虚拟机管理程序相似的东西。

就算一个编译器不能完美地适合你的任务,相比于从源码到源码的翻译工作,它可以节省你九成精力。

下面是一些巧妙利用了llvm,而又不是在做编译器的研究项目:

uiuc的virtual ghost,展示了你可以用编译器来保护挂掉的系统内核中的进程。

uw的coredet利用llvm实现了多线程程序的确定性。

在我们的近似计算工作中,我们使用llvm流程来给程序注入错误信息,以模仿一些易出错的硬件。

重要的话说三遍:llvm不是只用来实现编译优化的!llvm不是只用来实现编译优化的!llvm不是只用来实现编译优化的!

llvm架构的主要组成部分如下(事实上也是所有现代编译器架构):

前端,流程(pass),后端

为什么人人都该懂点LLVM

下面分别来解释:

前端获取你的源代码然后将它转变为某种中间表示。这种翻译简化了编译器其他部分的工作,这样它们就不需要面对比如c++源码的所有复杂性了。作为一个豪迈人,你很可能不想再做这部分工作;可以不加改动地使用clang来完成。

“流程”将程序在中间表示之间互相变换。一般情况下,流程也用来优化代码:流程输出的(中间表示)程序和它输入的(中间表示)程序相比在功能上完全相同,只是在性能上得到改进。这部分通常是给你发挥的地方。你的研究工具可以通过观察和修改编译过程流中的ir来完成任务。

后端部分可以生成实际运行的机器码。你几乎肯定不想动这部分了。

虽然当今大多数编译器都使用了这种架构,但是llvm有一点值得注意而与众不同:整个过程中,程序都使用了同一种中间表示。在其他编译器中,可能每一个流程产出的代码都有一种独特的格式。llvm在这一点上对hackers大为有利。我们不需要担心我们的改动该插在哪个位置,只要放在前后端之间某个地方就足够了。

让我们开干吧。

首先需要安装llvm。linux的诸发行版中一般已经装好了llvm和clang的包,你直接用便是。但你还是需要确认一下机子里的版本,是不是有所有你要用到的头文件。在os x系统中,和xcode一起安装的llvm就不是那么完整。还好,用cmake从源码构建llvm也没有多难。通常你只需要构建llvm本身,因为你的系统提供的clang已经够用(只要版本是匹配的,如果不是,你也可以自己构建clang)。

具体在os x上,brandon holt有一个不错的指导文章。用homebrew也可以安装llvm。

你需要对文档有所了解。我找到了一些值得一看的链接:

使用llvm来完成高产研究通常意味着你要写一些自定义流程。这一节会指导你构建和运行一个简单的流程来变换你的程序。

<code>$ git clone [email protected]:sampsyo/llvm-pass-skeleton.git</code>

主要的工作都是在<code>skeleton/skeleton.cpp</code>中完成的。把它打开。这里是我们的业务逻辑:

<code>virtual bool runonfunction(function &amp;f) {</code>

<code>errs() &lt;&lt; "i saw a function called " &lt;&lt; f.getname() &lt;&lt; "!\n";</code>

<code>return false;</code>

<code>}</code>

细节:

errs()是一个llvm提供的c++输出流,我们可以用它来输出到控制台。

函数返回false说明它没有改动函数f。之后,如果我们真的变换了程序,我们需要返回一个true。

通过cmake来构建这个流程:

<code>$ cd llvm-pass-skeleton</code>

<code>$ mkdir build</code>

<code>$ cd build</code>

<code>$ cmake .. # generate the makefile.</code>

<code>$ make # actually build the pass.</code>

如果llvm没有全局安装,你需要告诉cmake llvm的位置.你可以把环境变量<code>llvm_dir</code>的值修改为通往<code>share/llvm/cmake/</code>的路径。比如这是一个使用homebrew安装llvm的例子:

<code>$ llvm_dir=/usr/local/opt/llvm/share/llvm/cmake cmake ..</code>

构建流程之后会产生一个库文件,你可以在<code>build/skeleton/libskeletonpass.so</code>或者类似的地方找到它,具体取决于你的平台。下一步我们载入这个库来在真实的代码中运行这个流程。

想要运行你的新流程,用<code>clang</code>编译你的c代码,同时加上一些奇怪的flag来指明你刚刚编译好的库文件:

<code>$ clang -xclang -load -xclang build/skeleton/libskeletonpass.* something.c</code>

<code>i saw a function called main!</code>

<code>-xclang -load -xclang path/to/lib.so</code>这是你在clang中载入并激活你的流程所用的所有代码。所以当你处理较大的项目的时候,你可以直接把这些参数加到makefile的cflags里或者你构建系统的对应的地方。

恭喜你,你成功hack了一个编译器!接下来,我们要扩展这个hello world水平的流程,来做一些好玩的事情。

想要使用llvm里的程序,你需要知道一点中间表示的组织方法。

为什么人人都该懂点LLVM
模块(module),函数(function),代码块(basicblock),指令(instruction) 

首先了解一下llvm程序中最重要的组件: 

粗略地说,模块表示了一个源文件,或者学术一点讲叫翻译单元。其他所有东西都被包含在模块之中。 

最值得注意的是,模块容纳了函数,顾名思义,后者就是一段段被命名的可执行代码。(在c++中,函数function和方法method都相应于llvm中的函数。) 

除了声明名字和参数之外,函数主要会做为代码块的容器。代码块和它在编译器中的概念差不多,不过目前我们把它看做是一段连续的指令。 

而说到指令,就是一条单独的代码命令。这一种抽象基本上和risc机器码是类似的:比如一个指令可能是一次整数加法,可能是一次浮点数除法,也可能是向内存写入。

大部分llvm中的内容——包括函数,代码块,指令——都是继承了一个名为值的基类的c++类。值是可以用于计算的任何类型的数据,比如数或者内存地址。全局变量和常数(或者说字面值,立即数,比如5)都是值。

这是一个写成人类可读文本的llvm中间表示的指令的例子。

<code>%5 = add i32 %4, 2</code>

这个指令将两个32位整数相加(可以通过类型<code>i32</code>推断出来)。它将4号寄存器(写作<code>%4</code>)中的数和字面值2(写作<code>2</code>)求和,然后放到5号寄存器中。这就是为什么我说llvm ir读起来像是risc机器码:我们甚至连术语都是一样的,比如寄存器,不过我们在llvm里有无限多个寄存器。

另外,如果你想看你自己程序的llvm ir,你可以直接使用clang:

<code>$ clang -emit-llvm -s -o - something.c</code>

让我们回到我们正在做的llvm流程。我们可以查看所有重要的ir对象,只需要用一个普适而方便的方法:<code>dump()</code>。它会打印出人可读的ir对象的表示。因为我们的流程是处理函数的,所以我们用它来迭代函数里所有的代码块,然后是每个代码块的指令集。

<code>errs() &lt;&lt; "function body:\n";</code>

<code>f.dump();</code>

<code>for (auto&amp; b : f) {</code>

<code>errs() &lt;&lt; "basic block:\n";</code>

<code>b.dump();</code>

<code>for (auto&amp; i : b) {</code>

<code>errs() &lt;&lt; "instruction: ";</code>

<code>i.dump();</code>

使用c++ 11里的<code>auto</code>类型和foreach语法可以方便地在llvm ir的继承结构里探索。

如果你重新构建流程并通过它再跑程序,你可以看到很多ir被切分开输出,正如我们遍历它那样。

当你在找寻程序中的一些模式,并有选择地修改它们时,llvm的魔力真正展现了出来。这里是一个简单的例子:把函数里第一个二元操作符(比如+,-)改成乘号。听上去很有用对吧?

<code>if (auto* op = dyn_cast&lt;binaryoperator&gt;(&amp;i)) {</code>

<code>// insert at the point where the instruction `op` appears.</code>

<code>irbuilder&lt;&gt; builder(op);</code>

<code></code>

<code>// make a multiply with the same operands as `op`.</code>

<code>value* lhs = op-&gt;getoperand(0);</code>

<code>value* rhs = op-&gt;getoperand(1);</code>

<code>value* mul = builder.createmul(lhs, rhs);</code>

<code>// everywhere the old instruction was used as an operand, use our</code>

<code>// new multiply instruction instead.</code>

<code>for (auto&amp; u : op-&gt;uses()) {</code>

<code>user* user = u.getuser(); // a user is anything with operands.</code>

<code>user-&gt;setoperand(u.getoperandno(), mul);</code>

<code>// we modified the code.</code>

<code>return true;</code>

细节如下:

irbuilder用于构造代码。它有一百万种方法来创建任何你可能想要的指令。

为把新指令缝进代码里,我们需要找到所有它被使用的地方,然后当做一个参数换进我们的指令里。回忆一下,每个指令都是一个值:在这里,乘法指令被当做另一条指令里的操作数,意味着乘积会成为被传进来的参数。

我们其实应该移除旧的指令,不过简明起见我把它略去了。

<code>#include &lt;stdio.h&gt;</code>

<code>int main(int argc, const char** argv) {</code>

<code>int num;</code>

<code>scanf("%i", &amp;num);</code>

<code>printf("%i\n", num + 2);</code>

<code>return 0;</code>

如果用普通的编译器,这个程序的行为和代码并没有什么差别;但我们的插件会让它将输入翻倍而不是加2。

<code>$ cc example.c</code>

<code>$ ./a.out</code>

<code>10</code>

<code>12</code>

<code>$ clang -xclang -load -xclang build/skeleton/libskeletonpass.so example.c</code>

<code>20</code>

很神奇吧!

<code>// get the function to call from our runtime library.</code>

<code>llvmcontext&amp; ctx = f.getcontext();</code>

<code>constant* logfunc = f.getparent()-&gt;getorinsertfunction(</code>

<code>"logop", type::getvoidty(ctx), type::getint32ty(ctx), null</code>

<code>);</code>

<code>// insert *after* `op`.</code>

<code>builder.setinsertpoint(&amp;b, ++builder.getinsertpoint());</code>

<code>// insert a call to our function.</code>

<code>value* args[] = {op};</code>

<code>builder.createcall(logfunc, args);</code>

<code>void logop(int i) {</code>

<code>printf("computed: %i\n", i);</code>

要运行这个程序,你需要链接你的运行时库:

<code>$ cc -c rtlib.c</code>

<code>$ clang -xclang -load -xclang build/skeleton/libskeletonpass.so -c example.c</code>

<code>$ cc example.o rtlib.o</code>

<code>computed: 14</code>

<code>14</code>

如果你希望的话,你也可以在编译成机器码之前就缝合程序和运行时库。llvm-link工具——你可以把它简单看做ir层面的ld的等价工具,可以帮助你完成这项工作。

大部分工程最终是要和开发者进行交互的。你会希望有一套注记(annotations),来帮助你从程序里传递信息给llvm流程。这里有一些构造注记系统的方法:

可以自由修改clang使它可以翻译你的新语法。不过我不推荐这个。

我希望能在以后的文章里展开讨论这些技术。

llvm非常庞大。下面是一些我没讲到的话题:

使用llvm中的一大批古典编译器分析;

通过hack后端来生成任意的特殊机器指令(架构师们经常想这么干);

本文来自云栖社区合作伙伴“linux中国”,原文发布日期:2015-08-23

继续阅读