隻要你和程式打交道,了解編譯器架構就會令你受益無窮——無論是分析程式效率,還是模拟新的處理器和作業系統。通過本文介紹,即使你對編譯器原本一知半解,也能開始用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),後端
下面分别來解釋:
前端擷取你的源代碼然後将它轉變為某種中間表示。這種翻譯簡化了編譯器其他部分的工作,這樣它們就不需要面對比如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 &f) {</code>
<code>errs() << "i saw a function called " << f.getname() << "!\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裡的程式,你需要知道一點中間表示的組織方法。
子產品(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() << "function body:\n";</code>
<code>f.dump();</code>
<code>for (auto& b : f) {</code>
<code>errs() << "basic block:\n";</code>
<code>b.dump();</code>
<code>for (auto& i : b) {</code>
<code>errs() << "instruction: ";</code>
<code>i.dump();</code>
使用c++ 11裡的<code>auto</code>類型和foreach文法可以友善地在llvm ir的繼承結構裡探索。
如果你重新建構流程并通過它再跑程式,你可以看到很多ir被切分開輸出,正如我們周遊它那樣。
當你在找尋程式中的一些模式,并有選擇地修改它們時,llvm的魔力真正展現了出來。這裡是一個簡單的例子:把函數裡第一個二進制操作符(比如+,-)改成乘号。聽上去很有用對吧?
<code>if (auto* op = dyn_cast<binaryoperator>(&i)) {</code>
<code>// insert at the point where the instruction `op` appears.</code>
<code>irbuilder<> builder(op);</code>
<code></code>
<code>// make a multiply with the same operands as `op`.</code>
<code>value* lhs = op->getoperand(0);</code>
<code>value* rhs = op->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& u : op->uses()) {</code>
<code>user* user = u.getuser(); // a user is anything with operands.</code>
<code>user->setoperand(u.getoperandno(), mul);</code>
<code>// we modified the code.</code>
<code>return true;</code>
細節如下:
irbuilder用于構造代碼。它有一百萬種方法來建立任何你可能想要的指令。
為把新指令縫進代碼裡,我們需要找到所有它被使用的地方,然後當做一個參數換進我們的指令裡。回憶一下,每個指令都是一個值:在這裡,乘法指令被當做另一條指令裡的操作數,意味着乘積會成為被傳進來的參數。
我們其實應該移除舊的指令,不過簡明起見我把它略去了。
<code>#include <stdio.h></code>
<code>int main(int argc, const char** argv) {</code>
<code>int num;</code>
<code>scanf("%i", &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& ctx = f.getcontext();</code>
<code>constant* logfunc = f.getparent()->getorinsertfunction(</code>
<code>"logop", type::getvoidty(ctx), type::getint32ty(ctx), null</code>
<code>);</code>
<code>// insert *after* `op`.</code>
<code>builder.setinsertpoint(&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