為什么人人都該懂點LLVM
只要你和程序打交道,了解編譯器架構就會令你受益無窮——無論是分析程序效率,還是模擬新的處理器和操作系統。通過本文介紹,即使你對編譯器原本一知半解,也能開始用LLVM,來完成有意思的工作。
LLVM是什么?
LLVM是一個好用、好玩,而且超前的系統語言(比如C和C++語言)編譯器。
當然,因為LLVM實在太強大,你會聽到許多其他特性(它可以是個JIT;支持了一大批非類C語言;還是App Store上的一種新的發布方式等等)。這些都是真的,不過就這篇文章而言,還是上面的定義更重要。
下面是一些讓LLVM與眾不同的原因:
- LLVM的“中間表示”(IR)是一項大創新。LLVM的程序表示方法真的“可讀”(如果你會讀匯編)。雖然看上去這沒什么要緊,但要知道,其他編譯器的中間表示大多是種內存中的復雜數據結構,以至于很難寫出來,這讓其他編譯器既難懂又難以實現。
- 然而LLVM并非如此。其架構遠比其他編譯器要模塊化得多。這種優點可能部分來自于它的最初實現者。
- 盡管LLVM給我們這些狂熱的學術黑客提供了一種研究工具的選擇,它還是一款有大公司做后臺的工業級編譯器。這意味著你不需要去在“強大的編譯器”和“可玩的編譯器”之間做妥協——不像你在Java世界中必須在HotSpot和Jikes之間權衡那樣。
為什么人人需要懂點兒LLVM?
是,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
首先需要安裝LLVM。Linux的諸發行版中一般已經裝好了LLVM和Clang的包,你直接用便是。但你還是需要確認一下機子里的版本,是不是有所有你要用到的頭文件。在OS X系統中,和XCode一起安裝的LLVM就不是那么完整。還好,用CMake從源碼構建LLVM也沒有多難。通常你只需要構建LLVM本身,因為你的系統提供的Clang已經夠用(只要版本是匹配的,如果不是,你也可以自己構建Clang)。
具體在OS X上,Brandon Holt有一個不錯的指導文章。用Homebrew也可以安裝LLVM。
去讀手冊
你需要對文檔有所了解。我找到了一些值得一看的鏈接:
- 自動生成的Doxygen文檔頁非常重要。要想搞定LLVM,你必須要以這些API的文檔維生。這些頁面可能不太好找,所以我推薦你直接用Google搜索。只要你在搜索的函數或者類名后面加上“LLVM”,你一般就可以用Google找到正確的文檔頁面了。(如果你夠勤奮,你甚至可以“訓練”你的Google,使得在不輸入LLVM的情況下它也可以把LLVM的相關結果推到最前面)雖然聽上去有點逗,不過你真的需要這樣找LLVM的API文檔——反正我沒找到其他的好方法。
- 《語言參考手冊》也非常有用,如果你曾被LLVM IR dump里面的語法搞糊涂的話。
- 《開發者手冊》描述了一些LLVM特有的數據結構的工具,比如高效字符串,vector和map的替代品等等。它還描述了一些快速類型檢查工具 isa、cast和dyn_cast),這些你不管在哪都要跑。
?如果你不知道你的流程可以做什么,讀《編寫LLVM流程》 。不過因為你只是個研究人員而不是浸淫于編譯器的大牛,本文的觀點可能和這篇教程在一些細節上有所不同。(最緊急的是,別再用基于Makefile的構建系統了。直接開始用CMake構建你的程序吧,讀讀《“源代碼外”指令》)盡管上面這些是解決流程問題的官方材料, - 不過在在線瀏覽LLVM代碼時,這個GitHub鏡像有時會更方便。
寫一個流程
使用LLVM來完成高產研究通常意味著你要寫一些自定義流程。這一節會指導你構建和運行一個簡單的流程來變換你的程序。
框架
我已經準備好了模板倉庫,里面有些沒用的LLVM流程。我推薦先用這個模板。因為如果完全從頭開始,配好構建的配置文件可是相當痛苦的事。
首先從GitHub上下載llvm-pass-skeleton倉庫:
$ git clone git@github.com:sampsyo/llvm-pass-skeleton.git
主要的工作都是在skeleton/Skeleton.cpp中完成的。把它打開。這里是我們的業務邏輯:
virtual bool runOnFunction(Function &F) { errs() << "I saw a function called " << F.getName() << "!\n"; return false; }
LLVM流程有很多種,我們現在用的這一種叫函數流程(function pass)(這是一個不錯的入手點)。正如你所期望的,LLVM會在編譯每個函數的時候先喚起這個方法。現在它所做的只是打印了一下函數名。
細節:
- errs()是一個LLVM提供的C++輸出流,我們可以用它來輸出到控制臺。
- 函數返回false說明它沒有改動函數F。之后,如果我們真的變換了程序,我們需要返回一個true。
構建
通過CMake來構建這個流程:
$ cd llvm-pass-skeleton $ mkdir build $ cd build $ cmake .. # Generate the Makefile. $ make # Actually build the pass.
如果LLVM沒有全局安裝,你需要告訴CMake LLVM的位置.你可以把環境變量LLVM_DIR的值修改為通往share/llvm/cmake/的路徑。比如這是一個使用Homebrew安裝LLVM的例子:
$ LLVM_DIR=/usr/local/opt/llvm/share/llvm/cmake cmake ..
構建流程之后會產生一個庫文件,你可以在build/skeleton/libSkeletonPass.so或者類似的地方找到它,具體取決于你的平臺。下一步我們載入這個庫來在真實的代碼中運行這個流程。
運行
想要運行你的新流程,用clang編譯你的C代碼,同時加上一些奇怪的flag來指明你剛剛編譯好的庫文件:
$ clang -Xclang -load -Xclang build/skeleton/libSkeletonPass.* something.c I saw a function called main!
-Xclang -load -Xclang path/to/lib.so這是你在Clang中載入并激活你的流程所用的所有代碼。所以當你處理較大的項目的時候,你可以直接把這些參數加到Makefile的CFLAGS里或者你構建系統的對應的地方。
(通過單獨調用clang,你也可以每次只跑一個流程。這樣需要用LLVM的opt命令。這是官方文檔里的合法方式,但在這里我就不贅述了。)
恭喜你,你成功hack了一個編譯器!接下來,我們要擴展這個hello world水平的流程,來做一些好玩的事情。
理解LLVM的中間表示
想要使用LLVM里的程序,你需要知道一點中間表示的組織方法。
模塊(Module),函數(Function),代碼塊(BasicBlock),指令(Instruction)
模塊包含了函數,函數又包含了代碼塊,后者又是由指令組成。除了模塊以外,所有結構都是從值產生而來的。
容器
首先了解一下LLVM程序中最重要的組件:
- 粗略地說,模塊表示了一個源文件,或者學術一點講叫翻譯單元。其他所有東西都被包含在模塊之中。
- 最值得注意的是,模塊容納了函數,顧名思義,后者就是一段段被命名的可執行代碼。(在C++中,函數function和方法method都相應于LLVM中的函數。)
- 除了聲明名字和參數之外,函數主要會做為代碼塊的容器。代碼塊和它在編譯器中的概念差不多,不過目前我們把它看做是一段連續的指令。
- 而說到指令,就是一條單獨的代碼命令。這一種抽象基本上和RISC機器碼是類似的:比如一個指令可能是一次整數加法,可能是一次浮點數除法,也可能是向內存寫入。
大部分LLVM中的內容——包括函數,代碼塊,指令——都是繼承了一個名為值的基類的C++類。值是可以用于計算的任何類型的數據,比如數或者內存地址。全局變量和常數(或者說字面值,立即數,比如5)都是值。
指令
這是一個寫成人類可讀文本的LLVM中間表示的指令的例子。
%5 = add i32 %4, 2
這個指令將兩個32位整數相加(可以通過類型i32推斷出來)。它將4號寄存器(寫作%4)中的數和字面值2(寫作2)求和,然后放到5號寄存器中。這就是為什么我說LLVM IR讀起來像是RISC機器碼:我們甚至連術語都是一樣的,比如寄存器,不過我們在LLVM里有無限多個寄存器。
在編譯器內,這條指令被表示為指令C++類的一個實例。這個對象有一個操作碼表示這是一次加法,一個類型,以及一個操作數的列表,其中每個元素都指向另外一個值(Value)對象。在我們的例子中,它指向了一個代表整數2的常量對象和一個代表5號寄存器的指令對象。(因為LLVM IR使用了靜態單次分配格式,寄存器和指令事實上是一個而且是相同的,寄存器號是人為的字面表示。)
另外,如果你想看你自己程序的LLVM IR,你可以直接使用Clang:
$ clang -emit-llvm -S -o - something.c
查看流程中的IR
讓我們回到我們正在做的LLVM流程。我們可以查看所有重要的IR對象,只需要用一個普適而方便的方法:dump()。它會打印出人可讀的IR對象的表示。因為我們的流程是處理函數的,所以我們用它來迭代函數里所有的代碼塊,然后是每個代碼塊的指令集。
下面是代碼。你可以通過在llvm-pass-skeleton代碼庫中切換到containers分支來獲得代碼。
errs() << "Function body:\n"; F.dump(); for (auto& B : F) { errs() << "Basic block:\n"; B.dump(); for (auto& I : B) { errs() << "Instruction: "; I.dump(); } }
使用C++ 11里的auto類型和foreach語法可以方便地在LLVM IR的繼承結構里探索。
如果你重新構建流程并通過它再跑程序,你可以看到很多IR被切分開輸出,正如我們遍歷它那樣。
做些更有趣的事
當你在找尋程序中的一些模式,并有選擇地修改它們時,LLVM的魔力真正展現了出來。這里是一個簡單的例子:把函數里第一個二元操作符(比如+,-)改成乘號。聽上去很有用對吧?
下面是代碼。這個版本的代碼,和一個可以試著跑的示例程序一起,放在了llvm-pass-skeleton倉庫的 mutate分支。
for (auto& B : F) { for (auto& I : B) { if (auto* op = dyn_cast<BinaryOperator>(&I)) { // Insert at the point where the instruction `op` appears. IRBuilder<> builder(op); // Make a multiply with the same operands as `op`. Value* lhs = op->getOperand(0); Value* rhs = op->getOperand(1); Value* mul = builder.CreateMul(lhs, rhs); // Everywhere the old instruction was used as an operand, use our // new multiply instruction instead. for (auto& U : op->uses()) { User* user = U.getUser(); // A User is anything with operands. user->setOperand(U.getOperandNo(), mul); } // We modified the code. return true; } } }
細節如下:
- dyn_cast<T>(p)構造函數是LLVM類型檢查工具的應用。使用了LLVM代碼的一些慣例,使得動態類型檢查更高效,因為編譯器總要用它們。具體來說,如果I不是“二元操作符”,這個構造函數返回一個空指針,就可以完美應付很多特殊情況(比如這個)。
- IRBuilder用于構造代碼。它有一百萬種方法來創建任何你可能想要的指令。
- 為把新指令縫進代碼里,我們需要找到所有它被使用的地方,然后當做一個參數換進我們的指令里。回憶一下,每個指令都是一個值:在這里,乘法指令被當做另一條指令里的操作數,意味著乘積會成為被傳進來的參數。
- 我們其實應該移除舊的指令,不過簡明起見我把它略去了。
現在我們編譯一個這樣的程序(代碼庫中的example.c):
#include <stdio.h> int main(int argc, const char** argv) { int num; scanf("%i", &num); printf("%i\n", num + 2); return 0; }
如果用普通的編譯器,這個程序的行為和代碼并沒有什么差別;但我們的插件會讓它將輸入翻倍而不是加2。
$ cc example.c $ ./a.out 10 12 $ clang -Xclang -load -Xclang build/skeleton/libSkeletonPass.so example.c $ ./a.out 10 20
很神奇吧!
鏈接動態庫
如果你想調整代碼做一些大動作,用IRBuilder來生成LLVM指令可能就比較痛苦了。你可能需要寫一個C語言的運行時行為,然后把它鏈接到你正在編譯的程序上。這一節將會給你展示如何寫一個運行時庫,它可以將所有二元操作的結果記錄下來,而不僅僅是悶聲修改值。
這里是LLVM流程的代碼,也可以在llvm-pass-skeleton代碼庫的rtlib分支找到它。
// Get the function to call from our runtime library. LLVMContext& Ctx = F.getContext(); Constant* logFunc = F.getParent()->getOrInsertFunction( "logop", Type::getVoidTy(Ctx), Type::getInt32Ty(Ctx), NULL ); for (auto& B : F) { for (auto& I : B) { if (auto* op = dyn_cast<BinaryOperator>(&I)) { // Insert *after* `op`. IRBuilder<> builder(op); builder.SetInsertPoint(&B, ++builder.GetInsertPoint()); // Insert a call to our function. Value* args[] = {op}; builder.CreateCall(logFunc, args); return true; } } }
你需要的工具包括Module::getOrInsertFunction和IRBuilder::CreateCall。前者給你的運行時函數logop增加了一個聲明(類似于在C程序中聲明void logop(int i);而不提供實現)。相應的函數體可以在定義了logop函數的運行時庫(代碼庫中的rtlib.c)找到。
#include <stdio.h> void logop(int i) { printf("computed: %i\n", i); }
要運行這個程序,你需要鏈接你的運行時庫:
$ cc -c rtlib.c $ clang -Xclang -load -Xclang build/skeleton/libSkeletonPass.so -c example.c $ cc example.o rtlib.o $ ./a.out 12 computed: 14 14
如果你希望的話,你也可以在編譯成機器碼之前就縫合程序和運行時庫。llvm-link工具——你可以把它簡單看做IR層面的ld的等價工具,可以幫助你完成這項工作。
注記(Annotation)
大部分工程最終是要和開發者進行交互的。你會希望有一套注記(annotations),來幫助你從程序里傳遞信息給LLVM流程。這里有一些構造注記系統的方法:
- 一個實用而取巧的方法是使用魔法函數。先在一個頭文件里聲明一些空函數,用一些奇怪的、基本是獨特的名字命名。在源代碼中引入這個頭文件,然后調用這些什么都沒有做的函數。然后,在你的流程里,查找喚起了函數的CallInst指令,然后利用它們去觸發你真正要做的“魔法”。比如說,你可能想調用__enable_instrumentation()和__disable_instrumentation(),讓程序將代碼改寫限制在某些具體的區域。
- 如果想讓程序員給函數或者變量聲明加記號,Clang的__attribute__((annotate("foo")))語法會發射一個元數據和任意字符串,可以在流程中處理它。Brandon Holt(又是他)有篇文章講解了這個技術的背景。如果你想標記一些表達式,而非聲明,一個沒有文檔,同時很不幸受限了的__builtin_annotation(e, "foo")內建方法可能會有用。
- 可以自由修改Clang使它可以翻譯你的新語法。不過我不推薦這個。
- 如果你需要標記類型——我相信大家經常沒意識到就這么做了——我開發了一個名為Quala的系統。它給Clang打了補丁,以支持自定義的類型檢查和可插拔的類型系統,到Java的JSR-308。如果你對這個項目感興趣,并且想合作,請聯系我。
我希望能在以后的文章里展開討論這些技術。
其他
LLVM非常龐大。下面是一些我沒講到的話題:
- 使用LLVM中的一大批古典編譯器分析;
- 通過hack后端來生成任意的特殊機器指令(架構師們經常想這么干);
- 利用debug info連接源代碼中的行和列到IR中的每一處;
- 開發[Clang前端插件]。(http://clang.llvm.org/docs/ClangPlugins.html)
我希望我給你講了足夠的背景來支持你完成一個好項目了。探索構建去吧!如果這篇文章對你幫助,也請讓我知道。
感謝UW的架構與系統組,圍觀了我的這篇文章并且提了很多很贊的問題。
以及感謝以下的讀者:
- Emery Berger指出了動態二進制分析工具,比如Pin,仍然是你在觀察系統結構中具體內容(比如寄存器,內存繼承和指令編碼等)的好幫手;
- Brandon Holt發了一篇《LLVM debug 技巧》,包括如何用GraphViz繪制控制流圖;
- John Regehr在評論中提到把軟件搭在LLVM上的缺點:API不穩定性。LLVM內部幾乎每版都要大換,所以你需要不斷維護你的項目。Alex Bradbury的LLVM周報是個跟進LLVM生態圈的好資源。
原文:http://adriansampson.net/blog/llvm.html 作者: Adrian Sampson
譯文:http://geek.csdn.net/news/detail/37785 譯者: 張洵愷