1. 程式人生 > >Slisp:編譯到JVM平臺上的lisp方言

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彈出,然後計算它們的和,將結果壓入棧頂。imulisubidiv都類似於iadd,不同之處在於將運算子變為了*-/

istoreint儲存在區域性變數中。

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代表每個指令獨特的部分。