在上一篇中我们实现了一个能跑的解释器,支持了一些基本的栈操作指令。现在我们就可以开始实现"有点用"的数学运算和条件判断了。
github: https://github.com/wanghongfei/mini-jvm
由于JVM字节码是基于栈的指令集,因此一切操作都是以栈为基础的,也就是说计算1+1,那需要先在栈中压入两个1然后进行计算,如果是对象方法调用,那么对象的引用、方法参数都会事先被压入栈中。除栈外还有一个跟执行相关的重要结构就是局部变量表(Local Variable Table),用来保存当前执行环境(如当前方法)下的局部变量,在JVM中用一个数组来表示,有专门的字节码指令用于向局部变量表的指定下标存取数据,例如 storeX
和 loadX
指令。值得注意的是这个数组大小是固定的,不需要动态扩容,因为在编译期javac就能够确定一个方法需要用大的局部变量表,然后会把这个数字写入到class文件的 code
属性的 max_locals
字段中。我们在解释字节码的时候可以直接用它来创建数组。
程序计数器比较简单,就是一个整数类型,永远指向下一条将要执行的字节码,具体到实现就是指向下一条字节码数组的下标。
现在我们的方法栈就有三个元素了,操作数栈、局部变量表、程序计数器:
// 方法栈的栈帧 type MethodStackFrame struct { // 本地变量表 localVariablesTable []interface{} // 操作数栈 opStack *OpStack // 程序计数器 pc int }
因为本篇暂不涉及方法调用,因此栈帧创建的问题可以先忽略,假定全局只有一个 MethodStackFrame
。
iload
是一组指令,包含 iload
, iload0
, iload1
... ... iload3
。开头的 i
表示操作数必须是整数,后边的数字表示需要将局部变量表的哪个槽位中的整数压到栈顶,即数组下标。而对于不带数字的 iload
指令,指令后面会紧跟着一个byte, 表示数组下标,例如要把下标为5的槽位中的整数压栈那么指令就会是 iload 5
两个字节。了解这些以后就很容易实现了:
case bcode.Iload: // Load int from local variable // ilaod index index := codeAttr.Code[frame.pc + 1] frame.pc++ // (1) frame.opStack.Push(frame.localVariablesTable[index]) case bcode.Iload0: // 将第1个slot中的值压栈 frame.opStack.Push(frame.localVariablesTable[0]) case bcode.Iload1: frame.opStack.Push(frame.localVariablesTable[1]) case bcode.Iload2: frame.opStack.Push(frame.localVariablesTable[2]) case bcode.Iload3: frame.opStack.Push(frame.localVariablesTable[3])
注意(1)的位置,因为不带数字的 iload
指令后面会跟着一个表示数组下标的字节,因此我们在取出这个下标后需要将程序计数器+1, 否则下次循环后就会取到数组下标而不是字节码了。此外在操作数组的时候其实并不需要检查下标是否越界,javac会保证生成的指令不会操作越界的下标。
istore
指令也是类似的,表示将栈顶元素出栈,然后保存到局部变量表的指定槽位中,例如:
case bcode.Istore1: // 将栈顶int型数值存入第二个本地变量 top, _ := frame.opStack.PopInt() frame.localVariablesTable[1] = top case bcode.Istore2: // 将栈顶int型数值存入第3个本地变量 top, _ := frame.opStack.PopInt() frame.localVariablesTable[2] = top
iconst
指令有点不太一样,虽然也是将整数压栈,但是他不跟局部变量表交互,压栈的值直接在指令中体现,例如 iconst_1
就是把1压栈, iconst_2
就是压入2,实现起来也非常简单:
case bcode.Iconst1: frame.opStack.Push(1)
iadd
表示连续做两次出栈操作,然后将得到的两个整数相加,最后再把结果压回栈中。实现起来也非常简单:
case bcode.Iadd: // 取出栈顶2元素,相加,入栈 op1, _ := frame.opStack.PopInt() op2, _ := frame.opStack.PopInt() sum := op1 + op2 frame.opStack.Push(sum)
还是那句话,不需要在 pop()
前检查栈是否为空,因为编译器会保证不会非法操作栈,除非是我们的go代码出了问题,如果是后者的话就直接让程序崩溃方便及时发现问题。
有了 iload
, istore
, iadd
后,我们终于能计算1+1了,然而尴尬的是,计算后的结果是保存在局部变量表里的,看不见摸不着,不过可以在debug调试过程中看到这个值。下一篇会介绍如何实现方法调用,到时候就可以实现控制台输出的功能了。
if_<cond>
是代表一组指令,格式为 if_<cond> byte1 byte2
,也就是指令后面跟着两个字节,用来组成一个16位的有符号整数,此整数表示 当条件(栈顶元素跟数字0做比较)成立时的要跳转到的目标字节码的offset , 注意这个offset是以当前 if_<cond>
指令的位置为基准的。例如, if_lt
所在的offset是10, byte1 byte2
组合后是5, 那么目标字节码的位置就是 10 + 5
= 15。这里如果用 javap
反编译的话输出结果会有一点误导人, javap
会输出这条指令计算好偏移量后的数值而不是 byte1 byte2
本身的值,例如:
3: ifle 9
右侧的9表示 3 + 6 = 9
,即 ifle
后面跟着的16位数字其实是6,而不是9。
我们拿 ifle
举例,他表示将栈顶元素 value
出栈并且跟0作比较,当 value <= 0
时条件成立。go代码如下:
case bcode.Ifle: // 当栈顶int型数值小于等于0时跳转 err := i.bcodeIfCompZero(frame, codeAttr, func(op1 int, op2 int) bool { return op1 <= op2 }) if nil != err { return fmt.Errorf("failed to execute 'ifle': %w", err) }
bcodeIfCompZero()
函数实现如下:
func (i *InterpretedExecutionEngine) bcodeIfCompZero(frame *MethodStackFrame, codeAttr *class.CodeAttr, gotoJudgeFunc func(int, int) bool) error { // 当栈顶int型数值小于0时跳转 // 跳转的偏移量 twoByteNum := codeAttr.Code[frame.pc + 1 : frame.pc + 1 + 2] var offset int16 err := binary.Read(bytes.NewBuffer(twoByteNum), binary.BigEndian, &offset) if nil != err { return fmt.Errorf("failed to read offset for if_icmpgt: %w", err) } op, _ := frame.opStack.PopInt() if gotoJudgeFunc(op, 0) { frame.pc = frame.pc + int(offset) - 1 } else { frame.pc += 2 } return nil }
有些这些指令我们就可以解释一些类似于:
int sum = 0; if (sum > 0) { sum = 100; }
编译后的字节码了。我们可以照葫芦画瓢,先写一段java代码编译一下,然后 javap -verbose
看看有没有不认识的指令,如果有就查规范,看看应该如何解释,就能够实现很多简单指令了。这里要注意有很多指令后面会携带一个 _w
后缀,例如整数自增指令 iinc_w
,表示对字节码后面跟着的字节进行加宽处理,例如原本的 iinc byte1 byte2
变成了 iinc_w byte1 byte2 byte3
,诸如此类,只要对照规范看清楚就OK了。
下一篇会介绍方法调用相关指令的实现。
欢迎关注我们的微信公众号,每天学习Go知识