[Golang實現JVM第三篇] 解釋器雛形

在上一篇中我們已經完成了class文件的解析工作,雖然沒有解析所有的屬性,但是已經足夠支持一些基本的算法題Java代碼編譯生成的class文件了。有了這一步,日後如果遇到新的特性需要支持,只需缺哪補哪,補上對應屬性的解析邏輯就可以了。下一步就是實現一個基本的執行引擎,即解釋器,並且支持基本的棧操作相關的指令,比如iconst_x, istore_x, bipush等。

基於棧的指令集和基於寄存器的指令集

JVM字節碼是一套基於棧的指令集,也就是說操作數棧是一切計算的基本容器,大部分指令都是圍繞着操作數棧展開的。相對應的還有一種基於寄存器的指令集,這種指令集的特點是指令中就攜帶寄存器地址,對寄存器進行操作,優點指令短小精悍,執行效率高,缺點是會依賴於特定的硬件,可移植性差。棧指令集的優缺點則剛好相反,因爲棧是一種抽象數據結構而不是具體的硬件設施,因此可移植性強,但是指令的數量往往比較臃腫,執行效率相對較低。

舉個例子,計算1+1,用Javac編譯後的對應字節碼是這樣的:

iconst_1
iconst_1
iadd
istore_0

其中前兩條iconst_1表示將整數1壓入操作數棧,這時候操作數棧就有兩個1了。iadd指令則表示從操作數棧中連續出棧兩次,將值相加,最後再把結果壓入棧中。istore_0則表示將棧頂元素(計算結果2)出棧,存入本地變量表索引爲0的槽位中。可以看到,明明是很簡單的計算,卻多出了很多壓棧出棧操作。

如果是基於寄存器的指令集,那一般會長這樣:

mov exa,1
add exa,1

即先將1存入exa寄存器,然後直接把寄存器中的值+1完成計算,指令數量少了很多。

不過並不是說JVM的字節碼一定就會比寄存器指令慢,畢竟JVM 中有大量的優化,字節碼可能會被省略、被亂序執行,或者直接被JIT編譯成本地語言,也就是基於寄存器的指令了。當然,優化並不在我們實現JVM的目標範圍內。

解釋器實現思路

想實現JVM的朋友應該都對JVM的基本構成有了解了,什麼方法區、堆區、方法棧等等。還是那句話,千萬不要一上來就考慮這麼複雜的因素,這樣只會掉坑裏爬不出來,正確的方法是先實現一個最簡單的能跑的例子,然後再根據真實的JVM結構慢慢擴充。比如,對於字節碼iconst_1來說,他的含義就是將整數1壓如操作數棧,那麼實現一個棧,遇到這條指令就壓棧不就完事了嗎?什麼方法棧、堆區、類加載器、垃圾回收、線程調度,現階段通通都不要考慮,隨着字節碼越來越複雜,這些總會有的。

一個解釋器應該具備的最基本的要素,就兩條,一是死循環,二是指向下一條指令的程序計數器(Program Counter, 簡稱PC),golang僞代碼如下:

pc := 0
for {
  byteCode := code[pc] // 取出pc指向的指令
  execute(byteCode, &pc) // 執行指令,同時傳入PC的指針,因爲執行的過程可能需要修改pc的值
  if 結束? {
    break
  }
}

我們可以先定義一個結構體MiniJvm來表示一個JVM:

// VM定義
type MiniJvm struct {
	// 方法區
	MethodArea *MethodArea

	// MainClass全限定性名
	MainClass string

	// 執行引擎
	ExecutionEngine ExecutionEngine

	// 本地方法表
	NativeMethodTable *NativeMethodTable

	// 保存調用print的歷史記錄, 單元測試用
	DebugPrintHistory []interface{}
}

這裏有很多現階段用不到的字段,忽略即可,等解釋到對應的字節碼以後再回頭加上;

然後我們定義出執行引擎接口,爲啥用接口呢,因爲現在是解釋的,萬一以後牛逼了想搞個編譯的呢?

type ExecutionEngine interface {
	Execute(file *class.DefFile, methodName string) error
}

想想JVM在運行時都需要指定一個主類,所以第一個參數可以是主類的class定義,DefFile類型這個在上一篇解析class文件中就已經定義過了,裏面包含一個類的全部信息。methodName指定要從哪個方法開始執行,爲了簡單起見,直接傳入方法的簡單名。

然後就可以定義一個執行引擎的具體實現了:

// 解釋執行引擎
type InterpretedExecutionEngine struct {
	miniJvm *MiniJvm
}

好了,現在就可以開始解釋字節碼了!

解釋字節碼

直接解釋字節碼,很多人可能會問,符號引用解析了嗎?方法描述符解析了嗎?訪問權限驗證(public, private)做了嗎?方法棧哪去了?本地變量表還沒有呢?這些一堆的問題。但是,這些邊邊角角的東西對我們的MiniJvm來說,現在都還不重要。還是那句話,如果過早的陷入到繁雜的細節中就會失去對問題核心的把控。所以,接下來要做的就是:

  • 遍歷DefClass結構體的Methods字段
  • 根據傳入的方法名,找到目標方法
  • 取出code屬性
  • 遍歷字節碼,解釋執行

搜索方法的簡化代碼如下(完整代碼可參考https://github.com/wanghongfei/mini-jvm/blob/master/vm/interpreted_execution_engine.go):

// 查找方法定義;
// def: 當前class定義
// methodName: 目標方法簡單名
// methodDescriptor: 目標方法描述符
func (i *InterpretedExecutionEngine) findMethod(def *class.DefFile, methodName string, methodDescriptor string) (*class.MethodInfo, error) {
	currentClassDef := def
	for {
		for _, method := range currentClassDef.Methods {
			name := def.ConstPool[method.NameIndex].(*class.Utf8InfoConst).String()
			descriptor := def.ConstPool[method.DescriptorIndex].(*class.Utf8InfoConst).String()
			// 匹配簡單名和描述符
			if name == methodName && descriptor == methodDescriptor {
				return method, nil
			}
		}

		// 從父類中尋找
		// ... 省略
    
		// 取出父類全名
		// .. 省略

		// 加載父類
		// .. 省略

		currentClassDef = parentDef
	}


	return nil, fmt.Errorf("method '%s' not found", methodName)
}

忽略方法描述符參數,最最基本的邏輯其實就是遍歷數組、從常量池中取出方法名、判斷是否跟目標名稱匹配、返回。

找到方法後就可以提取字節碼了:

func (i *InterpretedExecutionEngine) findCodeAttr(method *class.MethodInfo) (*class.CodeAttr, error) {
	for _, attrGeneric := range method.Attrs {
		attr, ok := attrGeneric.(*class.CodeAttr)
		if ok {
			return attr, nil
		}
	}

	// native方法沒有code屬性
	return nil, nil
}

這個方法返回一個CodeAddr類型的指針,這個類型的定義在上一篇解析class文件中就定義好了,可能文章裏沒有,但是github項目裏有,結構如下:

// code屬性
type CodeAttr struct {
	AttrLength uint32

	MaxStack uint16
	MaxLocals uint16

	// 字節碼長度
	CodeLength uint32
	Code []byte

	// 異常表
	ExceptionTableLength uint16
	ExceptionTable []*ExceptionTable

	AttrCount uint16
	Attrs []interface{}
}

可以明顯的看到,Code字段就是我們要找的字節碼了!

接下的要做的就更簡單了,遍歷,執行:

for {
		// 取出pc指向的字節碼
		byteCode := codeAttr.Code[frame.pc]

		exitLoop := false

		// 執行
		switch byteCode {
		case bcode.Iconst0:
			// 將0壓棧
			frame.opStack.Push(0)

		default:
			return fmt.Errorf("unsupported byte code %s", hex.EncodeToString([]byte{byteCode}))
		}

		if exitLoop {
			break
		}

		// 移動程序計數器
		frame.pc++
	}

	return nil

由於從code中讀取出來的是byte類型,所以我們需要定義一下每一個byte對應哪條指令,例如:

package bcode

const (
	Nop byte = 0x00

	Iconst0 = 0x03
	// .. 省略 ..
)

這樣就可以用switch case非常直觀的處理字節碼了。

對於icons_0這條指令,是要我們向操作數棧中壓如一個整數0,所以,還需要一個棧:

// 操作數棧
type OpStack struct {
	elems []interface{}

	// 永遠指向棧頂元素
	topIndex int
}


func NewOpStack(maxDepth int) *OpStack {
	return &OpStack{
		elems:        make([]interface{}, maxDepth),
		topIndex:    -1,
	}
}

完整代碼可以參考: https://github.com/wanghongfei/mini-jvm/blob/master/vm/op_stack.go

有了棧就可以解釋這條指令了:

frame.opStack.Push(0)

注意frameMethodStackFrame類型的指針,表示一個方法棧的棧幀(現階段可以不實現,直接操作棧即可),裏面有程序計數器和本地變量表,完整定義可參考https://github.com/wanghongfei/mini-jvm/blob/master/vm/method_stack.go

至此,我們的Mini-JVM已經完成了第一條字節碼指令的解釋,算是邁出了萬里長征第二步。完成第一條指令的解釋後,我們就可以照葫蘆畫瓢,解釋第二、第三條,當發現缺少執行這條指令的基礎設施時再去實現這些設施,而不是一開始就想太多。

實際上當嚴格按照規範完成全部200多條字節碼的解釋後,JVM就基本完工了。雖然後面的指令會越來越複雜,解釋所需要做的工作也越來越多,但是我們可以把支持的字節碼數量當做衡量JVM進展的里程碑,相當於把一個天文工程劃分成了200多個小步,這樣寫起來就能及時看到成果,也很有意思,不是嗎?

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章