05.從0實現一個JVM語言之目標平臺程式碼生成-CodeGenerator

throw_new_NullPointe發表於2021-03-04

從0實現JVM語言之目標平臺程式碼生成-CodeGenerator

原始碼github倉庫, 如果這個系列文章對你有幫助, 希望獲得你的一個star

本節相關程式碼生成package地址

系列導讀 00.一個JVM語言的誕生

階段性的告別

    非常感謝您能看到這裡, 您的陪伴是我這幾天堅持寫作整合的動力! 但是所有的故事都有畫上句號
的那一天, 我們這個系列也不例外, 今天就是一個給大家的階段性停更, 因為我們程式碼優化部分以及一些
還沒實現但是應該實現的部分(陣列, printf等等)還會繼續實現, 還有程式碼中有的編譯優化可能也要隔
一段時間才能獻上了, 主要原因是目前由於個人要準備春招實習了, 要暫時跟大家說一聲告別, 今天這篇
結束以後可能會停更一段時間, 春招完後會繼續更新這個系列以及其他系列
   
    之後這個系列還會頻率更低地更新, 未來我打算出一個手寫TCP/IP協議簇系列, 再出一個手寫簡單的
作業系統系列, 手寫JVM系列, 如果時間足夠, 應該還會出手寫Tomcat系列, 哈哈這些太多了, 但是以前
沒有深入瞭解很遺憾, 在這之前應有一個Android系列, Android應該就是寫個部落格園/github客戶端, 安
卓真的是我一直想求而不得, 學校選課移動開發(Android), 我連選三個學期, 前兩個學期直接課程衝了, 
第三個學期我能選的那個時間課程只有5個人選, 被撤銷了, 我已經哭了, 學分已經修滿了,以後大概率是
不會再選這課的, 因為時間該實習了 , 後面Android系列打算用Kotlin實現, 一些很前衛的巨頭就是Kotlin 
first(據說位元組就是), Kotlin的函式式, 協程以及空安全是更先進更舒服的, 當然個人也簡單瞭解過一點
flutter, 所以到時候傾向於用Kotlin或者Flutter去完成這個專案

    更新的系列可能與複習同步, 當做複習練手, 但也可能暫時不會更新了, 要看我個人的複習進度, 我覺得
大概率是鴿, 不過大家放心, 過了春招這段時間我會繼續堅持分享個人一些有趣的, 比較精心打磨, 完成度較好
的專案的

    非常感謝一些老讀者的陪伴(雖然應該只有個位數的幾位朋友), 是你們的堅持閱讀才讓我堅持寫完了這個系列,
 也督促我不能每天都起碼更個新, 不水大家

    也非常感謝您能忍受我一直以來自己都覺得醜的文筆和敘述, 哈哈, 您有什麼寶貴意見都可以留言,
我會盡我所能改正或者提供支援
    
    所以專案就將時停止更新了, 讓我們有緣再會!
致親愛的讀者:

    個人的文字組織和寫文章的功底屬實一般, 寫的也比較趕時間, 所以系列文章的文字可能比較粗糙,
難免有詞不達意或者寫的很迷惑抽象的地方 

    如果您看了有疑問或者覺得我寫的實在亂七八糟, 這個很抱歉, 確實是我的問題, 您如果有不懂的地方
的地方或者發現我的錯誤(文字錯誤, 邏輯錯誤或者知識點錯誤都有可能), 可以直接留言, 我看到都會回覆您!

系列食用方法建議

    由於時間原因, 目前測試並不完善, 所以推薦如下方式根據您的目的進行閱讀

    如果您是學習用, 建議您先將整個專案clone到本地, 然後把感興趣的章節刪除, 自己重寫對照著重寫
    書寫完每一步測試一下能否正常執行(在指定的路徑去讀取原始碼測試能否編譯成功並在命令列執行

    java Application(類名)

嘗試能否輸出期望結果, 我沒有研究Junit對編譯器輸出class檔案進行測試, 所以目前可能需要您手動測試)

    按照以上步驟, 等您將所有模組重寫一遍, 大概也對這個系列的脈絡有深刻理解了! 如果您重頭開始重寫, 
往往可能由於出現某些低階錯誤導致長時間debug才找得到錯誤, 所以對於初學者, 推薦採用自己補寫替換模組的
方式

    對於希望貢獻程式碼的朋友或者對Cva感興趣的朋友, 歡迎貢獻您的原始碼與見解, 或者對於該系列一些錯誤/
bug願意提出指正的朋友, 您可以留言或者在github發issue, 我看到後一定及時處理!

本節提綱

  1. 引言-編譯器前端的尾聲

  2. BST(Backend Abstract Syntax Tree)

  3. Translator

  4. JVM指令

    4.1. 我們使用的指令介紹

  5. Jasmin 彙編器生成 .class File

引言-編譯器前端的尾聲

在該步之前還應有編譯期優化, 但是由於時間原因沒有完善, debug時也一直沒有開優化, 所以這部分以後有緣再
補充

程式碼生成是編譯器前端的最後一步了, Javac編譯器的編譯工作到這一步, 便是將Java原始碼編譯成JVM .class
位元組碼檔案, 而我們這個階段也是如此, 其實當我們拿到抽象語法樹, 可以玩一點花的, 可以將Cva位元組碼編譯到
Java, Csharp, C語言甚至js等等

這是因為我們拿到的抽象語法樹已經是一棵可執行的樹了, 我們得到的原始碼文字轉換得到的Program POJO是一個
有靈魂的POJO, 他能做的事就是你寫程式碼時所表達的那些事, 當然, 我們也可以為其開發一個直譯器, 執行這課
語法樹(當這個直譯器的構造與計算機類似, 擴充了許多與作業系統類似的功能如垃圾回收/JIT時, 其也可以叫做
虛擬機器), 當然, 這裡我們並不教大家如何去做這些工作, 如果你有興趣可以嘗試做一下, 我們這裡就做一些大家
喜聞樂見的, 常規的編譯器做法, 生成可執行的指令

如果是直接面向機器的編譯器, 那麼這個時候往往會生成指定平臺的彙編(8086彙編, x86彙編)再進行接下來一步
的生成, 我們這裡並不是直接生成JVM位元組碼(JVM平臺 .class檔案), 而是先生成JVM平臺中間語言(Intermediate
Language)JVM彙編指令, 再由彙編器(由中間語言/組合語言生成機器碼, 這個過程基本就是對映關係, 所以彙編指
令也叫助記符), 我們這裡其實也是面向機器, 不過這臺機器是一個特殊的JVM虛擬機器, JVM虛擬機器其實也有著自己的
一套匯編指令集及規範

當然, 在編譯器後端, 有著更為豐富精彩的世界, 比如執行時的編譯, JIT, AOT等等, 還有著無底洞般的優化, 希望
以後有機會能給大家展現這些東西

同時, 這一節由於時間原因, 和多次重構的歷史原因, 程式碼結構相對有一點亂, 個人將梳理講解值得一些注意的部分

BST(Backend Abstract Syntax Tree)

cn.misection.cva.ast 是表示我們前端Cva源程式的抽象語法樹, 但是這個東西表示的畢竟是我們自己定義的
Cva程式, 而不是我們的目標平臺JVM可以識別的程式, 或者說, 跟我們目標有差異, 我們不可能直接將這棵語法樹
用來在JVM平臺執行或者編譯到JVM上(當然我們可以針對這種程式結構開發直譯器, 讓其在我們的直譯器/虛擬機器上執行)

後端抽象語法樹, 說是後端語法樹, 其實過程還是屬於前端, 只不過這裡更接近虛擬機器後端, 而且我們的
語法樹由前端的樹形被我們翻譯成了後端的線性指令形式, 語法結構劇變, 所以我們需要一個 新的BST作為從AST
到指令的中間表示. BST主要關注點在Statement和Expression方面

Translator

說道 Translator, 顧名思義, 大家應該也能理解它的作用, 這是一個翻譯官, 能將我們前端的ast翻譯成後端的bst,
將樹狀的Cva程式翻譯成線性的JVM指令, 他接受一個前端傳入的CvaProgram, 生成一個後端的TargetProgram, 然
後交由IntermLangGenerator寫入.il中間語言檔案, 最後再交由Cvac 編譯器的main方法靜態呼叫jasmin的主方法
生成我們的.class位元組碼, 我們的編譯器工作就完成啦!

其實現是impl後端的visitor, 這裡使用到了(偽)訪問者模式, 是一個設計的不成功的訪問者模式, 大家看看就好,
我以後有更好的方案會重構他

JVM指令

由於JVM是基於棧式計算機概念的虛擬機器, 以JVM為目標平臺的程式碼生成較為簡單, 不必考慮暫存器分配等問題; 同時JVM
還有著豐富的指令

我們的語言由於支援的型別很有限, 支援的基本型別只有intboolean(其實這倆都是int), 其他型別暫時沒有過多
處理, 以及涉及到基本型別之間互相轉換的操作指令可以不用考慮,我們支援的比較也比較少, 因此跳轉指令可以只考慮部分,
由於不存在一些特殊型別或者操作符, 比如interface abstract instanceof等等複雜的指令

此外, JVM規範中有:

Java Virtual Machine Specification - 2.3.4 The boolean type

Although the Java Virtual Machine defines a boolean type, it only provides
very limited support for it. There are no Java Virtual Machine instructions solely
dedicated to operations on boolean values. Instead, expressions in the Java
programming language that operate on boolean values are compiled to use values
of the Java Virtual Machine int data type.

其實後端是沒有boolean的, 直接用int 型 0 1 表示即可, 這也是為啥C語言只有0和非0的原因了,
感覺我們似乎越來越接近世界的真相了, 哈哈

我們使用的指令介紹

按照JVM規範, JVM支援共計大約150個指令, 我們用到的指令僅僅是相當小的一個子集, 先給出我們使用的指令

aload
areturn
astore
getfield
goto <label>

iinc

iadd
isub
imul
idiv
iand
ior
ixor
irem
ishl
ishr
iushr

if_icmplt <label>
iload
invokespecial
invokevirtual
ireturn
istore
ldc
new
putfield

// 未來需要支援的
anewarray(陣列操作)

簡單地解釋一下這些指令

我們可以把一條指令分成兩部分看, 比如 iadd, 其實在JVM指令的語義中, 這條指令反映了兩個資訊 i 和add,
i 其實就是int的意思, add 是指將棧頂的兩個運算元相加, 所以這條指令的意思就是將棧頂的兩個 int 型操作
數相加, 理解了這條指令, 其他指令也就不難了, 他們都只是這樣一個個簡單的容易理解的對映關係

其他的資料型別如byte, 在虛擬機器指令層面就是b(作為運算元操作層面), 都在下面的EnumOperandType中
當然, 這些指令是怎麼得到的呢, 可以選擇看書, 如果沒有書籍或者課程資源怎麼辦呢, 除了去網上搗鼓電子書
之外, 我們也可以利用現成的JDK提供給我們的工具javap

授人以魚不如授人以漁, 我們這裡提供一個理解底層操作的思路

比如說, 你想知道long型的左移操作底層是使用哪條指令
可以寫一個LongLS.java

/**
* LongLS.java
*/
class LongLS
{
   public static void main(String[] args)
   {
       // 也可以放在方法中;
       // 一次多放幾條, 能事半功倍檢視原理
       long aLong = 1;
       long anotherLong = aLong << 1;
   }
}

然後在命令列執行

javac LongLS.java

然後

javap -c LongLS.class

可以得到

Compiled from "LongLS.java"
class LongLS {
  LongLS();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: lconst_1
       1: lstore_1
       2: lload_1
       3: iconst_1
       4: lshl
       5: lstore_3
       6: return
}

如果希望得到更詳細的資訊, 可以使用-v引數

javap -v LongLS.class

得到

Classfile /***/batTest/javap/demo/LongLS.class
  Last modified 2021-3-4; size 271 bytes
  MD5 checksum cc920428a4ba6c860e039dfd79252c53
  Compiled from "LongLS.java"
class LongLS
  minor version: 0
  major version: 52
  flags: ACC_SUPER
Constant pool:
   #1 = Methodref          #3.#12         // java/lang/Object."<init>":()V
   #2 = Class              #13            // LongLS
   #3 = Class              #14            // java/lang/Object
   #4 = Utf8               <init>
   #5 = Utf8               ()V
   #6 = Utf8               Code
   #7 = Utf8               LineNumberTable
   #8 = Utf8               main
   #9 = Utf8               ([Ljava/lang/String;)V
  #10 = Utf8               SourceFile
  #11 = Utf8               LongLS.java
  #12 = NameAndType        #4:#5          // "<init>":()V
  #13 = Utf8               LongLS
  #14 = Utf8               java/lang/Object
{
  LongLS();
    descriptor: ()V
    flags:
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 1: 0

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=3, locals=5, args_size=1
         0: lconst_1
         1: lstore_1
         2: lload_1
         3: iconst_1
         4: lshl
         5: lstore_3
         6: return
      LineNumberTable:
        line 6: 0
        line 7: 2
        line 8: 6
}
SourceFile: "LongLS.java"

這樣我們就得到了long型左移操作指令應該是 lshl, long型進行操作時的助記符應該為 l

同理, 我們希望獲得基本型別在方法簽名中的表示時, 或者希望深入研究檢視某個方法的具體細節,
看某些操作是否是原子性的時候, 看有些操作是否執行緒安全的時候, 也可以使用這個辦法去反彙編
class檔案

下面便是使用橋接模式橋接運算元和操作符的兩個列舉(當然, 我們這個橋接模式並沒有用實體類將其組合起來(因為
這些命令是不會有變數的的, 建立物件會浪費資源), 直接在translator中壓入命令佇列(用的是list))

package cn.misection.cvac.codegen.bst.instructor.operand;

import cn.misection.cvac.codegen.bst.instructor.IInstructor;
import cn.misection.cvac.codegen.bst.instructor.Instructable;

/**
 * @author Military Intelligence 6 root
 * @version 1.0.0
 * @ClassName EnumTargetOperand
 * @Description 橋接模式底層運算元;
 * @CreateTime 2021年02月21日 22:23:00
 */
public enum EnumOperandType implements IInstructor, Instructable
{
    /**
     * 底層運算元型別;
     */
    VOID(""),

    BYTE("b"),

    SHORT("s"),

    CHAR("c"),

    INT("i"),

    LONG("l"),

    FLOAT("f"),

    DOUBLE("d"),

    REFERENCE("a"),
    ;

    private final String typeInst;

    EnumOperandType(String typeInst)
    {
        this.typeInst = typeInst;
    }


    @Override
    public String toInst()
    {
        return typeInst;
    }
}


package cn.misection.cvac.codegen.bst.instructor.operand;

import cn.misection.cvac.codegen.bst.instructor.IInstructor;
import cn.misection.cvac.codegen.bst.instructor.Instructable;

/**
 * @author Military Intelligence 6 root
 * @version 1.0.0
 * @ClassName EnumOperator
 * @Description 橋接模式底層操作符;
 * @CreateTime 2021年02月21日 22:24:00
 */
public enum EnumOperator implements IInstructor, Instructable
{
    /**
     * 底層操作符;
     */

    ADD("add"),

    SUB("sub"),

    MUL("mul"),

    DIV("div"),

    /**
     * 求餘;
     */
    /*
     * neg 其實不應該出現, 其是一元的;
     */
    BIT_NEG("neg"),

    REM("rem"),

    BIT_AND("and"),

    BIT_OR("or"),

    BIT_XOR("xor"),

    LEFT_SHIFT("shl"),

    RIGHT_SHIFT("shr"),

    /**
     * 無符號右移;
     */
    UNSIGNED_RIGHT_SHIFT("ushr"),

    RETURN("return"),
    ;
    
    private final String opInst;

    EnumOperator(String opInst)
    {
        this.opInst = opInst;
    }

    @Override
    public String toInst()
    {
        return opInst;
    }
}

他們都實現介面 Instructable, 表示能將該元素轉換成JVM彙編指令

@FunctionalInterface
public interface Instructable
{
    /**
     * 獲得該型別指令;
     * @return instruct;
     */
    String toInst();
}

同時他們實現 IInstructor 介面
這個介面是為了能在假的訪問者模式(這個訪問者模式一個失敗的訪問者模式)有一個統一的傳入父介面

我們使用到的指令是相當精簡的. 但是, 以下兩條擴充套件"指令"值得我們注意.

label

這條"指令"的存在, 是交由下文提到的Jasmin處理

// 不是寫入il檔案的write, 而是標準輸出流列印指令;
write

這條"指令"是我們純粹為了程式設計簡單而擴充套件的. 考慮到我們的語言的特點, 將他們作為指令對待, 能夠大大簡化我們程式設計處理的複雜度, 而且也不會造成任何的副作用, 因為之前幾個階段的分析已經保證了源程式的合法. 這樣用不會有問題, 因為後面我們會將擴充套件的指令翻譯成jvm指令.

Code Translation

有了上文的"完善"的指令集(對於我們這個程式來說), 我們接下來的工作便是將樹狀的AST轉換成線性的指令. 這裡的轉換主要關注的方法中的真正"幹活"的程式碼, 就是方法體內部的工作程式碼. 下面通過一個例子展示具體工作.

class Recursor
{
    int compute(int num)
    {
        int total;
        if ( num < 1)
        {
            total = 1;
        }
        else
        {
            total = num * (this.compute(num - 1));
        }
        return total;
    }
}

/**
 * This is the entry point of the program
 */
int main(string[] args)
{
    // fib(10);
    println new Recursor().compute(10);   
    return 0;
}

觀察以上一段程式碼, 我們主要關注compute方法編譯出的指令, 而且給出了較詳細的註釋.

.method public Compute(I)I ; 方法簽名傳入參int, 返回int;
.limit stack 4096 ; 棧呼叫深度, 由於目前還沒有實現該演算法, 因此編譯結果給出預設值 4096
.limit locals 4 ; 本地變數的編號一共4個, 後面的iload 1 往往是 iload var1而不是常量1
    ; num < 1 對於if語句中的判別式進行計算
    iload 1     ; 從本地變數表中載入變數1的值(num)到棧頂
    ldc 1       ; ldc 即 load const將整型數字1壓入棧
                ; 事實上, 我們需要完成的一個TODO就是優化這些常數的載入
                ; JVM對於常量的載入
                ; 取值 -1~5 採用 iconst 指令, -1是iconst_m1, 其他則如iconst_1;
                ; 取值 -128~127 採用 bipush 指令(byte取值區間, 下同
                ; byte num >= Math.pow(2, 8) && num < Math.pow(2, 8));
                ; 取值 -32768~32767 採用 sipush指令;
                ; 取值 -2147483648~2147483647 採用 ldc 指令;

    if_icmplt Label_2 ;比較兩個值, 如果第一個值(num)小於整數1, 跳轉至Label_2
    ldc 0       ; 將整數0壓入棧(用於表示比較結果為false)
    goto Label_3 ; 否則跳轉到label_3
Label_2:
    ldc 1       ; 將整數1壓入棧(用於表示比較結果為真)
Label_3:        ; 判別式計算完成
    ldc 1
    if_icmplt Label_0 ; 對於求值真假進行計算
    ldc 1       ; 將整數1壓入棧
    istore 2    ; 將棧上的數字存入本地變數2
    goto Label_1
Label_0:
    iload 1     ; 從本地變數表中載入變數1的值 (num)
    aload 0     ; 從本地變數表中載入變數0的值 (this)
    iload 1
    ldc 1
    isub
    invokevirtual FibCalcer/Compute(I)I ; 呼叫例項方法(在指令引數處指出了方法的從屬及簽名)
    imul        ; 棧頂兩個int型相乘, 並將結果壓入棧頂
    istore 2
Label_1:
    iload 2     ; 從本地變數表中加贊變數2的值
    ireturn     ; 從方法返回. 
.end method

; main 方法
.class public Application
.super java/lang/Object
.method public static main([Ljava/lang/String;)V ; main方法返回V, void, Cva中的int只是致敬C語言;
.limit stack 4096
.limit locals 2
    ldc "fib(10) is " ; 載入字串常量;
    getstatic java/lang/System/out Ljava/io/PrintStream; ; 列印的System.out 放到棧頂
    swap ; 由於列印必須要被列印者在棧頂, PrintStream在其下, 所以交換, 以後考慮優化;
    invokevirtual java/io/PrintStream/print(Ljava/lang/String;)V ; 呼叫虛方法
    ; 對於 private 方法和構造方法<init> 都是invokespecial呼叫
    ; invokevirtual 呼叫例項方法, 包括父類方法, 抽象類的抽象方法實現;
    ; invokeinterfacre 呼叫介面的抽象方法
    ; invokestatic 呼叫類方法
    ; invokedynamic Java7之後才有的動態方法呼叫指令
    new Recursor ; 建立物件;
    dup ; dup指令為複製運算元棧頂值,並將其壓入棧頂,也就是說此時運算元棧上有連續相同的兩個物件地址;
        ; 這是因為一會有出棧操作, 保留一個副本;
    invokespecial Recursor/<init>()V
    ldc 10
    invokevirtual Recursor/compute(I)I
    getstatic java/lang/System/out Ljava/io/PrintStream;
    swap
    invokevirtual java/io/PrintStream/println(I)V
    return ; 相當於void return, 這個指令只能用於void
           ; 如果要返回 int, 像上面ireturn
           ; 如果是引用型別 則是areturn;
.end method

Jasmin 彙編器生成 .class File

最後的工作就是從字元形式的指令, 由彙編器轉換成二進位制形式的.class檔案, 用於jvm的執行,
這個彙編器是一個現成的工具, 這個工具其實也是一個命令列應用, 如果使用指令碼去粘合我們將面臨
效能問題和不少通訊上的問題, 我們的方法是在cvac的main方法中靜態呼叫這個彙編器的main方法
直接傳入路徑作為其命令列引數
Jasmin.

相關文章