基於 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 也支持)。