[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


发表评论

电子邮件地址不会被公开。 必填项已用*标注