[Golang實現JVM第二篇]解析class文件是萬里長征第一步

正確解析class文件是萬里長征第一步。本篇我們會全程使用golang完成class文件的解析工作。

數據類型

JVM的class文件完全是二進制文件,最小單位是字節,也有數據類型,但都是字節的整數倍(廢話)。規範中class文件一共有兩類數據,一種是無符號整數,一種是表。無符號整數一共有u1,u2, u4, u8四種類型,分別表示8bit, 16bit, 32bit, 64bit的無符號整數。表則是無符號整數的集合,class文件中在出現表之前都會先跟着一個u2類型的長度數據,表名後面表的總長度,這樣才能正確解析表。

另外還要注意字節序的問題,JVM規範規定class文件統一採用Big Endian字節序,也就是低地址存儲高位,高地址存放低位。如果是用C/C++語言寫JVM,則程序使用的字節序是跟CPU綁定的,比如intel的x86平臺使用Little Endian,PowerPC則是Big Endian。不過幸好我們的主角是Go, Go統一採用大端,這樣就不需要操心平臺了。假設我們用一個二元素的[]byte數組來存儲從class文件中按順序讀到的u16類型數據,那麼byte[0]就是u16的高8位,byte[1]就是低8位,組合起來就是:

uint16(b[1]) | uint16(b[0]) << 8

即將高位左移8位,然後跟低位做按位或操作即可還原。

Go讀取二進制數據常用函數

我們使用標準庫的io.Reader接口從文件中讀取字節,然後從字節數組中還原原本的數據類型,例如讀取u16類型的數據可以這麼寫:

func ReadInt16(bufReader io.Reader) (uint16, error) {
	numBuf := make([]byte, 2, 2)
	_, err := bufReader.Read(numBuf)
	if nil != err {
		return 0, err
	}

	var num uint16
	err = binary.Read(bytes.NewBuffer(numBuf), binary.BigEndian, &num)
	if nil != err {
		return 0, err
	}

	return num, nil
}

這裏我們用了binary包替我們執行的位運算,但是這個方法會涉及類型查詢操作和內存分配,所以肯定會比直接手動組裝byte要慢一些,但是上篇就已經說了,過早優化是萬惡之源,不必在意。

同理,u32的讀取可以這麼寫:

func ReadInt32(bufReader io.Reader) (uint32, error) {
	numBuf := make([]byte, 4, 4)
	_, err := bufReader.Read(numBuf)
	if nil != err {
		return 0, err
	}

	var num uint32
	err = binary.Read(bytes.NewBuffer(numBuf), binary.BigEndian, &num)
	if nil != err {
		return 0, err
	}

	return num, nil

如果是讀取u8,那直接讀一個byte返回就可以了:

func ReadInt8(bufReader io.Reader) (uint8, error) {
	numBuf := make([]byte, 1, 1)
	_, err := bufReader.Read(numBuf)
	if nil != err {
		return 0, err
	}

	return numBuf[0], nil
}

至此,我們已經排除了讀取class文件的全部"技術障礙"。

class文件結構

我們先用Go定義出一個class文件的完整結構:

// class文件定義
type DefFile struct {
	MagicNumber uint32

	MinorVersion uint16
	MajorVersion uint16

	// 常量池數量
	ConstPoolCount uint16
	// 常量池
	ConstPool []interface{}

	// 訪問標記
	AccessFlag uint16
	// 當前類在常量池的索引
	ThisClass uint16
	// 父類索引
	SuperClass uint16

	// 接口
	InterfacesCount uint16
	Interfaces []uint16

	// 字段
	FieldsCount uint16
	Fields []*FieldInfo

	// 方法
	MethodCount uint16
	Methods []*MethodInfo

	// 屬性
	AttrCount uint16
	Attrs []interface{}
}

我們一個個的來看。

  • MagicNumber, MajorVersion, MinorVersion

上來就是一個標識文件類型的魔術數,就是那個有名的“咖啡寶貝” 0xCAFEBABE。然後是主版本號、副版本號。這些沒啥好說的。

  • ConstPoolCount, ConstPool

這是整個 class文件最重要的部分,常量池。對是常量池,並不是字節碼。先是一個16位無符號整數表示常量池數據項的數量,然後就是常量池數組。 所有的符號引用和字面值(如字符串, 整數)都保存在常量池中,所有其他屬性都通過保存常量池數組下標的方式來記錄自己引用了哪一條數據。要注意的一點是常量池數組的下標是從1開始填充數據的,下標爲0的位置不保存任何數據項,這是爲了方便表達"不指向任何一個常量"的含義。比如ConstPoolCount = 10的話,則ConstPool數組有11個元素,下標從1開始,直到11爲止。

常量池數據項有十幾種種類型,隨着JDK版本的增加往往會有新的類型加入。每種類型的結構都不太樣,但是都遵循先是一個uint8類型的tag用來表示數據項類型,然後是常量池數據的結構,例如方法引用項(CONSTANT_Methodref):

// 方法引用常量
type MethodRefConstInfo struct {
	Tag uint8
	ClassIndex uint16
	NameAndTypeIndex uint16
}

Tag: 固定爲10, 表示這是一條方法引用數據項

ClassIndex: 是一個常量池的數組下標,引用的是一條類引用(CONSTANT_Class)類型的數據項,用來記錄方法屬於哪個類。

NameAndTypeIndex: 同樣是常量池的數組下標,引用的是一個NameAndType類型,用來記錄方法名、方面描述符號,而方法描述符中記錄了方法的參數類型和返回值類型。

這裏就是單拿一個例子來舉例,在Java8中完整的常量池類型和結構可以直接參考Oralce的JVM規範在線文檔:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-4.html ,就不再一一列舉了。因爲非常繁瑣,這裏列出來解釋了也是雲裏霧裏,意義不大,後面在解釋字節碼引用到常量池的時候再解釋含義。要注意的是我們並不需要實現全部常量池類型,只需要實現你的class文件中存在的常用類型即可。具體操作方法在上一篇中提到過,自己寫一個簡單的java文件,編譯,然後用javap -verbose查看。

  • AccessFlag

訪問標記,即當前類是public, abstract, 還是final, interface等。注意每個標記不是通過單獨的取值存儲的,而是通過一個二進制位來標記。例如0x0001表示public, 0x0010表示final,解析的時候需要遍歷每一個位,通過判斷是否爲1來決定是否帶有此標記。

完整的標記位取值如下:

const (
	Public = 0x0001
	Private = 0x0002
	Protected = 0x0004
	Static = 0x0008
	Final = 0x0010
	Synchronized = 0x0020
	Bridge = 0x0040
	Varargs = 0x0080
	Native = 0x0100
	Abstarct = 0x0400
	Strict = 0x0800
	Synthetic = 0x1000
)
  • ThisClass, SuperClass

分別表示當前類和父類在常量池中的索引。前者用於確定當前類的全限定性名,後者用於確定父類的全限定性名。在JVM中,給定一個類的全限定性名就可以從classpath中找出這個類的class文件,繼而執行加載邏輯。

  • InterfacesCount, Interfaces

因爲Java類允許同時實現多個接口,因此這裏在記錄實現了那些接口時就必須用一個數組來記錄了。同樣的,先是一個count表示有多少數據項,然後是數據表本身。

  • FieldsCount, Fields

跟接口一樣,用於記錄當前類級別的字段和實例級別的字段。在Fields的每個數據項中又記錄了實例名、類型、修飾符(如private, final)信息。

  • MethodCount, Methods

用於記錄方法信息。同理,每一個Methods數據項都會詳細記錄方法的所有屬性。

  • AttrCount, Attrs

屬性表集合,用於記錄一些附加信息。注意屬性表可以出現在class裏,也可以在method, field中出現,出現在哪就表名記錄的是哪一個層級的屬性。屬性表跟常量池一樣,每個數據項都有不同的類型,而且截至Java12,數據項的類型數量已經高達29種,可以說非常複雜了。每中數據項都遵循着先是一個屬性名,再跟一個屬性數據的長度(以字節爲單位),然後是屬性本身。我們常說的字節碼,就是保存在Method中的Code屬性裏的,定義如下:

// code屬性
type CodeAttr struct {
  AttrNameIndex uint16
	AttrLength uint32

	MaxStack uint16
	MaxLocals uint16

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

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

	AttrCount uint16
	Attrs []interface{}
}

注意第一個字段,AttrNameIndex是一個16位的無符號整數,保存的是一個常量池數組下標,而下標所保存的常量池數據項類型就是一個UTF8字符串,在這裏就是Code這個固定值。

下面的幾項分別保存了操作數棧最大深度、本地變量表最大長度、字節碼長度、字節碼本身、異常信息,另外最後還有屬性信息,套娃。我們以後實現解釋器主要就是要找到method中的Code屬性的Code字段,然後一條條的解釋字節碼。

以上就是class文件結構的全部內容了,說實在的,非常複雜,解析的時候也會比較痛苦。但還是那句話,不需要全部都解析出來,只需要解析需要的那部分即可。對於每一個具體的數據類型的含義,在後面實現解釋器時用到了再解釋,這裏不羅列了。筆者已經實現了對class文件的解析邏輯,可以參考下面的地址:https://github.com/wanghongfei/mini-jvm/blob/master/vm/class/jclass.go

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