Slisp:編譯到JVM平臺上的lisp方言
一、前言
之前經常變更學習方向,沒有收到很好的學習效果,浪費了不少時間。最近痛定思痛,把方向定為JVM和編譯原理,這次真的不改了。本文是學習該方向的階段性總結。
之前寫過幾個直譯器,但還沒寫過編譯器。剛好看到知乎Belleve給出的一幅學習路線圖,於是決定實現一個lisp方言的編譯器。
之所以選擇JVM而不是X86作為目標平臺,一是JVM平常用的多一些,可以互相印證、互相補充;二是文件和社群資源豐富友好,開發體驗較好。
專案地址:https://github.com/tdkihrr/Slisp
截止最新的commit77f126d4
,實現的功能有:
-
定義變數
-
支援字串、整數和布林型別
-
列印以上三種預置型別的值
-
四則運算
-
條件判斷
二、編譯和執行方法
來一段具體的Slisp程式:
(define a (+ 1 2 3 4)) (println a) (define b (+ a a)) (println b) (define a (+ b b)) (println a) (println (+ (+ 1 1) (- 6 4) (* 2 2) (/ 4 2))) (println "Hello Slisp!") (define c "Hello world!") (println c) (println true) (println false) (define d true) (println d) (if true (println true) (println false)) (if (== 1 1) (println "1 == 1") (println "1 != 1"))
以上程式出自本專案/Slisp/Hello.slisp。
想要執行必須先打包編譯器:
./gradlew clean build
得到了build/libs/slisp-0.1.0.jar
,之後在命令列編譯原始碼:
java -jar build/libs/slisp-0.1.0.jar Slisp/Hello.slisp
即可生成Hello.class
檔案,java Hello
執行該檔案,輸出為:
10 20 40 10 Hello Slisp! Hello world! true false true true 1 == 1
三、編譯器組成部分
這個編譯器由三部分組成,一是前端部分,二是構建抽象語法樹,三是遞迴下降生成位元組碼。
前端部分使用了Antlr來構建。Antlr是一個流行的parser generator,可以根據給定的文法,生成相應的parser。因為Slisp本身採用了lisp系的語法,並不複雜,所以很容易寫出文法供Antlr使用。
構建抽象語法樹使用了visitor模式。由於Antlr本身返回的結果已經是一棵樹,所以這部分的工作是,根據每個節點不同的形態建立相應的類和例項。
這裡有一些實現上的細節可以優化,比如針對四則運算,可以將這些運算全部用一個類來表示,只更改其中的一個欄位以示區別。還有一點是,如果打算只使用一個visitor,那麼每個節點類都需要繼承同一個介面或父類。
還有,實現了一點簡單的型別推導。傳統的lisp方言大多是動態語言,不過Slisp是靜態的,而且可以在定義變數時推匯出變數的型別,不需要開發者手動宣告變數的型別。(define a 123)
、(define b "Hello")
和(define c true)
可以由字面值推匯出型別,而(define d (+ 1 (- 2 3))
也可以推匯出表示式(+ 1 (- 2 3))
的型別並以此確定d
的型別。
生成位元組碼部分採用了遞迴下降來生成。比如對(+ (+ 1 1) (- 6 4) (* 2 2) (/ 4 2))
,生成了:
44: bipush 1
46: bipush 1
48: iadd
49: bipush 6
51: bipush 4
53: isub
54: iadd
55: bipush 2
57: bipush 2
59: imul
60: iadd
61: bipush 4
63: bipush 2
65: idiv
66: iadd
這段程式碼是Hello.class檔案中的一部分,使用OpenJDK中的javap反彙編器生成。
(+ 1 1)
對應44、46和48,先將兩個1壓入棧中,然後相加,將之前的兩個人從棧中彈出,然後將結果壓入棧頂,繼續執行(- 6 4)
。
這裡需要注意的是,並不是說執行完這四個運算(+ 1 1) (- 6 4) (* 2 2) (/ 4 2)
,然後再計算它們的和。而是在計算完(+ 1 1)
和(- 6 4)
之後(結果為2和2),立即計算了(+ 2 2)
(得到4),然後計算(* 2 2)
(得到4),再計算(+ 4 4)
,以此類推。過程如下所示:
(+ (+ 1 1) (- 6 4) (* 2 2) (/ 4 2))
(+ 2 (- 6 4) (* 2 2) (/ 4 2))
(+ 2 2 (* 2 2) (/ 4 2))
(+ 4 (* 2 2) (/ 4 2))
(+ 4 4 (/ 4 2))
(+ 8 (/ 4 2))
(+ 8 2)
(10)
為了契合這樣的位元組碼運算方式,後端在建立抽象語法樹的時候需要注意“左結合與右結合”的問題。這裡採用了右結合的方式,大致結構如下所示:
(+ (/ 4 2)
(+ (* 2 2)
(+ (- 6 4)
(+ 1 1))))
這樣從底層開始生成位元組碼,每生成一層,就向上傳遞,繼續生成上層節點的位元組碼。
實際開發中使用了ASM庫來輔助生成位元組碼,只需要手動拼接好類似於bipush 1
這樣的文字傳給ASM中合適的類和方法,最後呼叫generateBytecode
這樣的方法即可。
雖然ASM庫很方便,但想要生成符合語義的位元組碼,開發者仍需要閱讀JVM規範。JVM規範中定義了各位元組碼的名稱與語義,對照著網路上的眾多示例還是很容易理解的。
四、位元組碼簡介
bipush
是指將一個型別為byte
擴充為int
,然後壓到棧上。
iadd
是將棧最上面的兩個int
彈出,然後計算它們的和,將結果壓入棧頂。imul
、isub
和idiv
都類似於iadd
,不同之處在於將運算子變為了*
、-
和/
。
istore
將int
儲存在區域性變數中。
iload
從區域性變數中取出儲存在其中的值。
astore
是將對一個Ojbect
的引用儲存在區域性變數中。
alocal
是將儲存在區域性變數中的引用壓入棧頂。
ifeq
是將棧頂的值與0
進行比較,如果相等,進入true branch,否則進行false branch。該指令還會指定一個數字作為false branch入口的地址。
if_icmpne
是比較棧上的兩個型別為int
的值,如果不相等,進入true branch,否則進入false branch。
值得注意的是,諸如if
這樣的指令並不是單個存在,它們更多的像是一個家庭,比如比較兩個int
會有許多相似的指令,從JVM規範中抄錄一段:
• if_icmpeq succeeds if and only if value1 = value2
• if_icmpne succeeds if and only if value1 ≠ value2
• if_icmplt succeeds if and only if value1 < value2
• if_icmple succeeds if and only if value1 ≤ value2
• if_icmpgt succeeds if and only if value1 > value2
• if_icmpge succeeds if and only if value1 ≥ value2
可以看到if_icmpne
只是用來比較兩個數相等時的情況,還有其它指令用於比較不等、大於、小於、相等時的情況。像這樣相似而略有區別的指令,JVM規範大多將它們的文件合併在一起,並起名為if_icmp<cond>
,這裡的cond
代表每個指令獨特的部分。