|
|
# 08|基于TypeScript的虚拟机(一):实现一个简单的栈机
|
|
|
|
|
|
你好,我是宫文学。
|
|
|
|
|
|
上一节课,我们已经探讨了设计一个虚拟机所要考虑的那些因素,并做出了一些设计决策。那么今天这一节课,我们就来实现一个初级的虚拟机。
|
|
|
|
|
|
要实现这个初级虚拟机,具体来说,我们要完成下面三方面的工作:
|
|
|
|
|
|
首先,我们要设计一些字节码,来支持第一批语言特性,包括支持函数、变量和整型数据的运算。也就是说,我们的虚拟机要能够支持下面程序的正确运行:
|
|
|
|
|
|
```plain
|
|
|
//一个简单的函数,把输入的参数加10,然后返回
|
|
|
function foo(x:number):number{
|
|
|
return x + 10;
|
|
|
}
|
|
|
//调用foo,并输出结果
|
|
|
println(foo(18));
|
|
|
|
|
|
```
|
|
|
|
|
|
第二,我们要做一个字节码生成程序,基于当前的AST生成正确的字节码。
|
|
|
|
|
|
第三,使用TypeScript,实现一个虚拟机的原型系统,验证相关设计概念。
|
|
|
|
|
|
话不多说,开搞,让我们先设计一下字节码吧!
|
|
|
|
|
|
## “设计”字节码
|
|
|
|
|
|
说是设计,其实我比较懒,更愿意抄袭现成的设计,比如Java的字节码设计。因为Java字节码的资料最充分,比较容易研究,不像V8等的字节码,只有很少的文档资料,探讨的人也很少。另外,学会手工生成Java字节码还有潜在的实用价值,比如你可以把自己的语言编译后直接在JVM上运行。那么我们就先来研究一下Java字节码的特点。
|
|
|
|
|
|
上面的用TypeScript编写的示例代码,如果用Java改写,会变成下面的程序:
|
|
|
|
|
|
```plain
|
|
|
//实现同样功能的Java程序。
|
|
|
public class A{
|
|
|
public static int foo(int a){
|
|
|
return a + 10;
|
|
|
}
|
|
|
public static void main(String args[]){
|
|
|
System.out.println(foo(8));
|
|
|
}
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
我们首先把这个Java程序编译成字节码。
|
|
|
|
|
|
```plain
|
|
|
javac A.java
|
|
|
|
|
|
```
|
|
|
|
|
|
这个文件是一个二进制文件。我们可以用hexdump命令查看它的内容。
|
|
|
|
|
|
```plain
|
|
|
hexdump -C A.class
|
|
|
|
|
|
```
|
|
|
|
|
|
![图片](https://static001.geekbang.org/resource/image/19/2e/19380b1b13c608e9db8c842a3a7de92e.jpg?wh=1492x1199)
|
|
|
|
|
|
从hexdump显示的信息中,你能看到一些可以阅读的字符,比如“java/lang/Object”、"java/lang/System"等等,这些是常量表中的内容。还有一些内容显然不是字符,没法在屏幕上显示,所以hexdump就用一个.号来表示。其中某些字节,代表的是指令,我在图中把代表foo函数、main函数和构造函数的指令标注了出来,这些都是。用于运行的字节码指令,其他都是一些符号表等描述性的信息。
|
|
|
|
|
|
通过上图,你还能得到一个直观的印象:**字节码文件并不都是由指令构成的**。
|
|
|
|
|
|
没错,指令只是一个程序文件的一部分。除了指令以外,在字节码文件中还要存储不少其他内容,才能保证程序的正常运行,比如类和方法的符号信息、字符串和数字常量,等等。至于字节码文件的格式,是由字节码的规范来规定的,你有兴趣的话,可以按照规范生成这样的字节码文件。这样的话,我们的程序就可以在JVM上运行了。
|
|
|
|
|
|
不过,我现在不想陷入字节码文件格式的细节里,而是想用自己的方式生成字节码文件,够支持现在的语言特性,能够在我们自己的虚拟机上运行就行了。
|
|
|
|
|
|
上面这张图显示的字节码文件不是很容易阅读和理解。所以,我们用javap命令把它转化成文本格式来看看。
|
|
|
|
|
|
```plain
|
|
|
javap -v A.class > A.bc
|
|
|
|
|
|
```
|
|
|
|
|
|
在这个新生成的文件里,我们可以清晰地看到每个函数的定义以及指令,我也在图里标注了主要的指令的含义。
|
|
|
|
|
|
![图片](https://static001.geekbang.org/resource/image/c9/a1/c90344b05e25ffae95a2c3e9e1a804a1.jpg?wh=1328x1265 "文本格式的字节码文件")
|
|
|
|
|
|
看到这个字节码文件的内容,你可能会直观地觉得:这看上去跟我们的高级语言也没有那么大的区别嘛。程序照样划分成几个函数,只不过每个函数里的语句变成了栈机的指令而已,函数之间照样需要互相调用。
|
|
|
|
|
|
实际上也确实没错。字节码文件里本来就存储了各个类和方法的符号信息,相当于保存了高级语言里的主体框架。当然,每个方法体里的代码就看不出if语句、循环语句这样的结构了,而是变成了字节码的指令。
|
|
|
|
|
|
通过研究这些指令,加上查阅[JVM规则](https://docs.oracle.com/javase/specs/jvms/se12/html/jvms-6.html)中对于字节码的规定,你会发现为了实现上面示例代码中的功能,我们目前只需要这几个指令就够了:
|
|
|
|
|
|
![图片](https://static001.geekbang.org/resource/image/60/20/608cdfd47efe1e162e8bd7ca88f1d420.jpg?wh=1919x1265)
|
|
|
|
|
|
![图片](https://static001.geekbang.org/resource/image/55/f6/55334d4a30a41ed87bf7bc82c64c9ef6.jpg?wh=1920x1220 "常数入栈指令")
|
|
|
|
|
|
![图片](https://static001.geekbang.org/resource/image/e5/8b/e52ef425d612381fe33628818c75c68b.jpg?wh=1920x1166 "二元运算指令,以及函数调用和返回指令")
|
|
|
|
|
|
你先花一两分钟看一下这些指令,看上去挺多,其实可以分为几组。
|
|
|
|
|
|
首先是iload系列,这是把指定下标的本地变量入栈。注意,变量的下标是由声明的顺序决定的,参数也算本地变量,并且排在最前面。所以,iload 0的意思,就是把第一个参数入栈。如果没有参数,就是把第一个本地变量入栈。
|
|
|
|
|
|
iload后面的那几个指令,是压缩格式的指令,也就是利用指令末尾富余的位,把操作数和指令压缩在了一起,这样可以少一个字节码,能够缩小最后生成的字节码文件的大小。从这里面,你能借鉴到字节码设计的一些好的实践。所以你看,学习成熟的设计是有好处的吧?
|
|
|
|
|
|
第二组是istore系列,它做的工作刚好跟iload相反,是把栈顶的值存到指定下标的变量里去。
|
|
|
|
|
|
第三组,是对常数做入栈的操作。对于0~5这几个数字,Java字节码也是提供了压缩格式的指令。对于8位整数(-128~127),使用bipush指令。对于16位整数(-32768~32767),使用sipush指令。而对于更大的常数,则要使用ldc指令,从常量池里去取。
|
|
|
|
|
|
第四组,是几个二元运算的指令。它们都是从栈里取两个操作数,计算完毕之后,再压回栈里。
|
|
|
|
|
|
最后一组指令,是函数调用和返回的指令。函数调用的时候,也是从栈里取参数。返回值呢,则压回栈里。
|
|
|
|
|
|
通过这些指令,我们就完全能够实现一些基本的功能了,之后我们再根据需要添加更多的指令就好。
|
|
|
|
|
|
现在,我们把这些指令做成枚举值,方便程序使用:
|
|
|
|
|
|
```plain
|
|
|
/**
|
|
|
* 指令的编码
|
|
|
*/
|
|
|
enum OpCode{
|
|
|
iconst_0= 0x03,
|
|
|
iconst_1= 0x04,
|
|
|
iconst_2= 0x05,
|
|
|
iconst_3= 0x06,
|
|
|
iconst_4= 0x07,
|
|
|
iconst_5= 0x08,
|
|
|
bipush = 0x10, //8位整数入栈
|
|
|
sipush = 0x11, //16位整数入栈
|
|
|
iload = 0x15, //本地变量入栈
|
|
|
iload_0 = 0x1a,
|
|
|
iload_1 = 0x1b,
|
|
|
iload_2 = 0x1c,
|
|
|
iload_3 = 0x1d,
|
|
|
istore = 0x36,
|
|
|
istore_0= 0x3b,
|
|
|
istore_1= 0x3c,
|
|
|
istore_2= 0x3d,
|
|
|
istore_3= 0x3e,
|
|
|
iadd = 0x60,
|
|
|
isub = 0x64,
|
|
|
imul = 0x68,
|
|
|
idiv = 0x6c,
|
|
|
ireturn = 0xac,
|
|
|
return = 0xb1,
|
|
|
invokestatic= 0xb8, //调用函数
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
好了,我们通过全盘照抄的方式,“设计”出了自己所需要的字节码。不过这些都只是指令的部分。还要有常量池的部分,我们在下面使用到的时候再去设计。
|
|
|
|
|
|
接下来,我们来生成自己的字节码。
|
|
|
|
|
|
## 生成字节码
|
|
|
|
|
|
生成栈机的字节码是比较简单的。为什么呢?因为栈机的字节码基本上跟AST是同构的,通过深度优先的顺序遍历AST就可以实现。相对来说,生成寄存器机的指令的算法就要稍微绕一点,我们也会在后面的课程中体会到。
|
|
|
|
|
|
怎么来深度优先地遍历AST、生成栈机的字节码呢?以“3+a”这样一个简单的表达式为例,我们处理的顺序依次是字面量3、变量a和+号。
|
|
|
|
|
|
首先来处理字面量。处理整型字面量的时候,我们需要根据整数的不同长度,分别使用不同的常量指令。其中的一个细节是,当整数是16位时,操作数要拆成两个字节。而当大于16位时,我们干脆就把字面量放在常量池里,操作数只是该常数在常量池的索引值。
|
|
|
|
|
|
这里的处理你可以参照下面这个代码:
|
|
|
|
|
|
```plain
|
|
|
visitIntegerLiteral(integerLiteral: IntegerLiteral):any{
|
|
|
let ret:number[] = [];
|
|
|
let value = integerLiteral.value;
|
|
|
//0-5之间的数字,直接用快捷指令
|
|
|
if (value >= 0 && value <= 5) {
|
|
|
switch (value) {
|
|
|
case 0:
|
|
|
ret.push(OpCode.iconst_0);
|
|
|
break;
|
|
|
...省略1、2、3、4的情况
|
|
|
case 5:
|
|
|
ret.push(OpCode.iconst_5);
|
|
|
break;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
//如果是8位整数,用bipush指令,直接放在后面的一个字节的操作数里就行了
|
|
|
else if (value >= -128 && value <128){
|
|
|
ret.push(OpCode.bipush);
|
|
|
ret.push(value);
|
|
|
}
|
|
|
|
|
|
//如果是16位整数,用sipush指令
|
|
|
else if (value >= -32768 && value <32768){
|
|
|
ret.push(OpCode.sipush);
|
|
|
//要拆成两个字节
|
|
|
ret.push(value >> 8);
|
|
|
ret.push(value & 0x00ff);
|
|
|
}
|
|
|
|
|
|
//大于16位的,采用ldc指令,从常量池中去取
|
|
|
else{
|
|
|
//把value值放入常量池。
|
|
|
this.module.consts.push(value);
|
|
|
ret.push(this.module.consts.length -1);
|
|
|
}
|
|
|
return ret;
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
接着来处理变量a。在处理变量的时候,我们要区分左值和右值的情况。在“3+a”这个表达式中,a是个右值,我们需要取出a的值,也就是要生成iload指令。但对于“a=3”这样的表达式,a是个左值,这个时候返回a的符号即可,在处理赋值运算的时候再生成istore指令。
|
|
|
|
|
|
```plain
|
|
|
/**
|
|
|
* 左值的情况,返回符号。否则,生成iload指令。
|
|
|
* @param v
|
|
|
*/
|
|
|
visitVariable(v:Variable):any{
|
|
|
if (v.isLeftValue){
|
|
|
return v.sym;
|
|
|
}
|
|
|
else{
|
|
|
return this.getVariableValue(v.sym);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
* 生成获取本地变量值的指令
|
|
|
* @param varName
|
|
|
*/
|
|
|
private getVariableValue(sym:VarSymbol|null):any{
|
|
|
if (sym != null){
|
|
|
let code:number[] = []; //生成的字节码
|
|
|
//本地变量的下标
|
|
|
let index = this.functionSym?.vars.indexOf(sym);
|
|
|
assert(index != -1, "生成字节码时(获取变量的值),在函数符号中获取本地变量下标失败!");
|
|
|
//根据不同的下标生成指令,尽量生成压缩指令
|
|
|
switch (index){
|
|
|
case 0:
|
|
|
code.push(OpCode.iload_0);
|
|
|
break;
|
|
|
case 1:
|
|
|
code.push(OpCode.iload_1);
|
|
|
break;
|
|
|
case 2:
|
|
|
code.push(OpCode.iload_2);
|
|
|
break;
|
|
|
case 3:
|
|
|
code.push(OpCode.iload_3);
|
|
|
break;
|
|
|
default:
|
|
|
code.push(OpCode.iload);
|
|
|
code.push(index as number);
|
|
|
}
|
|
|
return code;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
然后,我们要对加减乘除这些二元运算来生成代码,我们可以先为左右子树分别生成代码,再把加减乘除的运算指令放在最后。注意,赋值运算的处理逻辑是不同的,它要生成istore指令。
|
|
|
|
|
|
```plain
|
|
|
visitBinary(bi:Binary):any{
|
|
|
let code:number[];
|
|
|
let code1 = this.visit(bi.exp1);
|
|
|
let code2 = this.visit(bi.exp2);
|
|
|
|
|
|
////1.处理赋值
|
|
|
if (bi.op == Op.Assign){
|
|
|
let varSymbol = code1 as VarSymbol;
|
|
|
//加入右子树的代码
|
|
|
code = code2;
|
|
|
//加入istore代码
|
|
|
code = code.concat(this.setVariableValue(varSymbol));
|
|
|
}
|
|
|
////2.处理其他二元运算
|
|
|
else{
|
|
|
//加入左子树的代码
|
|
|
code = code1;
|
|
|
//加入右子树的代码
|
|
|
code = code.concat(code2);
|
|
|
//加入运算符的代码
|
|
|
switch(bi.op){
|
|
|
case Op.Plus: //'+'
|
|
|
code.push(OpCode.iadd);
|
|
|
break;
|
|
|
case Op.Minus: //'-'
|
|
|
code.push(OpCode.isub);
|
|
|
break;
|
|
|
case Op.Multiply: //'*'
|
|
|
code.push(OpCode.imul);
|
|
|
break;
|
|
|
case Op.Divide: //'/'
|
|
|
code.push(OpCode.idiv);
|
|
|
break;
|
|
|
default:
|
|
|
console.log("Unsupported binary operation: " + bi.op);
|
|
|
return [];
|
|
|
}
|
|
|
}
|
|
|
return code;
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
好了,到此这里,我们对处理基本的表达式就没有问题了。接下来,我们再增加与函数调用和返回有关的指令。
|
|
|
|
|
|
在进行函数调用的时候,我们要依次生成与参数计算有关的指令,最后生成invokestatic指令。
|
|
|
|
|
|
```plain
|
|
|
let code:number[] = [];
|
|
|
//1.依次生成与参数计算有关的指令
|
|
|
for(let param of functionCall.paramValues){
|
|
|
let code = this.visit(param);
|
|
|
if (typeof code == 'object'){
|
|
|
code = code.concat(code as number[]);
|
|
|
}
|
|
|
}
|
|
|
//2.生成invoke指令
|
|
|
let index = this.module.consts.indexOf(functionCall.sym);
|
|
|
code.push(OpCode.invokestatic);
|
|
|
code.push(index>>8);
|
|
|
code.push(index);
|
|
|
return code;
|
|
|
|
|
|
```
|
|
|
|
|
|
然后,在处理return语句的时候,也要注意,我们要根据是否有返回值,分别生成ireturn和return指令。
|
|
|
|
|
|
```plain
|
|
|
let code:number[] = [];
|
|
|
//1.为return后面的表达式生成代码
|
|
|
if(returnStatement.exp != null){
|
|
|
let code1 = this.visit(returnStatement.exp);
|
|
|
if (typeof code1 == 'object'){
|
|
|
code = code.concat(code1 as number[]);
|
|
|
//生成ireturn代码
|
|
|
code.push(OpCode.ireturn);
|
|
|
return code;
|
|
|
}
|
|
|
}
|
|
|
else{
|
|
|
//2.生成return代码,返回值是void
|
|
|
code.push(OpCode.return);
|
|
|
return code;
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
到这里,我们已经能够顺利的生成字节码了。生成字节码以后,接下来就只剩最后一步了:实现一个虚拟机,让这些字节码真正运行起来!
|
|
|
|
|
|
## 实现一个TypeScript版本的虚拟机
|
|
|
|
|
|
如果你是学习Java的同学,那你在面试的时候肯定经常会被问到与虚拟机有关的知识点。你也会根据自己了解的知识,对操作数栈、常量池等发表一通见解。但是,你心里多多少少对这些概念还会隔着一层面纱。
|
|
|
|
|
|
直到你自己亲自动手实现一遍,哪怕只是实现一个原型系统,你对这些概念,以及对虚拟机到底是如何运行的,才会有真真切切的理解。所以,我鼓励你动手实现一遍。而且,我可以告诉你,真的花不了多少时间。如果你看看我给出的示例代码,其实就是一个大函数,针对不同的字节码指令做处理而已。
|
|
|
|
|
|
不过,为了便于你理解代码,我还是先画一个虚拟机的示意图。我们这个简单的虚拟机主要涉及几个对象:
|
|
|
|
|
|
![图片](https://static001.geekbang.org/resource/image/76/a0/76a4640bbe5c0d394c1ed8e330c214a0.jpg?wh=1584x1265)
|
|
|
|
|
|
**首先是模块。**模块代表了我们的程序,模块里最重要的就是一个常量表。常量表中的常量包含字符串常量、数字常量和函数符号。而函数符号里有一个bytecode属性,保存了这个函数的字节码。这样,我们就可以找到函数的代码并运行它们了。
|
|
|
|
|
|
```plain
|
|
|
export class BCModule{
|
|
|
//常量
|
|
|
consts:any[] = [];
|
|
|
|
|
|
//入口函数
|
|
|
_main:FunctionSymbol|null = null;
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
**然后是运行程序的栈机。**栈机里最重要的数据结构是一个调用栈,在AST解释器里也有类似的数据结构。调用栈是由栈桢构成的,在执行每个函数的时候,都需要在栈顶新加一个栈桢,而在退出函数的时候,就会弹出这个函数的栈桢。
|
|
|
|
|
|
```plain
|
|
|
//栈机
|
|
|
export class VM{
|
|
|
//调用栈
|
|
|
callStack:StackFrame[]=[];
|
|
|
//执行一个模块
|
|
|
execute(bcModule:BCModule):number{
|
|
|
...
|
|
|
}
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
你要注意,栈桢是一个关键的数据结构,它用来保存每个函数运行时所需要保存的状态信息。每个栈桢里又有几个关键的组成部分。
|
|
|
|
|
|
* 操作数栈:用来保存函数执行过程中各个指令所需要用到的操作数;
|
|
|
* 存放本地变量的数组:可以在这里存取变量值;
|
|
|
* 返回地址:也是在调用子函数的时候,告诉子函数返回以后,从哪条代码接着运行。
|
|
|
|
|
|
栈机里的execute方法,就是虚拟机的核心执行引擎。下面我摘一些有代表性的代码给你讲一下,你先看一下示例代码的结构。
|
|
|
|
|
|
```plain
|
|
|
//一直执行代码,直到遇到return语句
|
|
|
let opCode = code[codeIndex];
|
|
|
while(true){
|
|
|
switch (opCode){
|
|
|
case OpCode.iconst_0: //加载常量
|
|
|
frame.oprandStack.push(0);
|
|
|
opCode = code[++codeIndex];
|
|
|
continue;
|
|
|
...
|
|
|
case OpCode.sipush: //加载32位常量,需要取出2个字节
|
|
|
let byte1 = code[++codeIndex];
|
|
|
let byte2 = code[++codeIndex];
|
|
|
frame.oprandStack.push(byte1<<8|byte2);
|
|
|
opCode = code[++codeIndex];
|
|
|
continue;
|
|
|
...
|
|
|
case OpCode.iload_0: //从变量里取值
|
|
|
frame.oprandStack.push(frame.localVars[0]);
|
|
|
opCode = code[++codeIndex];
|
|
|
continue;
|
|
|
...
|
|
|
case OpCode.istore_0: //给变量赋值
|
|
|
frame.localVars[0] = frame.oprandStack.pop();
|
|
|
opCode = code[++codeIndex];
|
|
|
continue;
|
|
|
...
|
|
|
case OpCode.iadd: //加减乘除
|
|
|
frame.oprandStack.push(frame.oprandStack.pop() + frame.oprandStack.pop());
|
|
|
opCode = code[++codeIndex];
|
|
|
continue;
|
|
|
...
|
|
|
}
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
你可能马上会注意到,**整个程序执行的过程就是一个大的while循环**。
|
|
|
|
|
|
几乎所有的虚拟机的核心执行引擎都是这么写的,因为程序的执行过程就是不停地读取指令,然后根据指令做相应的动作。其中的代码计数器,一直指向下一个要执行的指令的位置。由于指令的种类比较多,所以switch后面会罗列很多个case,代码也会很长。你用电脑看这些代码的话,可能要翻很多屏。
|
|
|
|
|
|
虽然这个函数的代码很长,我们一般也不会把它拆成多个函数。为什么呢?这似乎违背了通常的编程理念呀。通常我们都不会编写太长的函数,这不容易阅读,也不容易维护。
|
|
|
|
|
|
但事情总有例外。对于虚拟机的执行引擎来说,性能上的考虑是第一位的。执行一个指令,可能开销并不大。但如果执行这条指令要调用一个专门的函数,那函数调用的额外开销会比执行指令本身的开销都大,那显然就不合理了,这也是我们为什么弃用AST解释器的原因。
|
|
|
|
|
|
我们这个示例代码中体现了加载常量、加载变量、保存变量和执行加减乘除的运算的实现方式。你能通过这些代码,再一次验证栈机的工作原理,这里所有操作都是围绕着操作数栈来进行的。
|
|
|
|
|
|
而函数的调用和返回,则显得复杂一点。这两部分代码,也有助于你更加细致的了解栈桢的使用方式。
|
|
|
|
|
|
首先我们来看看函数调用:
|
|
|
|
|
|
```plain
|
|
|
case OpCode.invokestatic:
|
|
|
//从常量池找到被调用的函数
|
|
|
byte1 = code[++codeIndex];
|
|
|
byte2 = code[++codeIndex];
|
|
|
let functionSym = bcModule.consts[byte1<<8|byte2] as FunctionSymbol;
|
|
|
|
|
|
//设置返回值地址,为函数调用的下一条指令
|
|
|
frame.returnIndex = codeIndex;
|
|
|
|
|
|
//创建新的栈桢
|
|
|
let lastFrame = frame;
|
|
|
frame = new StackFrame(functionSym);
|
|
|
this.callStack.push(frame);
|
|
|
|
|
|
//传递参数
|
|
|
let paramCount = (functionSym.decl as FunctionDecl).callSignature.params.length;
|
|
|
for(let i = paramCount -1; i>= 0; i--){
|
|
|
frame.localVars[i] = lastFrame.oprandStack.pop();
|
|
|
}
|
|
|
|
|
|
//设置新的code、codeIndex和oPCode
|
|
|
if (frame.funtionSym.bytecode !=null){
|
|
|
//切换到被调用函数的代码
|
|
|
code = frame.funtionSym.bytecode;
|
|
|
//代码指针归零
|
|
|
codeIndex = 0;
|
|
|
opCode = code[codeIndex];
|
|
|
continue;
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
在函数调用的时候,我们要完成这几项工作:
|
|
|
|
|
|
* 设置返回地址,被调用的函数在执行return指令的时候,会回到这个位置;
|
|
|
* 为被调用的函数生成新的栈桢,并加到调用栈中;
|
|
|
* 从操作数栈中取出参数的值,并赋给新的栈桢,因为参数也算作本地变量,因此只需要对新栈桢的前几个本地变量赋值就可以了;
|
|
|
* 把代码切换到被调用函数的代码,并且把代码计数器设置为0,也就是从第一个字节码指令开始执行。
|
|
|
|
|
|
而函数返回是函数调用的逆向操作:
|
|
|
|
|
|
```plain
|
|
|
case OpCode.ireturn:
|
|
|
case OpCode.return:
|
|
|
//确定返回值
|
|
|
let retValue = undefined;
|
|
|
if(opCode == OpCode.ireturn){
|
|
|
retValue = frame.oprandStack.pop();
|
|
|
}
|
|
|
|
|
|
//弹出栈桢,返回到上一级函数,继续执行
|
|
|
this.callStack.pop();
|
|
|
if (this.callStack.length == 0){ //主程序返回,结束运行
|
|
|
return 0;
|
|
|
}
|
|
|
else { //返回到上一级调用者
|
|
|
frame = this.callStack[this.callStack.length-1];
|
|
|
//设置返回值到上一级栈桢
|
|
|
// frame.retValue = retValue;
|
|
|
if(opCode == OpCode.ireturn){
|
|
|
frame.oprandStack.push(retValue);
|
|
|
}
|
|
|
//设置新的code、codeIndex和oPCode
|
|
|
if (frame.funtionSym.byteCode !=null){
|
|
|
//切换到调用者的代码
|
|
|
code = frame.funtionSym.byteCode;
|
|
|
//设置指令指针为返回地址,也就是调用该函数的下一条指令
|
|
|
codeIndex = frame.returnIndex;
|
|
|
opCode = code[codeIndex];
|
|
|
continue;
|
|
|
}
|
|
|
else{
|
|
|
console.log("Can not find code for "+ frame.funtionSym.name);
|
|
|
return -1;
|
|
|
}
|
|
|
}
|
|
|
continue;
|
|
|
|
|
|
```
|
|
|
|
|
|
函数返回时,我们需要完成下面这几项工作:
|
|
|
|
|
|
* 设置返回值,如果确实有一个返回值,那么就从当前操作数栈中取出来,返回值加载到上一级栈桢里的操作数栈;
|
|
|
* 从调用栈中弹出当前栈桢;
|
|
|
* 把代码切换成调用者的代码,并且把代码指针设置成函数调用指令的下一条指令,继续函数调用之后的工作;
|
|
|
* 这里还有一个特殊情况,执行到return指令的时候,你会发现当前栈桢之上,再也没有上一级栈桢了。这说明当前所在的函数已经是最顶层了,也就是我们的编译器内部的\_main()函数。这个时候,return指令就会结束整个while循环,也意味着整个程序运行结束。
|
|
|
|
|
|
好了,这就是函数调用和返回的过程。通过这个过程,你会发现这两类指令其实是遵循了一些共同的约定。
|
|
|
|
|
|
这些约定包括:我们传参数的时候,把参数放在哪里?函数返回的时候,又把返回值放在哪里?还有,调用函数的时候,我们应该在一个双方都知道的位置设置返回地址,以便函数返回后调到这个地址继续执行。
|
|
|
|
|
|
这里,你又学到一个概念,叫做**调用约定(Calling Convention)**。计算机语言的设计者,可以设计自己的调用约定,这样用自己的编译器编译出来的模块,都能互相调用。但如果一种语言想调用另一种语言编写的模块,那么它们必须遵循相同的调用约定,或者要在不同的调用约定之间做转换。比如,Java调用C语言写的模块,要使用JNI接口,就是要完成这种调用约定的转换。
|
|
|
|
|
|
跟调用约定差不多的一个概念叫做**ABI(Application Binary Interface)**。ABI这个名字更强调如何在二进制的层面上实现互相调用,以及二进制程序文件的格式,等等,所以更适合描述像C、C++这些编译成二进制目标代码的情形。
|
|
|
|
|
|
好了,现在我们已经拥有了一个简化版的虚拟机了,你可以用它跑几个程序试一下。因为现在我们这个虚拟机还不支持if语句和循环语句,所以还不方便做性能测试。这个工作,我们放在下一节课再去做。
|
|
|
|
|
|
## 课程小结
|
|
|
|
|
|
好了,今天这节课到这里就结束了,让我们来简单回顾一下。
|
|
|
|
|
|
今天,我们设计并实现了一个简单的基于栈机的虚拟机,实现了我们在虚拟机领域0的突破。如果你认真跟着学完了这一节,我相信你一定会有很多收获。
|
|
|
|
|
|
首先,我们学了栈机的指令,通常包括加载常数、加载变量、保存变量、加减乘除、函数调用和返回这些,用这些指令就能让程序运行起来了。下一节课我们还会多学习一些指令,特别是分支指令。
|
|
|
|
|
|
第二,我们已经可以通过遍历AST的方式生成字节码。其中稍微有点难度的地方,是在访问变量节点的时候,要区分左值和右值,分别生成给变量赋值的代码和读取变量值的代码。
|
|
|
|
|
|
第三,在虚拟机里,我们用了BCModule这个数据结构来表示可被执行的程序。其实你把这个数据结构保存到文件里就是字节码文件了,它里面主要的内容就是一个常量表。常量表里除了字符串、数字这样的常规意义上的常数以外,最重要的还有函数符号,函数符号中包含了要运行的字节码。
|
|
|
|
|
|
第四,栈机的执行引擎是一个很大的循环,要依次执行每条指令,在这个过程中操作数栈会不停的压入数据、弹出数据。在调用函数和返回函数的时候,要建立栈桢和弹出栈桢。函数参数被设置到新栈桢的本地变量里,而返回值则被设置到上一级栈桢的操作数栈里,这就构成了函数之间的调用约定。
|
|
|
|
|
|
虽然我们目前是在虚拟机层面上了解这些概念,但是,即使是到了我们把程序编译成机器码的时候,这些基本概念仍然是差不多的。你现在学习的成果,会为后面的学习内容打下很好的基础。
|
|
|
|
|
|
在下一节课,我们将继续深化我们虚拟机。到时候它会在哪些方面取得突破呢?敬请期待吧!
|
|
|
|
|
|
## 思考题
|
|
|
|
|
|
在这节课的BCModule中有一个常量表,为什么把函数符号也看做是常量呢?刨去其中的字节码,这些符号信息可能有什么潜在的用途呢?欢迎在留言区发表你的看法!
|
|
|
|
|
|
感谢你和我一起学习,欢迎你把我这节课分享给更多对字节码虚拟机感兴趣的朋友。我是宫文学,我们下节课见!
|
|
|
|
|
|
## 课程资源
|
|
|
|
|
|
[这节课的示例代码在这里!](https://gitee.com/richard-gong/craft-a-language/tree/master/16-18)
|
|
|
|