天天看點

為什麼人人都該懂點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

繼續閱讀