使用 LLVM 框架建立一個工作編譯器,第 1 部分

發表於2012-07-18

來源:IBM developerworks

簡介: LLVM 編譯器基礎架構提供了一種強大的方法來優化您使用任何程式語言編寫的應用程式。瞭解本系列文章(由兩部分組成)第一部分中有關 LLVM 的基礎知識。構建一個自定義編譯器會變得更輕鬆!

LLVM(之前稱為低階虛擬機器)是一種非常強大的編譯器基礎架構框架,專門為使用您喜愛的程式語言編寫的程式的編譯時、連結時和執行時優化而設計。LLVM 可執行於若干個不同的平臺之上,它以能夠生成快速執行的程式碼而著稱。

LLVM 框架是圍繞著程式碼編寫良好的中間表示 (IR) 而構建的。本文(由兩部分組成的系列文章的第一部分)將深入講解 LLVM IR 的基礎知識以及它的一些微妙之處。在這裡,您將構建一個可以自動為您生成 LLVM IR 的程式碼生成器。擁有一個 LLVM IR 生成器意味著您所需要的是一個前端以供插入您所喜愛的程式語言,而且這還意味著您擁有一個完整的流程(前端解析器 + IR 生成器 + LLVM 後端)。建立一個自定義編譯器會變得更加簡單。

使用 LLVM 框架建立一個工作編譯器,第 1 部分

開始使用 LLVM

在開始之前,在您的開發計算器上必須已經擁有已編譯好的 LLVM(參閱 參考資料 獲取相關連結)。本文中的示例均基於 LLVM V3.0。對於 LLVM 程式碼的後期生成和安裝,最重要的兩個工具是 llc 和 lli

llc 和 lli

因為 LLVM 是一個虛擬機器,所以它可能應該擁有自己的中間位元組程式碼表示,不是嗎?最後,您需要將 LLVM 位元組程式碼編譯到特定於平臺的組合語言中。然後您才能通過本機彙編程式和連結器來執行彙編程式碼,從而生成可執行的共享庫等。您可以使用 llc 將 LLVM 位元組程式碼轉換成特定於平臺的彙編程式碼(請參閱 參考資料,獲取關於此工具的更多資訊的連結)。對於 LLVM 位元組程式碼的直接執行部分,不要等到在本機執行程式碼崩潰後才發現您的程式中有一個或兩個 bug。這正是 lli 的用武之地,因為它可以直接執行位元組程式碼。lli 可以通過直譯器或使用高階選項中的即時 (JIT) 編譯器執行此工作。請參閱 參考資料,獲取關於 lli 的更多資訊的連結。

llvm-gcc

llvm-gcc 是 GNU Compiler Collection (gcc) 的修改版本,可以在使用 -S -emit-llvm 選項執行時會生成 LLVM 位元組程式碼。然後您可以使用 lli 來執行這個已生成的位元組程式碼(也稱為 LLVM 組合語言)。有關 llvm-gcc 的更多資訊,請參閱 參考資料。如果您沒有在自己的系統中預先安裝 llvm-gcc,那麼您應該能夠從原始碼構建它,請參閱 參考資料,獲取分步指南的連結。

使用 LLVM 編寫 Hello World

要更好地理解 LLVM,您必須瞭解 LLVM IR 及其微妙之處。這個過程類似於學習另一種程式語言。但是,如果您熟悉 C 語言和 C++ 語言以及它們的一些語法怪現象,那麼在瞭解 LLVM IR 方面您應該沒有太大的障礙。清單 1 給出了您的第一個程式,該程式將在控制檯輸出中列印 “Hello World”。要編譯此程式碼,您可以使用 llvm-gcc。

清單 1. 看起來非常熟悉的 Hello World 程式

要編譯此程式碼,請輸入此命令:

完成編譯後,llvm-gcc 會生成 helloworld.s 檔案,您可以使用 lli 來執行該檔案,將訊息輸出到控制檯。lli 的用法如下:

現在,先看一下 LLVM 組合語言。清單 2 給出了該程式碼。

清單 2. Hello World 程式的 LLVM 位元組程式碼

理解 LLVM IR

LLVM 提供了一個詳細的組合語言表示(參閱 參考資料 獲取相關的連結)。在開始編寫我們之前討論的自己的 Hello World 程式版本之前,有幾個需知事項:

● LLVM 組合語言中的註解以分號 (;) 開始,並持續到行末。

● 全域性識別符號要以 @ 字元開始。所有的函式名和全域性變數都必須以 @ 開始。

● LLVM 中的區域性識別符號以百分號 (%) 開始。識別符號典型的正規表示式是 [%@][a-zA-Z$._][a-zA-Z$._0-9]*

● LLVM 擁有一個強大的型別系統,這也是它的一大特性。LLVM 將整數型別定義為 iN,其中 N 是整數佔用的位元組數。您可以指定 1 到 223- 1 之間的任意位寬度。

● 您可以將向量或陣列型別宣告為 [no. of elements X size of each element]。對於字串 “Hello World!”,可以使用型別[13 x i8],假設每個字元佔用 1 個位元組,再加上為 NULL 字元提供的 1 個額外位元組。

● 您可以對 hello-world 字串的全域性字串常量進行如下宣告:@hello = constant [13 x i8] c"Hello World!0"。使用關鍵字 constant 來宣告後面緊跟型別和值的常量。我們已經討論過型別,所以現在讓我們來看一下值:您以 c 開始,後面緊跟放在雙引號中的整個字串(其中包括  並以 0 結尾)。不幸的是,關於字串的宣告為什麼需要使用 c 字首,並在結尾處包含 NULL 字元和 0,LLVM 文件未提供任何解釋。如果您有興趣研究更多有關 LLVM 的語法怪現象,請參閱 參考資料,獲取語法檔案的連結。

● LLVM 允許您宣告和定義函式。而不是仔細檢視 LLVM 函式的整個特性列表,我只需將精力集中在基本要點上即可。以關鍵字define 開始,後面緊跟返回型別,然後是函式名。返回 32 位元組整數的 main 的簡單定義類似於:define i32 @main() { ; some LLVM assembly code that returns i32 }

● 函式宣告,顧名思義,有著重大的意義。這裡提供了 puts 方法的最簡單宣告,它是 printfdeclare i32 puts(i8*) 的 LLVM 等同物。該宣告以關鍵字 declare 開始,後面緊跟著返回型別、函式名,以及該函式的可選引數列表。該宣告必須是全域性範圍的。

● 每個函式均以返回語句結尾。有兩種形式的返回語句:ret <type> <value> 或 ret void。對於您簡單的主例程,使用 ret i32 0 就足夠了。

● 使用 call <function return type> <function name> <optional function arguments> 來呼叫函式。注意,每個函式引數都必須放在其型別的前面。返回一個 6 位的整數並接受一個 36 位的整數的函式測試的語法如下:call i6 @test( i36 %arg1 )

這只是一個開始。您還需要定義一個主例程、一個儲存字串的常量,以及處理實際列印的 puts 方法的宣告。清單 3 顯示第一次嘗試建立的程式。

清單 3. 第一次嘗試建立手動編寫的 Hello World 程式

這裡提供了來自 lli 的日誌:

程式並未按預期的執行。發生了什麼?如之前所提及的,LLVM 擁有一個強大的型別系統。因為 puts 期望提供一個指向 i8 的指標,並且您能傳遞一個 i8 向量,這樣 lli 才能快速指出錯誤。該問題的常用解決方法(來自 C 程式設計背景)是使用型別轉換。這將您引向了 LLVM 指令 getelementptr。請注意,您必須將 清單 3 中的 puts 呼叫修改為與 call i32 @puts(i8* %t) 類似,其中 %t 是型別i8*,並且是 [13 x i8] to i8* 的型別轉換結果。(請參閱 參考資料,獲取 getelementptr 的詳細描述的連結。)在進一步探討之前,清單 4 提供了可行的程式碼。

清單 4. 使用 getelementptr 正確地將型別轉換為指標

getelementptr 的第一個引數是全域性字串變數的指標。要單步執行全域性變數的指標,則需要使用第一個索引,即 i64 0。因為getelementptr 指令的第一個引數必須始終是 pointer 型別的值,所以第一個索引會單步除錯該指標。0 值表示從該指標起偏移 0 元素偏移量。我的開發計算機執行的是 64 位 Linux®,所以該指標是 8 位元組。第二個索引 (i64 0) 用於選擇字串的第 0 個元素,該元素是作為 puts 的引數來提供的。

建立一個自定義的 LLVM IR 程式碼生成器

瞭解 LLVM IR 是件好事,但是您需要一個自動化的程式碼生成系統,用它來轉儲 LLVM 組合語言。謝天謝地,LLVM 提供了強大的應用程式程式設計介面 (API) 支援,讓您可以檢視整個過程(請參閱 參考資料,獲取程式設計師手冊的連結)。在您的開發計算機上查詢 LLVMContext.h 檔案;如果該檔案缺失,那麼可能是您安裝 LLVM 的方式出錯。

現在,讓我們建立一個程式,為之前討論的 Hello World 程式生成 LLVM IR。該程式不會處理這裡的整個 LLVM API,但是接下來的程式碼樣例會證明,適量位數的 LLVM API 很直觀而且易於使用。

針對 LLVM 程式碼的連結

LLVM 提供了一款出色的工具,叫做 llvm-config(參閱 參考資料)。執行 llvm-config –cxxflags,獲取需要傳遞至 g++ 的編譯標誌、連結器選項的 llvm-config –ldflags 以及 llvm-config –ldflags,以便針對正確的 LLVM 庫進行連結。在 清單 5 的樣例中,所有的選項均需要傳遞至 g++。

清單 5. 通過 LLVM API 使用 llvm-config 構建程式碼

LLVM 模組和上下文環境等

LLVM 模組類是其他所有 LLVM IR 物件的頂級容器。LLVM 模組類能夠包含全域性變數、函式、該模組所依賴的其他模組和符號表等物件的列表。這裡將提供了 LLVM 模組的建構函式:

要構建您的程式,必須從建立 LLVM 模組開始。第一個引數是該模組的名稱,可以是任何虛擬的字串。第二個引數稱為LLVMContextLLVMContext 類有些晦澀,但使用者足以瞭解它提供了一個用來建立變數等物件的上下文環境。該類在多執行緒的上下文環境中變得非常重要,您可能想為每個執行緒建立一個本地上下文環境,並且想讓每個執行緒完全獨立於其他上下文環境執行。目前,使用這個預設的全域性上下文來處理 LLVM 所提供的程式碼。這裡給出了建立模組的程式碼:

您要了解的下一個重要類是能實際提供 API 來建立 LLVM 指令並將這些指令插入基礎塊的類:IRBuilder 類。IRBuilder 提供了許多華而不實的方法,但是我選擇了最簡單的可行方法來構建一個 LLVM 指令,即使用以下程式碼來傳遞全域性上下文:

準備好 LLVM 物件模型後,就可以呼叫模組的 dump 方法來轉儲其內容。清單 6 給出了該程式碼。

清單 6. 建立一個轉儲模組

執行 清單 6 中的程式碼之後,控制檯的輸出如下:

然後,您需要建立 main 方法。LLVM 提供了 llvm::Function 類來建立一個函式,並提供了 llvm::FunctionType 將該函式與某個返回型別相關聯。此外,請記住,main 方法必須是該模組的一部分。清單 7 給出了該程式碼。

清單 7. 將 main 方法新增至頂部模組

請注意,您需要讓 main 返回 void,這就是您呼叫 builder.getVoidTy() 的原因;如果 main 返回 i32,那麼該呼叫會是builder.getInt32Ty()。在編譯並執行 清單 7 中的程式碼後,出現的結果如下:

您還尚未定義 main 要執行的指令集。為此,您必須定義一個基礎塊並將其與 main 方法關聯。基礎塊 是 LLVM IR 中的一個指令集合,擁有將標籤(類似於 C 標籤)定義為其建構函式的一部分的選項。builder.setInsertPoint 會告知 LLVM 引擎接下來將指令插入何處。清單 8 給出了該程式碼。

清單 8. 向 main 新增一個基礎塊

這裡提供了 清單 8 的輸出。請注意,由於現在已經定義了 main 的基礎塊,所以 LLVM 轉儲將 main 看作為是一個方法定義,而不是一個宣告。非常酷!

現在,向程式碼新增全域性 hello-world 字串。清單 9 給出了該程式碼。

清單 9. 向 LLVM 模組新增全域性字串

在 清單 9 的輸出中,注意 LLVM 引擎是如何轉儲字串的:

現在您需要做的就是宣告 puts 方法,並且呼叫它。要宣告 puts 方法,則必須建立合適的 FunctionType*。從您的 Hello World 源始程式碼中,您知道 puts 返回了 i32 並接受 i8* 作為輸入引數。清單 10 給出了建立 puts 的正確型別的程式碼。

清單 10. 宣告 puts 方法的程式碼

FunctionType::get 的第一個引數是返回型別;第二個引數是一個 LLVM::ArrayRef 結構,並且最後的 false 指明瞭後面未跟可變數量的引數。ArrayRef 結構與向量相似,只是它不包含任何基礎資料,並且主要用於包裝諸如陣列和向量等資料塊。由於這個改變,輸出顯示將如 清單 11 所示。

清單 11. 宣告 puts 方法

剩下要做的是呼叫 main 中的 puts 方法,並從 main 中返回。LLVM API 非常關注轉換等操作:您需要做的是呼叫 puts 來呼叫builder.CreateCall。最後,要建立返回語句,請呼叫 builder.CreateRetVoid。清單 12 提供了完整的執行程式碼。

清單 12. 輸出 Hello World 的完整程式碼

結束語

在這篇初步瞭解 LLVM 的文章中,瞭解了諸如 lli 和 llvm-config 等 LLVM 工具,還深入研究了 LLVM 中間程式碼,並使用 LLVM API 來為您自己生成中間程式碼。本系列的第二部分(也是最後一部分)將探討可以使用 LLVM 完成的另一項任務,即毫不費力地新增額外的編譯傳遞。

 

相關文章