為什麼人人都該懂點LLVM

發表於2015-08-26

只要你和程式打交道,瞭解編譯器架構就會令你受益無窮——無論是分析程式效率,還是模擬新的處理器和作業系統。通過本文介紹,即使你對編譯器原本一知半解,也能開始用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倉庫:

主要的工作都是在skeleton/Skeleton.cpp中完成的。把它開啟。這裡是我們的業務邏輯:

LLVM流程有很多種,我們現在用的這一種叫函式流程(function pass)(這是一個不錯的入手點)。正如你所期望的,LLVM會在編譯每個函式的時候先喚起這個方法。現在它所做的只是列印了一下函式名。

細節:

  • errs()是一個LLVM提供的C++輸出流,我們可以用它來輸出到控制檯。
  • 函式返回false說明它沒有改動函式F。之後,如果我們真的變換了程式,我們需要返回一個true。

構建

通過CMake來構建這個流程:

如果LLVM沒有全域性安裝,你需要告訴CMake LLVM的位置.你可以把環境變數LLVM_DIR的值修改為通往share/llvm/cmake/的路徑。比如這是一個使用Homebrew安裝LLVM的例子:

構建流程之後會產生一個庫檔案,你可以在build/skeleton/libSkeletonPass.so或者類似的地方找到它,具體取決於你的平臺。下一步我們載入這個庫來在真實的程式碼中執行這個流程。

執行

想要執行你的新流程,用clang編譯你的C程式碼,同時加上一些奇怪的flag來指明你剛剛編譯好的庫檔案:

-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中間表示的指令的例子。

這個指令將兩個32位整數相加(可以通過型別i32推斷出來)。它將4號暫存器(寫作%4)中的數和字面值2(寫作2)求和,然後放到5號暫存器中。這就是為什麼我說LLVM IR讀起來像是RISC機器碼:我們甚至連術語都是一樣的,比如暫存器,不過我們在LLVM裡有無限多個暫存器。

在編譯器內,這條指令被表示為指令C++類的一個例項。這個物件有一個操作碼錶示這是一次加法,一個型別,以及一個運算元的列表,其中每個元素都指向另外一個值(Value)物件。在我們的例子中,它指向了一個代表整數2的常量物件和一個代表5號暫存器的指令物件。(因為LLVM IR使用了靜態單次分配格式,暫存器和指令事實上是一個而且是相同的,暫存器號是人為的字面表示。)

另外,如果你想看你自己程式的LLVM IR,你可以直接使用Clang:

檢視流程中的IR

讓我們回到我們正在做的LLVM流程。我們可以檢視所有重要的IR物件,只需要用一個普適而方便的方法:dump()。它會列印出人可讀的IR物件的表示。因為我們的流程是處理函式的,所以我們用它來迭代函式裡所有的程式碼塊,然後是每個程式碼塊的指令集。

下面是程式碼。你可以通過在llvm-pass-skeleton程式碼庫中切換到containers分支來獲得程式碼。

使用C++ 11裡的auto型別和foreach語法可以方便地在LLVM IR的繼承結構裡探索。

如果你重新構建流程並通過它再跑程式,你可以看到很多IR被切分開輸出,正如我們遍歷它那樣。

 

做些更有趣的事

當你在找尋程式中的一些模式,並有選擇地修改它們時,LLVM的魔力真正展現了出來。這裡是一個簡單的例子:把函式裡第一個二元操作符(比如+,-)改成乘號。聽上去很有用對吧?

下面是程式碼。這個版本的程式碼,和一個可以試著跑的示例程式一起,放在了llvm-pass-skeleton倉庫的 mutate分支

細節如下:

  • dyn_cast<T>(p)建構函式是LLVM型別檢查工具的應用。使用了LLVM程式碼的一些慣例,使得動態型別檢查更高效,因為編譯器總要用它們。具體來說,如果I不是“二元操作符”,這個建構函式返回一個空指標,就可以完美應付很多特殊情況(比如這個)。
  • IRBuilder用於構造程式碼。它有一百萬種方法來建立任何你可能想要的指令。
  • 為把新指令縫進程式碼裡,我們需要找到所有它被使用的地方,然後當做一個引數換進我們的指令裡。回憶一下,每個指令都是一個值:在這裡,乘法指令被當做另一條指令裡的運算元,意味著乘積會成為被傳進來的引數。
  • 我們其實應該移除舊的指令,不過簡明起見我把它略去了。

現在我們編譯一個這樣的程式(程式碼庫中的example.c):

如果用普通的編譯器,這個程式的行為和程式碼並沒有什麼差別;但我們的外掛會讓它將輸入翻倍而不是加2。

很神奇吧!

 

連結動態庫

如果你想調整程式碼做一些大動作,用IRBuilder來生成LLVM指令可能就比較痛苦了。你可能需要寫一個C語言的執行時行為,然後把它連結到你正在編譯的程式上。這一節將會給你展示如何寫一個執行時庫,它可以將所有二元操作的結果記錄下來,而不僅僅是悶聲修改值。

這裡是LLVM流程的程式碼,也可以在llvm-pass-skeleton程式碼庫的rtlib分支找到它。

你需要的工具包括Module::getOrInsertFunction和IRBuilder::CreateCall。前者給你的執行時函式logop增加了一個宣告(類似於在C程式中宣告void logop(int i);而不提供實現)。相應的函式體可以在定義了logop函式的執行時庫(程式碼庫中的rtlib.c)找到。

要執行這個程式,你需要連結你的執行時庫:

如果你希望的話,你也可以在編譯成機器碼之前就縫合程式和執行時庫。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生態圈的好資源。

相關文章