You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

160 lines
17 KiB
Markdown

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 07怎么设计属于我们自己的虚拟机和字节码
你好,我是宫文学。
到目前为止,我们的语言看上去已经有点像模像样了。但是有一个地方,我还一直是用比较凑合的方式来实现的,这就是**解释器**,这节课我想带你把它升级一下。
在之前的内容中我们用的解释器都是基于AST执行的而实际上你所能见到的大多数解释执行的脚本语言比如Python、PHP、JavaScript等内部都是采用了一个虚拟机用字节码解释执行的。像Java这样的语言虽然是编译执行但编译的结果也是字节码JVM虚拟机也能够解释执行它。
为什么这些语言都广泛采用虚拟机呢这主要是由基于AST的解释器的不足导致的。今天这节课我就带你先来分析一下AST解释器的缺陷然后了解一下像JVM这样的虚拟机的运行原理特别是栈机和寄存器机的差别以便确定我们自己的虚拟机的设计思路。
看上去任务有点多没关系我们一步一步来我们先来分析一下基于AST的解释器。
## 基于AST的解释器的缺陷
其实,我们目前采用的解释器,是一种最简单的解释器。它有时被称为“树遍历解释器”(Tree-walking Interpreter)或者更简单一点也被叫做“AST解释器”。
为什么我刚刚会说我们这个基于AST的解释器有点凑合呢你可能会想通过遍历AST来执行程序不是挺好的吗
确实AST解释器虽然简单但很有用。比如最常见的就是对一个表达式做求值实现类似公式计算的功能这在电子表格等系统里很常见。甚至在MySQL中也是基于AST做表达式的计算还有一些计算机语言的早期版本如Ruby以及一些产品中自带的脚本功能也是用AST解释器就足够了
不过虽然AST解释器很有用但它仍然有明显的缺陷。**最主要的问题就是性能差在运行时需要消耗更多的CPU时间和内存。**在上一节课里你可能使用过我们的函数特性计算过斐波那契数列在参数值比较大的情况下比如n大于30以后你会看到程序的运行速度确实比较慢。
为什么会这样呢你再来看我们的解释器会发现它哪怕只是做一个表达式求值也要层层做很多次的函数调用。比如简单的计算2+3\*5需要做的调用包括
```plain
visitBlock()
visitStatement()
visitExpressionStatement()
visitBinary() //+号
visitIntegerLiteral() //2
visitBinary() //*号
visitIntegerLiteral() //3
visitIntegerLiteral() //5
```
从表面上看起来这只是做了8次的函数调用。其实如果你仔细看我们代码的细节就会发现由于我们的程序采用了Visitor模式每一次调用还都有一个Visitor.visit(AstNode)和AstNode.accept(Visitor)来回握手的过程所以加起来一共做了24次函数调用。
这样的函数调用的开销是很大的。在上一节课你已经知道了每次函数调用都需要创建一个栈桢这会导致内存的消耗。调用函数和从函数返回也都需要耗费额外的CPU时间。在后面的课程里等我们对程序的运行时机制的细节了解得更清楚以后你会更加理解这些额外的开销发生在什么地方。
除了性能问题AST解释器还有其他的问题。比如我们已经看到在实现Return语句的时候需要额外的冗余处理以便跳过Return后面的语句类似的情况还发生在Break、Continue等语句中。总的来说在控制流的跳转方面用AST都不方便。
还有我们执行函数调用的时候需要从函数调用的AST节点跳到函数声明的节点这让人有点眼花缭乱。如果我们后面支持类、Lambda等更加丰富的特性需要运行类的构造函数、进行类成员的初始化查找并执行正确的父类或子类的方法那么程序的执行过程会更加让人难以理解。
而且从根本上来说AST这种数据结构比较忠实地体现了高级语言的语法特征。而高级语言呢是设计用来方便人类理解的并不是适合机器执行的。把计算机语言从适合人类阅读变成适合机器执行本来就是编译器要做的事情。不过把高级语言变成AST我们叫做解析Parse还不能称上是编译Compile。要称得上编译要对程序的表示方式做更多的变换才行。
那接下来呢,我们就探讨一下如何把程序编译成对机器更友好的方式。按照循序渐进的学习原则,我们不会一下子就编译成机器码,而是先编译成字节码,并试着实现一个虚拟机。
## 初步了解虚拟机
说到虚拟机我们大多数人都不陌生。比如Java语言最显著的特征之一就是运行在虚拟机上而不是像C语言或Go语言那样编译成可执行文件直接运行.NET也是用了类似的架构。就算现在Java支持AOTAhead of Time编译方式了它的可执行文件中仍然打包了一个小型的虚拟机以便支持某些特别的语言特性。
至于其他语言如Python、JavaScript等虽然我们不怎么提及它们的虚拟机但它们通常都是基于虚拟机来运行的。
虚拟机如此流行,不是偶然现象,因为它提供了一些明显的优点。其中最值得注意的,就是**程序可以独立于具体的计算机硬件和操作系统而运行**。所以我们在任何设备上无论是Mac电脑、Windows电脑、安卓手机还是iPad都可以用浏览器打开一个页面并运行里面的JavaScript。而如果采用C语言那样的运行方式那么针对每种不同的CPU和操作系统的组合都要生成不同的可执行文件这对于像浏览器一样的很多应用场景显然都是很麻烦的。
虚拟机还能提供其他好处,比如通过托管运行提供更高的安全性,还有通过标准的方式使用不同计算机上的文件资源、网络资源、线程资源和图形资源等等。
实际上虚拟化是计算机领域的一个基本思路。比如云计算平台能够虚拟出很多不同的操作系统在基于ARM芯的Mac上可以仿真运行基于X86的Windows等等都是不同角度的虚拟化。
**所以说,要实现一门现代计算机语言,就不能忽视虚拟机方面的知识。**
那语言的虚拟机都要包含哪些功能呢?又是由哪些部分构成的呢?
介绍虚拟机的文章很多特别是针对像JVM和安卓的ART这样广泛使用的平台有专门的书籍和课程来深入剖析。在我们的课程里由于我们自己要实现一下虚拟机因此我也会简单介绍一下虚拟机的原理和构成当然更多的就要靠你动手实践来掌握虚拟机的精髓了。
总的来说,虚拟机可以看做是一台虚拟的计算机,它能像物理计算机一样,给程序提供一个完整的运行环境。
首先,虚拟机像物理计算机一样,会支持一个指令集。我们的程序按照这个指令集编译成目标代码后,就可以在虚拟机上执行了。
第二虚拟机也像物理计算机一样提供内存资源。比如在JVM中你可以使用栈和堆两种内存。根据不同语言的内存管理机制的不同在虚拟机里通常还要集成垃圾收集机制。
第三,虚拟机要像物理计算机一样,能够加载程序的代码并执行。程序的目标代码文件中,除了可执行的代码(也就是虚拟机指令),还会包含一些静态的数据,比如程序的符号表,这会让你的程序支持元编程功能,比如运行时的类型判断、基于反射来运行代码等等。静态数据还包括程序中使用的一些常量,比如字符串常量、整数常量等等。对于代码和静态数据,会被虚拟机放在特定的区域,并且能够被指令访问。
此外虚拟机还要在IO、并发等方面提供相应的支持。这样我们才可以实现像在终端打印字符这样的功能。
好了,我们已经大概了解了虚拟机相关的概念。不过,不同的虚拟机在运行代码的机制方面是有所区别的,这也会影响到字节码的设计和算法的实现,所以我们现在展开介绍一下。
你可能听说过寄存器机和栈机,这就是比较流行两种程序运行机制。
## 栈机和寄存器机
使用栈机的典型代表就是JVM它能够运行Java的字节码。Web Assembly是为浏览器设计的字节码它的设计也是栈机的架构。
使用寄存器机的典型代表是能够运行JavaScript的V8V8里面有一个基于寄存器机的字节码解释器。而Lua和Erlang内部也是采用了寄存器机作为程序的运行机制其实我们现在使用物理计算机也是寄存器机。
**栈机和寄存器机的主要区别,是获取操作数的方式不同:栈机是从操作数栈里获取操作数,而寄存器机是从寄存器里获取。**比如要计算“a+3”这个表达式虚拟机通常都提供了一个指令用来做加法。a和3呢则是加法指令的操作数其中a是一个本地变量其值为2另一个操作数是常量3。那怎么完成这个加法操作呢
栈机的运行方式是先把a的值从内存取出来压到一个叫做操作栈的区域使用load指令然后把常量3压到操作数栈里使用push指令接着执行add指令。在执行add指令的时候就从操作数栈里弹出a和3做完加法以后再把结果5压到栈里。
![](https://static001.geekbang.org/resource/image/99/d9/99753bda45c78379ffe7ab1276cb7bd9.jpg?wh=1407x777)
而寄存器机的运行方式是先把a的值加载到寄存器在执行add指令的时候从这寄存器取数加上常量3以后再把结果5放回到寄存器。
![](https://static001.geekbang.org/resource/image/4a/3c/4a3329c35a7694c279a27697e8b1453c.jpg?wh=1708x812)
总结起来,栈机和寄存器机的区别有这三个方面:
**第一,操作数的存储机制不同**。栈机的操作数放在操作栈,操作数栈的大小几乎不受限制;而寄存器机的操作数是放在寄存器里,寄存器的数量是有限的。需要说明的是,对于物理机来说,寄存器指的是物理寄存器;而对于虚拟机来说,寄存器通常只是几个本地变量。但因为这些变量被频繁访问,根据寄存器分配算法,它们有比较大的概率被映射成物理寄存器,从而大大提高运行性能。
**第二,指令的格式不同**。寄存器机的指令需要在操作数里指定运算所需的源和目的操作数。而栈机的运算性的指令比如加减乘除等是不需要带操作数的因为操作数就在栈顶而像push、load这样的指令是把数据压到栈里也不需要指定目的地因为这个数据也一定是存到栈顶的。
**第三,生成字节码的难度不同**。从AST生成栈机的代码是比较容易的你在后面就可以体会到。而生成寄存器机的代码的难度就更高一些因为寄存器的数量是有限的我们必须要添加寄存器分配算法。
了解了栈机和寄存器机的这些差别以后,我们就可以根据自己的需求做取舍。比如,如果要更关注运行性能,就选用寄存器机;而如果想实现起来简单一点,并且指令数量更少,便于通过网络传输,我们就可以用栈机。
好了,现在我们已经初步了解了两种运行机制和两类指令集的特点了,是时候设计我们自己的虚拟机和字节码了!
## 设计我们自己的虚拟机
设计一个虚拟机是一项挺有挑战的工作,不过,如果我们仍然采取先迈出一小步,然后慢慢迭代的思路,就没那么复杂了。
在实现一个虚拟机之前,有些关键的技术决策是要确定一下的,这些决策影响到虚拟机的特性和我们所采用的技术。
**决策1选择栈机还是寄存器机**
其实,栈机和寄存器都能满足我们对于程序运行的核心需求,因为我们目前对性能、字节码的大小都没有什么特别的要求。不过,经过思考,**我最终选择了栈机**,主要有这几个考虑:
首先Java的JVM用的就是栈机而且讲述JVM的资料也很多方便我们借鉴和学习它成熟的设计思路。
第二,由于我们最后是要生成面向物理机的机器码,而物理机就是寄存器机,所以我们肯定会学到这方面的知识。从扩大知识面的角度,我们在虚拟机层面熟悉一下栈机就更好了。
第三Web Assembly是一个很有前途的技术领域目前各门语言都在添加编译成Web Assembly的工作。而自己动手实现一个栈机的经验有助于我们理解Web Assembly为未来支持Web Assembly打下基础。
**决策2字节码如何设计**
这个决策也简单。**既然已经选择了栈机的机制那就参考最成熟的Java的字节码就好了**。这样我们只要能生成类似于Java的字节码就能保证编译程序的正确性。
说到这里你其实还想到一个可能性是不是可以把TypeScript编译以后放在JVM上运行呢
这是完全可能的这样的工作也很有意义你将来如果有需要可以做这样的一个实现。作为参考我前一阵在技术会议上认识的一个极客朋友做了一项很有意思的工作就是把游戏领域的DSL编译成.NET的字节码从而驱动游戏引擎。通过这个核心技术他开发了一个低代码游戏开发平台大大降低了开发游戏的成本提高了开发效率。不过由于JVM不是我们这个课程的重点我们这个课程就不去专门生成能跑在JVM上的字节码文件了。
**决策3最先支持哪些数据类型**
字节码的设计跟语言特性是紧密相关的。比如在Python的字节码中所有操作都是针对对象的因为在Python中所有的数据都是对象化的。而在Java语言中数据类型包括基础数据类型和对象两类所以相应的指令也分为了两类一类用于操作整型、浮点型等基础数据类型一类用于操作对象。
TypeScript/JavaScript语言提供了number、string、boolean等内置数据类型也支持自定义类型。但我们目前还没有探讨到像class这样的自定义类型机制。不过到目前为止我们的语言特性基本上只支持数值运算。
**所以第一步我们就先只支持number类型就好了。甚至为了简单起见我们就只支持整型运算就好了。**一旦通过整型把整个实现机制跑通,再去支持其他数据类型也就没那么复杂了。
**决策4采用什么语言来实现虚拟机**
到目前为止我们的课程采用的语言一直是TypeScript和JavaScript但虚拟机却对运行性能的要求比较高所以最好能够进行一些底层的控制比如如何为栈桢分配内存、如何最高效地存储和访问常量等等所以真实世界的虚拟机大多是采用C和C++实现的。
所以在这节课中,我们采取这样的策略:**一方面采用TypeScript语言做原型**,这样可以帮助我们迅速理清楚设计思路,掌握原理。**另一方面我们又会用C语言做一个参考实现**这样我们能够充分享受到采用基于字节码的虚拟机所带来的优点。好消息是大部分同学都或多或少接触过C语言所以理解和使用起来并不会特别困难。
## 课程小结
今天这节课,我们迈入了一个新的领域,也就是采用虚拟机作为新的运行机制。
相比我们之前运行的AST解释器运行字节码的虚拟机的性能更高更容易运行流程控制语句。而且字节码也是更加面向机器的代码表示方式因此也更便于机器运行但不利于人类阅读。
而虚拟机中有一个重要部分,就是程序的运行机制。对运行机制的了解是你实现一门语言时所需要的基本功。今天我们初步介绍了栈机和虚拟机两种比较流行的程序运行机制的区别,主要是在操作数的存储机制、指令的格式和生成字节码的难度这三点上,在后面的课程里,你会对这两种运行机制有更加深入的了解。
最后我们对虚拟机做了一些设计决策。我们选择了栈机决定参考最成熟的Java的字节码最先实现number类型和整型运算而且我还会带你用TypeScript和C语言实现两种虚拟机。虽然这节课还没有做具体的实现但理清设计思路可能比实现还重要特别是面对虚拟机这样一个比较新的话题时。不要着急我们下一节就会实现出一个简单的栈机
## 思考题
你所使用的语言有没有内置一个虚拟机?有什么特点?里面有没有字节码解释器?它是栈机还是寄存器机?欢迎你和我分享一下。
另外如果你是带着实际的项目需求来学习这门课程的。那么对于你的需求AST解释器是否够用还是要必须采用其他的运行机制为什么
感谢你和我一起学习,如果你觉得我这节课讲得还不错,也欢迎你把它分享给更多对编程语言感兴趣的朋友。我是宫文学,我们下节课见。