基於 LLVM 自制編譯器(10)——總結

語言: CN / TW / HK

本章是本系列教程的最後一章。通過本教程,我們實現並擴充套件 Kaleidoscope 程式語言,使其語言的特性和功能不斷增強。

在這個過程中,我們構建了詞法分析器、解析器、AST、程式碼生成器、REPL、JIT,併為可執行檔案支援了除錯資訊。所有的功能僅僅用了 1000 行左右程式碼就實現了。

我們的語言支援幾個有趣的特性,比如:支援自定義的二元運算子和一元運算子,支援 JIT 編譯並執行,支援構造控制流等。

本教程的初衷是為了向開發者展示定義、構建、使用語言是如此簡單和有趣,編譯器的實現也並不是難如登天!現在,我們已經瞭解了自制編譯器的一些基礎知識,這裡強烈建議開發者能夠使用程式碼對其進行魔改。比如,可以嘗試支援以下這些特性:

  • 全域性變數 :雖然全域性變數在軟體工程中並不是一個非常有價值的特性,但是將它應用於 Kaleidoscope 中,其實是非常有用的。在目前的實現中,我們可以非常容易地為 Kaleidoscope 支援全域性變數:只需要在全域性變數符號表中查詢未解析的變數即可。如果要建立全域性變數,請使用 LLVM GlobalVariable 類。
  • 型別變數 :目前,Kaleidoscope 只支援一種資料型別 double 。由於只支援一種型別,因此無需指定變數型別。如果要支援多種資料型別,最簡單的方法是要求使用者為每個變數定義指定型別,並在符號表中記錄變數的型別及其值。
  • 陣列、結構體、向量 :一旦支援了多種型別,我們可以通過各種方式對型別系統進行擴充套件。對於陣列、結構體、向量等型別,其核心是基於 LLVM getelementptr 進行實現。
  • 標準執行時 :目前,Kaleidoscope 執行用於訪問任意外部函式,比如: printdputchard 等。當我們擴充套件語言以支援更高階的特性時,可以考慮實現執行時。比如:對於實現雜湊表,雜湊表底層封裝了一系列實現,如果將這些實現內聯至程式碼中,那麼每定義一個雜湊表會生成底層的實現程式碼,如果我們將雜湊表的底層實現作為一個子程式定義在執行時,那麼將會非常具有優化意義。
  • 記憶體管理 :目前,Kaleidoscope 只能訪問棧記憶體。如果為 Kaleidoscope 支援通過呼叫標準的 libc malloc / free 介面或使用垃圾收集器來分配堆記憶體,那麼也能夠極大地增強語言的能力。對此,LLVM 是完全支援精準垃圾收集(Accurate Garbage Collection)功能的,包括物件移動、棧掃描與更新等演算法。
  • 異常處理 :LLVM 支援生成零開銷異常。此外,我們還可以隱式地使每個函式返回一個錯誤值並檢查,從而生成程式碼。我們還可以顯式地使用 setjmp / longjmp
  • 面向物件、泛型、資料庫訪問、複數、幾何程式設計… :我們可以為語言擴充套件任何特性。
  • 其他領域 :我們可以將 LLVM 應用至很多領域,從而構建特定語言。當然,還有很多其他領域會利用編譯相關技術,比如:LLVM 被用於實現 OpenGL圖形加速、C++ 程式碼轉換為 ActionScript 等等。甚至,也許你將是第一個使用 LLVM 將正則表示式直譯器 JIT 編譯成本機程式碼的人!

LLVM IR 屬性

對於 LLVM IR,我們經常會有一些疑問。本章,我們梳理了一些常見的問題,並進行解答。

目標獨立

Kaleidoscope 是 可移植語言 的一個例子:任何用 Kaleidoscope 編寫的程式都可以在它執行的任何目標上以相同的方式工作。絕大多數程式語言都具有此屬性,如:lisp、java、haskell、javascript、python 等。但需要注意的是,雖然這些語言是可移植的,但並非所有的庫都是如此。

LLVM 有一個特性是它通常能夠在 IR 中保持目標獨立:我們可以將 LLVM IR 用於 Kaleidoscope 編譯的程式並在 LLVM 支援的任何目標上執行它。簡而言之,Kaleidoscope 編譯器生成與目標無關的程式碼,因為它在生成程式碼時不會查詢任何特定於目標的資訊。

安全保證

上面提到的一些程式語言,很多都是 安全 的語言。比如,用 Java 編寫的程式不可能破壞其地址空間並使程序崩潰(假設 JVM 沒有錯誤)。 安全性是一個有趣的屬性,它需要結合語言設計、執行時支援以及作業系統支援。

在 LLVM 中實現安全語言是完全可以的,但 LLVM IR 本身並不能保證安全。LLVM IR 允許不安全的指標轉換、釋放錯誤後使用、緩衝區溢位和各種其他問題。要實現安全的特性,我們需要在 LLVM 之上構建一個層來實現。

語言特定優化

和其他工具一樣,LLVM 不能在一個系統中解決所有的問題。對此,很多開發者會抱怨 LLVM 無法執行高階語言的特定優化,因為 LLVM 丟失了太多資訊。對此,本章給出瞭如下的一些看法。

首先,LLVM 確實會丟失資訊。例如,在撰寫本教程時,在 LLVM IR 中無法區分 SSA 值是來自 ILP32 機器上的 C int 還是 C long (除錯資訊除外)。兩者都被編譯為 i32 值,並且關於它來自什麼的資訊丟失了。這裡更普遍的問題是 LLVM 型別系統使用 “結構等價” 而不是 “命名等價”。另一個讓人感到驚訝的地方是,如果我們在高階語言中有兩種具有相同結構的型別(例如,兩個具有單個 int 欄位的不同結構),那麼這些型別將被編譯成單個 LLVM 型別。

其次,雖然 LLVM 確實會丟失資訊,但 LLVM 並不是一個固定的目標:我們會繼續以許多不同的方式增強和改進它。除了新增新功能(LLVM 並不總是支援異常或除錯資訊)外,我們還擴充套件了 IR 以捕獲重要資訊以進行優化(例如,引數是符號擴充套件還是零擴充套件、指標別名資訊等)。許多增強功能都是使用者驅動的:開發者希望 LLVM 包含一些特定功能,為此,開發者們一直在對它進行擴充套件。

第三,新增特定於語言的優化是可能且容易的。舉一個簡單的例子,我們可以很容易地新增特定於語言的優化通道,從而為一種語言編譯的程式碼。對於 C 系列,有一個標準 C 庫函式的優化通道。如果我們在 main() 中呼叫 exit(0) ,它會知道將其優化為 return 0; 是安全的。

此外,還可以將各種其他語言特定的資訊嵌入到 LLVM IR 中。即使在最壞的情況下,我們也可以將 LLVM 視為純粹的程式碼生成器,並在特定於語言的 AST 上在編譯前端實現我們想要的高階優化。

提示與技巧

在使用 LLVM 之後,我們會了解到許多有用的提示與技巧,這些技巧和技巧乍一看並不明顯。這裡,我們只討論其中的一些問題。

實現可移植的 offsetof/sizeof

如果我們希望讓編譯器生成的程式碼保持 目標獨立 ,那麼會出現一件有趣的事情,那就是我們經常需要知道某些 LLVM 型別的大小或 llvm 結構中某些欄位的偏移量。 例如,我們可能需要將型別的大小傳遞給分配記憶體的函式。

不幸的是,這在不同目標之間可能會有很大差異:例如,指標的寬度是特定於目標的。不過,有一種巧妙的方法,即使用 getelementptr 指令,它允許我們以可移植的方式計算它。

垃圾回收棧幀

某些語言想要顯式地管理棧幀,通常是為了支援垃圾收集棧幀或允許實現閉包。事實上,通常有比顯式管理棧幀更好的方法來實現這些功能,但如果我們執意這麼做,LLVM 也是支援的。 這需要我們的編譯前端將程式碼轉換為連續傳遞樣式並使用尾呼叫(LLVM 也支援)。