写自己的JVM(0x4)- 实现一个解释器

​原始博客的地址:

lyldalek.notion.site/JVM-0x4-8a7...

该项目的地址:

github.com/aprz512/wri...

上一篇文章,我们主要是实现了虚拟机栈,知道了方法执行的一个大致的流程。在这一篇文章中我们就要深入到方法的指令,实现指令的解析,以及实现一个简单的解释器。

解释器也是也是编译器的一种,java文件编译成class文件是前端编译器完成的,java程序在运行的时候对字节码的执行使用的是解释器,也叫后端编译器。

指令结构介绍

通常将指令分为两部分:

  • 操作符
  • 操作数

每条指令都以一个单字节的操作码(opcode)开头,这意味着Java虚拟机最多只能支持256条指令。

Java虚拟机规范给每个操作码都指定了一个助记符(mnemonic)。

比如操作码是0x00这条指令,因为它什么也不做,所以它的助记符是nop(no operation)。

JVM的指令中,操作数不是固定的,可能有0个,1个,2个等等。

指令的读取

前面我们解析了 class 文件,拿到了每个 method 的 code 属性,使用的是 byte[] 储存的:

java 复制代码
public class CodeAttribute extends AttributeInfo {
  private final byte[] code;
}

要解析指令,我们需要遵循每条指令的格式。我们定义一个 CodeReader 类,它负责读取固定字节的数据并进行转换:

ini 复制代码
public class CodeReader {

    private byte[] code;

    private int pc;

    public void reset(byte[] code, int pc) {
        this.code = code;
        this.pc = pc;
    }

    public int readByte() {
        byte b = code[pc];
        pc += 1;
        return b;
    }

    /**
     * remove extended sign bit
     */
    public int readUnsignedByte() {
        byte b = code[pc];
        pc += 1;
        return b & 0xFF;
    }

    public int readShort() {
        int b1 = readUnsignedByte();
        int b2 = readUnsignedByte();
        return (short) (b1 << 8 | b2);
    }

    /**
     * remove extended sign bit
     */
    public int readUnsignedShort() {
        int b1 = readUnsignedByte();
        int b2 = readUnsignedByte();
        return (b1 << 8 | b2) & 0xFFFF;
    }

    public int readInt() {
        int b1 = readUnsignedByte();
        int b2 = readUnsignedByte();
        int b3 = readUnsignedByte();
        int b4 = readUnsignedByte();
        return b1 << 24 | b2 << 16 | b3 << 8 | b4;
    }

    public int[] readInts(int count) {
        int[] result = new int[count];
        for (int i = 0; i < count; i++) {
            result[i] = readInt();
        }
        return result;
    }

    public void skipPadding() {
        while (pc % 4 != 0) {
            readByte();
        }
    }

    public int getPc() {
        return pc;
    }
}

该类主要是读取 1/2/4 个字节,并转换为有符号或者无符号数。之所以要区分有符号与无符号是因为不同的指令的操作数不同,有的操作数需要看作无符号数。

这里我们也维护了一个 pc,其实这个pc的值就是 StackFrame 里面的 nextPc 的值。因为一个栈对应一个方法,一个方法对应一个(或者0个)code 属性。code 的 pc 位置就是方法执行到的位置。

指令的解析

JVM的指令分为多种:

  • 常量(constants)指令
  • 加载(loads)指令
  • 存储(stores)指令
  • 操作数栈(stack)指令
  • 数学(math)指令
  • 转换(conversions)指令
  • 比较(comparisons)指令
  • 控制(control)指令
  • 引用(references)指令
  • 扩展(extended)指令
  • 保留(reserved)指令

由于篇幅问题,我们每种指令介绍1~2个吧,后面实现方法区后,还会实现 invoke 相关的指令。

一般的指令有自身的操作类型,比如,IConst_0 就是操作的 int 类型,具体的类型对应表如下:

常量指令

const系列指令,这一系列指令把隐含在操作码中的常量值推入操作数栈顶。

IConst_0 指令的具体意义可以看官方文档:

docs.oracle.com/javase/spec...

贴一下该指令的描述:

有了指令的描述,我们就可以写出指令的具体执行逻辑。

我们定义了一个 Instruction 接口,它表示无操作数的指令。

typescript 复制代码
public class IConst0 implements Instruction {
    @Override
    public int getOpCode() {
        return 0x3;
    }

    @Override
    public void execute(StackFrame frame) {
        frame.getOperandStack().pushInt(0);
    }

    @Override
    public String getReadableName() {
        return "iconst_0";
    }
}

再看 bipush 的描述:

描述里面说,第一个操作数是一个有符号数,当作 int 处理,将这个值放入操作数的栈顶。所以代码实现如下:

typescript 复制代码
public class BiPush extends Operand1Instruction {

    public BiPush(CodeReader reader) {
        super(reader);
    }

    @Override
    public int getOpCode() {
        return 0x10;
    }

    @Override
    public void execute(StackFrame frame) {
    // operand 是 readOperand 返回的值
        frame.getOperandStack().pushInt(operand);
    }

    @Override
    public String getReadableName() {
        return "bipush";
    }

    @Override
    protected int readOperand(CodeReader reader) {
    // 这里读取的是 byte 的值
        return reader.readByte();
    }
}

Operand1Instruction 是一个抽象类,它封装了读取一个操作数的逻辑,具体可看源码。

加载指令

加载指令从局部变量表获取变量,然后推入操作数栈顶。举个例子:

iload指令后面跟了一个操作数 index

这个指令描述的很清楚:index是一个无符号数,指向的是局部变量表的索引,该索引对应的位置一定是一个 int 类型的数。

所以代码写起来就很简单:

java 复制代码
public class ILoad extends Operand1Instruction {
    public ILoad(CodeReader reader) {
        super(reader);
    }

    @Override
    protected int readOperand(CodeReader reader) {
        return reader.readUnsignedByte();
    }

    @Override
    public int getOpCode() {
        return 0x15;
    }

    @Override
    public void execute(StackFrame frame) {
        int local = frame.getLocalVariableTable().getInt(operand);
        frame.getOperandStack().pushInt(local);
    }

    @Override
    public String getReadableName() {
        return "iload";
    }
}

JVM为了加快指令解析速度,还搞了一些常用的指令。

因为有一些index比较常见,就直接将index写在指令(助记符)上,不用那么的麻烦,再去读一个操作数,还可以节省 code 大小。

比如:

看一下,iload_0 的实现:

typescript 复制代码
public class ILoad0 implements Instruction {

    @Override
    public int getOpCode() {
        return 0x1A;
    }

    @Override
    public void execute(StackFrame frame) {
        int local = frame.getLocalVariableTable().getInt(0);
        frame.getOperandStack().pushInt(local);
    }

    @Override
    public String getReadableName() {
        return "iload_0";
    }
}

这种指令几乎占了大部分,看起来JVM的指令很多,实际上很多都是类似的。后面就不重复介绍这种类似的指令了。

储存指令

和加载指令刚好相反,存储指令把变量从操作数栈顶弹出,然后存入局部变量表。举一个例子就够了:

java 复制代码
public class IStore extends Operand1Instruction {
    public IStore(CodeReader reader) {
        super(reader);
    }

    @Override
    protected int readOperand(CodeReader reader) {
        return reader.readUnsignedByte();
    }

    @Override
    public int getOpCode() {
        return 0x36;
    }

    @Override
    public void execute(StackFrame frame) {
        int value = frame.getOperandStack().popInt();
        frame.getLocalVariableTable().setInt(operand, value);
    }

    @Override
    public String getReadableName() {
        return "istore";
    }
}

栈指令

栈指令直接对操作数栈进行操作,共9条:

  • pop和pop2指令将栈顶变量弹出,
  • dup系列指令复制栈顶变量,
  • swap指令交换栈顶的两个变量。

和其他类型的指令不同,栈指令并不关心变量类型。

举个例子,看看 dup指令:

就算不看描述,看 Operand Stack 也很形象,就是将栈顶的变量复制一下,将复制后的变量也推到栈顶。具体代码实现如下:

typescript 复制代码
public class Dup implements Instruction {
    @Override
    public int getOpCode() {
        return 0x59;
    }

    @Override
    public void execute(StackFrame frame) {
        OperandStack operandStack = frame.getOperandStack();
        Slot slot = operandStack.popSlot();
        operandStack.pushSlot(slot);
        operandStack.pushSlot(slot);
    }

    @Override
    public String getReadableName() {
        return "dup";
    }
}

那这个指令啥时候会用到呢?实际上我们在创建对象的时候都会是一个成对的指令,比如如下代码:

ini 复制代码
Test03 test03 = new Test03();

编译后的字节码如下:

为啥 new 后面会跟一个 dup 指令呢?是因为创建对象会调用类的 init 方法。而 init 方法默认带一个参数,就是 this。这个 this 就是 new 指令创建出来的对象了。是不是有点神奇!!!想通这一点对后面实现方法区等很有帮助哦。

数学指令

数学指令又可以细分好多种,但是由于我们是使用 Java 实现 JVM,所以这一部分功能都没啥难度。比如,isub 指令,具体的实现细节还是要看 JVM 的说明哈:

注意这里的操作数栈,栈顶的是 value2,下面的是 value1。我代码里面写的是反的:

typescript 复制代码
public class ISub implements Instruction {
    @Override
    public int getOpCode() {
        return 0x64;
    }

    @Override
    public void execute(StackFrame frame) {
        int v1 = frame.getOperandStack().popInt();
        int v2 = frame.getOperandStack().popInt();
        frame.getOperandStack().pushInt(v2 - v1);
    }

    @Override
    public String getReadableName() {
        return "isub";
    }
}

转换指令

类型转换指令大致对应Java语言中的基本类型强制转换操作。

我们看一个 int 转 float 的指令,i2f

typescript 复制代码
public class I2F implements Instruction {
    @Override
    public int getOpCode() {
        return 0x86;
    }

    @Override
    public void execute(StackFrame frame) {
        float value = frame.getOperandStack().popInt();
        frame.getOperandStack().pushFloat(value);
    }

    @Override
    public String getReadableName() {
        return "i2f";
    }
}

因为在 java 里面 int 转 float 不需要做啥,所以代码逻辑也是非常的简单哈。

比较指令

比较指令是编译器实现if-else、for、while等语句的基石。

看一个比较复杂的例子:

这张图里面介绍了很多指令,但是功能类似,只不过判断条件不一样,有的是判断等于,有的是判断不等于。

我们看等于的实现:

java 复制代码
public class IfICmpEq extends Operand1Instruction {

    public IfICmpEq(CodeReader reader) {
        super(reader);
    }

    @Override
    protected int readOperand(CodeReader reader) {
        return reader.readShort();
    }

    @Override
    public int getOpCode() {
        return 0x9F;
    }

    @Override
    public void execute(StackFrame frame) {
        int v2 = frame.getOperandStack().popInt();
        int v1 = frame.getOperandStack().popInt();
        if (v1 == v2) {
            frame.setNextPc(frame.getThread().getPc() + operand);
        }
    }

    @Override
    public String getReadableName() {
        return "if_icmpeq";
    }

}

其实也非常的简单,因为该指令后面虽然跟了两个字节,但是需要将它看作一个操作数,这个操作数表示的是一个 offset,是 pc 需要跳转的位置。

看具体的例子:

arduino 复制代码
  public static int test(int a) {
        if (a != 10) {
            a = 1;
        } else {
            a = 0;
        }

        return a;
    }

编译后的字节码:

看到 if_icmpeq 后面跟了一个 11,是 3 + 8 得出来的,所以其 offset 为 8。也就是说满足了当前的条件的话,下一条指令就要跳转到 11 位置去执行。

控制指令

这里主要讨论 gototableswitchlookupswitch 这3条指令,其他的比如 return 等指令后面再说。

goto

goto指令进行无条件跳转,我们看一个例子:

ini 复制代码
  public static int test(int a) {
        if (a > 0) {
            a = 1;
        } else if (a < 0){
            a = -1;
        } else {
            a = 0;
        }

        return a;
    }

看其编译后的字节码:

可以看到 if 与 goto 基本是配对的(虽然编译后的字节码逻辑变得很奇怪,但是还是正确的)。

goto 的实现:

typescript 复制代码
public class Goto extends Operand1Instruction {
    public Goto(CodeReader reader) {
        super(reader);
    }

    @Override
    public int getOpCode() {
        return 0xa7;
    }

    @Override
    public void execute(StackFrame frame) {
        frame.setNextPc(frame.getThread().getPc() + operand);
    }

    @Override
    public String getReadableName() {
        return "goto";
    }

    @Override
    protected int readOperand(CodeReader reader) {
        return reader.readShort();
    }
}

tableswitch

Java语言中的switch-case语句有两种实现方式:

  • 如果case值可以编码成一个索引表,则实现成tableswitch指令;
  • 否则实现成lookupswitch指令。

下面这个Java方法中的switch-case可以编译成tableswitch指令,代码如下:

arduino 复制代码
int chooseNear(int i) {
    switch (i) {
        case 0: return 0;
        case 1: return 1;
        case 2: return 2;
        default: return -1;
    }
}

下面这个Java方法中的switch-case则需要编译成lookupswitch指令:

arduino 复制代码
int chooseFar(int i) {
    switch (i) {
        case -100: return -1;
        case 0: return 0;
        case 100: return 1;
        default: return -1;
    }
}

可以看出,如果 case 的值是大致连续的,就编译成tableswitch,否者就是lookupswitch ,但是这个也仅仅是推测,具体还需要去查一些资料。由于 tableswitch 是按照查表得方式,根据操作数去计算出表得索引,所以这个索引也不能太大。

看看代码实现:

java 复制代码
public class TableSwitch implements Instruction {

    private final int defaultOffset;
    private final int low;
    private final int high;
    private final int[] jumpOffsets;

    public TableSwitch(CodeReader reader) {
        reader.skipPadding();
        defaultOffset = reader.readInt();
        low = reader.readInt();
        high = reader.readInt();
        jumpOffsets = reader.readInts(high - low + 1);
    }

    @Override
    public int getOpCode() {
        return 0xAA;
    }

    @Override
    public void execute(StackFrame frame) {
        int index = frame.getOperandStack().popInt();
        int offset;
        if (index <= high && index >= low) {
            offset = jumpOffsets[index - low];
        } else {
            offset = defaultOffset;
        }
        frame.setNextPc(frame.getThread().getPc() + offset);
    }

    @Override
    public String getReadableName() {
        return "tableswitch";
    }
}

可以看到,这条指令后面跟了不少的操作数:

  • defaultOffset,就是 default case
  • low,case 的低边界
  • high,case 的高边界
  • jumpOffsets,每个元素都代表一个 case

LookupSwitch

tableswitch 挺像的,就是它里面储存的是键值对:

java 复制代码
public class LookupSwitch implements Instruction {

    private final int defaultOffset;
    private final int npairs;
    private final int[] matchOffsets;

    public LookupSwitch(CodeReader reader) {
        reader.skipPadding();
        defaultOffset = reader.readInt();
        npairs = reader.readInt();
        matchOffsets = reader.readInts(npairs * 2);
    }

    @Override
    public int getOpCode() {
        return 0xab;
    }

    @Override
    public void execute(StackFrame frame) {
        int value = frame.getOperandStack().popInt();
        for (int i = 0; i < 2 * npairs; i += 2) {
            if (matchOffsets[i] == value) {
                frame.setNextPc(frame.getThread().getPc() + matchOffsets[i + 1]);
                return;
            }
        }
        frame.setNextPc(frame.getThread().getPc() + defaultOffset);
    }

    @Override
    public String getReadableName() {
        return "lookupswitch";
    }
}

tableswitch 的 offsets 里面只有偏移,但是这个 offsets 里面储存了 case 的值与偏移。

发现一个问题,switch编译后无论使用哪种指令,它们的边界都是 int 值,那么Switch是如何支持String类型的呢?有兴趣的可以自行研究一下 String case 编译后的字节码,很有意思。

扩展指令

这一系列的指令其实很杂,只介绍一个 wide 吧。

加载类指令、存储类指令、ret指令和iinc指令需要按索引访问局部变量表,索引以uint8的形式存在字节码中。对于大部分方法来说,局部变量表大小都不会超过256,所以用一字节来表示索引就够了。但是如果有方法的局部变量表超过这限制呢?Java虚拟机规范定义了wide指令来扩展前述指令。

java 复制代码
/**
 * <a href="https://docs.oracle.com/javase/specs/jvms/se7/html/jvms-6.html#jvms-6.5.wide">return new </a>
 */
public class Wide implements Instruction {

    private final Instruction instruction;

    public Wide(CodeReader reader) {
        int opCode = reader.readUnsignedByte();
        instruction = delegateInstruction(reader, opCode);
    }

    private Instruction delegateInstruction(CodeReader reader, int opCode) {
        ...
    }

    @Override
    public int getOpCode() {
        return 0xC4;
    }

    @Override
    public void execute(StackFrame frame) {
        instruction.execute(frame);
    }

    @Override
    public String getReadableName() {
        return "wide " + instruction.getReadableName();
    }

  private static class AStoreWide extends AStore {
        public AStoreWide(CodeReader reader) {
            super(reader);
        }

        @Override
        protected int readOperand(CodeReader reader) {
            return reader.readUnsignedShort();
        }
    }

  ...

}

对于 wide 指令的处理,想了很多的方案。最终我的实现是重写了很多指令,虽然麻烦,但是看起来要清晰点,具体逻辑看源代码。

解释器

上面我们实现了那么多的指令,大概有100多条,然后我们就可以实现自己的解释器了。

我们先根据解析出来的 ClassFile 对象获取到我们想要执行的方法(MemberInfo)。

有了方法信息,就可以拿到其 CodeAttribute,这个属性里面有局部变量表与操作数栈的大小,还有 code 指令。

使用这些信息,就可以执行方法了,现在我们只能执行一个方法,还是一个简单的方法,但是也是一个巨大的进步。

先创建解释器:

ini 复制代码
Interpreter interpreter = new Interpreter();

然后执行指定的类的 main 方法:

ini 复制代码
Classpath classpath = new Classpath(cmd.getClasspath());
ClassFile mainClass = new ClassFile(classpath.readClass(xxx));
MemberInfo mainInfo = mainClass.getMainMethod();
CodeAttribute mainCode = mainInfo.getCodeAttribute();

int maxLocals = mainCode.getMaxLocals();
int operandSize = mainCode.getOperandSize();
byte[] code = mainCode.getCode();

// 创建一个栈帧
StackFrame stackFrame = thread.newStackFrame(maxLocals, operandSize, code);
thread.pushStackFrame(stackFrame);
// 执行栈帧
interpreter.interpret(thread);

解释器代码:

ini 复制代码
CodeReader codeReader = new CodeReader);
do {
 StackFrame stackFrame = thread.currentStackFrame();
 
 // 更新 pc
 int pc = stackFrame.getNextPc();
 thread.setPc(pc);
 
 // 执行指令
 codeReader.reset(code, pc);
 int opCode = codeReader.readUnsignedByte();
 Instruction instruction = InstructionFactory.create(opCode, codeReader);
 
 // 每执行一条指令都需要更新 pc
 stackFrame.setNextPc(codeReader.getPc());
 
 // 调用指令的执行方法
 instruction.execute(stackFrame);
} while (xxx)

这样解释器就实现完成了,非常的简单对不对,这个时候就必须要贴一张南京大学的计算机系统实验 ics-pa 的一张图了:

解释器就是做了这个工作。

测试

arduino 复制代码
public class Test05 {
    public static void main(String[] args) {
        int sum = 0;
        for (int i = 1; i <= 100; i++) {
            sum += i;
        }
        System.out.println(sum);
    }
}

配置环境并执行:

输出结果:

结果是正确的,说明我们的解释器没太大问题,但是由于没有测试到所有指令,所以可能会有一些小bug,不过没关系,后面遇到了再修一修即可。

相关推荐
l1x1n013 分钟前
No.3 笔记 | Web安全基础:Web1.0 - 3.0 发展史
前端·http·html
昨天;明天。今天。29 分钟前
案例-任务清单
前端·javascript·css
zqx_72 小时前
随记 前端框架React的初步认识
前端·react.js·前端框架
惜.己2 小时前
javaScript基础(8个案例+代码+效果图)
开发语言·前端·javascript·vscode·css3·html5
什么鬼昵称2 小时前
Pikachu-csrf-CSRF(get)
前端·csrf
长天一色3 小时前
【ECMAScript 从入门到进阶教程】第三部分:高级主题(高级函数与范式,元编程,正则表达式,性能优化)
服务器·开发语言·前端·javascript·性能优化·ecmascript
NiNg_1_2343 小时前
npm、yarn、pnpm之间的区别
前端·npm·node.js
秋殇与星河3 小时前
CSS总结
前端·css
BigYe程普3 小时前
我开发了一个出海全栈SaaS工具,还写了一套全栈开发教程
开发语言·前端·chrome·chatgpt·reactjs·个人开发
余生H4 小时前
前端的全栈混合之路Meteor篇:关于前后端分离及与各框架的对比
前端·javascript·node.js·全栈