2021-05-12 14:32:11
為什麼人人都該懂點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
中完成的。把它開啟。這裡是我們的業務邏輯:
virtualbool runOnFunction(Function&F){
errs()<<"I saw a function called "<< F.getName()<<"!n";
returnfalse;
}
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.
returntrue;
}
}
}
細節如下:
dyn_cast<T>(p)
建構函式是LLVM型別檢查工具的應用。使用了LLVM程式碼的一些慣例,使得動態型別檢查更高效,因為編譯器總要用它們。具體來說,如果I
不是“二元操作符”,這個建構函式返回一個空指標,就可以完美應付很多特殊情況(比如這個)。- IRBuilder用於構造程式碼。它有一百萬種方法來建立任何你可能想要的指令。
- 為把新指令縫進程式碼裡,我們需要找到所有它被使用的地方,然後當做一個引數換進我們的指令裡。回憶一下,每個指令都是一個值:在這裡,乘法指令被當做另一條指令裡的運算元,意味著乘積會成為被傳進來的引數。
- 我們其實應該移除舊的指令,不過簡明起見我把它略去了。
現在我們編譯一個這樣的程式(程式碼庫中的example.c):
#include<stdio.h>
int main(int argc,constchar** argv){
int num;
scanf("%i",&num);
printf("%in", num +2);
return0;
}
如果用普通的編譯器,這個程式的行為和程式碼並沒有什麼差別;但我們的外掛會讓它將輸入翻倍而不是加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);
returntrue;
}
}
}
你需要的工具包括Module::getOrInsertFunction和IRBuilder::CreateCall。前者給你的執行時函數logop
增加了一個宣告(類似於在C程式中宣告void logop(int i);
而不提供實現)。相應的函數體可以在定義了logop
函數的執行時庫(程式碼庫中的rtlib.c)找到。
#include<stdio.h>
void logop(int i){
printf("computed: %in", 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生態圈的好資源。
更多LLVM的資訊:
LLVM 和 GCC 編譯器開發者將開始展開合作 http://www.linuxidc.com/Linux/2014-02/96366.htm
LLVM 基金會成立 http://www.linuxidc.com/Linux/2014-04/99454.htm
LLVM 編譯器架構獲得 ACM 軟體系統獎 http://www.linuxidc.com/Linux/2013-04/82660.htm
如何建立原生的LLVM SVN映象庫 http://www.linuxidc.com/Linux/2013-03/80162.htm
RMS 談 GCC、LLVM 和 Copyleft http://www.linuxidc.com/Linux/2014-01/95828.htm
相關文章