gitbook/手把手带你写一门编程语言/docs/412865.md
2022-09-03 22:05:03 +08:00

202 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 10基于C语言的虚拟机实现一个简单的栈机
你好,我是宫文学。
到目前为止我们已经用TypeScript实现了一个小而全的虚拟机也在这个过程中稍微体会了一下虚拟机设计的一些要点比如字节码的设计、指令的生成和栈机的运行机理等等而且我们还通过性能测试也看到了栈机确实比AST解释器的性能更高。
虽然上面这些工作我们都是用TypeScript实现的但既然我们已经生成了字节码我不由地产生了一个想法我们能不能用C语言这样的更基础的语言来实现一个虚拟机同样来运行这些字节码呢
我这样的想法可不是凭空产生的。你看,字节码最大的好处,就是和平台无关的能力。不管什么平台,只要有个虚拟机,就可以运行字节码,这也是安卓平台一开始选择字节码作为运行机制的原因。你甚至也可以来试一试,假设现在时间回到智能手机刚出现的时代,你是否也能够快速设计一个虚拟机,来运行手机上的应用呢?
那么进一步在这种移动设备上运行的应用很重要的功能就是去调用底层操作系统的API。用C和C++实现的虚拟机显然在这方面有优势能够尽量降低由于ABI转换所带来的性能损失。
所以,**这一节课我就带你用C语言重新实现一遍虚拟机**。在这个过程中你会对字节码文件的设计有更细致的体会对于符号表的作用的理解也会加深也会掌握如何用C语言设计栈桢的知识点。
好了,我们首先实现第一步的目标,把程序保存成字节码文件,再把字节码文件加载到内存。
## 读写字节码文件
在TypeScript版的虚拟机中我们用了模块BCModule来保存程序的相关信息有了这样一个模块程序就可以生成一个独立的字节码文件就像Java语言里每个.java文件会编译生成一个.class文件那样。
**首先,我们要先来定字节码的文件格式。**
我们也说过Java语言的字节码文件是依据了专门的技术规范。其实我们仍然可以采用Java字节码文件的格式你查阅相应的技术规格就可以。这样的话我们编译后的结果就直接可以用Java虚拟机来运行了
有时间的同学可以做一下这个尝试。这项工作在某些场景下会很有意义。你可以定义自己的DSL直接生成字节码跟Java编写的程序一起混合运行。我认识的一位极客朋友就做了类似的使用用低代码的编程界面直接生成了.NET的字节码形成了一个基于Unity的游戏开发平台。
不过,我们目前的语言比较简单,所以不用遵循那么复杂的规范,我们就设计自己的文件格式就好了。
**第二,我们需要考虑:保存什么信息到字节码文件里,才足够用于程序的运行?**
从我们目前实现的虚拟机来看,其实不需要太多的信息。你可以回忆一下,其实要保证程序的运行,只需要能够从常量表里查找到函数的一些基本信息即可,最重要的信息包括:
* 这个函数的字节码;
* 这个函数有几个本地变量?我们需要在栈桢里保留存储位置;
* 这个函数的操作数栈的最大尺寸是多少?也就是最多的时候,需要在栈里保存几个操作数,以便我们预留存储空间。
除了这些信息外再就是我们的代码里用到了的部分数字常量也需要从常量表里加载就像ldc指令那样。
所以说,只要把函数常量、数字常量存成字节码文件,就足够我们现在的虚拟机使用了。你也可以看到,我们现在甚至连函数名称、函数的签名都不需要,如果需要的话,也是为了在运行期来显示错误信息而已。
不过,如果把函数名称和函数签名的信息加进去,会有利于我们实现多模块的运行机制。也就是说,如果一个模块中的函数要调用另一个模块中的函数,那么我们可以创造一种机制,实现模块之间的代码查找。
这其实就是Java的类加载机制和多个.class文件之间互相调用的机制我们仍然可以借鉴。而且即使像C语言那样的编译成本地代码的语言也是通过暴露出函数签名的信息来实现多个模块之间的静态链接和动态链接机制的。
那再进一步,既然需要函数签名,那么我们就需要知道一些类型信息,比如往函数里要传递什么类型的参数,返回的是什么类型的数据,这样调用者和被调用者之间才能无缝衔接到一起。
像C语言这样的系统语言以及在操作系统的ABI里支持的都是一些基础的数据类型的信息比如整数、浮点数、整数指针、字符串指针之类的。而像Java等语言它们建立了具有较高抽象度的类型体系还可以包含这些高级的类型信息从而实现像运行时的类型判断、通过反省的方式动态运行程序等高级功能。
上面这几段的分析总结起来,就是我们需要往目标文件里或多或少地保存一些类型信息。
那么现在就清楚了,我们需要把常量信息和类型信息写到字节码里,就足够程序运行了。
**现在,我们来到了第三个步骤:序列化。**
具体来说,序列化就是把这些信息以一定的格式写到文件里,再从文件里恢复的过程,是一个比较啰嗦的、充满细节的过程。也就是说,我们在内存里是一种比较结构化的数据,而在文件里保存,或者通过网络传输,都是采用一个线性的数据结构。
在我的编程经验里所有这些序列化的工作都比较繁琐但大致的实现方式都是一样的。无论是保存成二进制格式、XML格式、json格式还是基于一种网络协议在网络上传输都是一个把内存中的数据结构变成线性的数据结构然后再从线性的数据结构中恢复的过程。
你可以看看BCModuleWriter和BCModuleReader中的代码实现的技巧也很简单**最重要的就是你要知道每个数据占了多少个字节**。比如,当你向文件里写一个字符串的时候,你先要写下字符串的长度,再写字符串的实际数据,用这样的方法,当你读文件的时候,就能把相关信息顺利还原了。
**这类程序中稍微有点难的地方,是保证对象之间正确的引用关系**。比如,函数引用了变量和类型,而高级的类型之间也是互相有引用关系的,比如子类型的关系等等,这样就构成了一张网状的数据结构,相互之间有引用。
当你写入文件的时候,要注意,这个网的每个节点只能写一次,不能因为两个函数的返回值都引用了某个类型,就把这个类型写了两次。在读的时候呢,则要重新建立起对象之间正确的引用关系。
好了了解了实现思路以后再阅读相应的示例代码就很容易了。在TypeScript中我用BCModuleWriter把斐波那契数列程序的字节码写成了文件然后用hexdump命令来显示一下看看
![](https://static001.geekbang.org/resource/image/d2/69/d2ff1d5709a2f55d8f3663ebe1580669.jpg?wh=2236x861)
乍一看这个跟Java的字节码文件还挺像的不过我们用的是自己的简单格式。我在图中做了标注标明了字节是什么含义。其中\_main函数和fibonacci函数的字节码指令我也标了出来。
之后我可以用BCModuleReader把这个字节码文件再读入内存重建BCModule包括里面的符号信息。如果基于这个新的BCModule程序同样可以顺畅地运行那就说明我们的字节码文件里面确实包含了足够的运行信息。
好了现在我们的字节码文件以及相应的读写机制已经设计成功也用TypeScript做完了所有的设计验证。在这个基础上重新用C语言实现一个虚拟机就是一个比较简单的事情了。你可以发现虽然我们的语言换了但虚拟机的实现机制没有变。
接下来我们就需要用C语言把字节码文件读到内存并在内存重建BCModule相关的各种对象结构。
## 用C语言读入字节码文件
关于C语言版本的字节码读取程序你可以参考一下readBCModule函数的代码。在读取了字节码文件以后我还写了一个dumpBCModule的函数可以在控制台显示BCModule的信息如下图所示
![图片](https://static001.geekbang.org/resource/image/74/c5/747504b30c7c85ce648f647bf77ca1c5.png?wh=1726x1249)
你可能会注意到我们最后的模型里的类型信息和函数都比字节码文件里的要多。不要担心多出来的其实是系统内置的类型比如number类型和内置函数比如println它们不需要被保存在字节码文件里但是会被我们的程序引用到所以我们要在内存的数据结构中体现。
在这里,我重新梳理一下内存里的对象模型,这个对象模型就是我们运行时所需要的所有信息。我们读取了字节码文件以后,会在内存里形成这个结构化的对象模型,来代表一个程序的信息。
![](https://static001.geekbang.org/resource/image/7e/1b/7e8abe4d347ac8b9264619078213e01b.jpg?wh=2248x1300)
这里我再讲一个小技术点看着上面的类图你可能会问C语言不是不支持面向对象吗你为什么还能用面向对象的方式来保存这些信息
其实用C语言也能模拟类似面向对象的机制。以符号为例我们是这样声明Symbol和FunctionSymbol的让FunctionSymbol包含基类Symbol中的数据
```plain
typedef struct _Symbol{
char* name; //符号名称
Type* theType; //类型
SymKind kind; //符号种类
} Symbol;
typedef struct _FunctionSymbol{
Symbol symbol; //基类数据
int numVars; //本地变量数量
VarSymbol ** vars; //本地变量信息
int opStackSize; //操作数栈大小
int numByteCodes; //字节码数量
unsigned char* byteCode; //字节码指令
} FunctionSymbol;
```
在内存里FunctionSymbol最前面的字段就是Symbol的字段因此你可以把FunctionSymbol的指针强制转换成Symbol的指针从而访问Symbol的字段。这种编程方式在一些用C语言编写的系统软件里非常普遍包括其他作者写的一些编译器的代码以及Linux操作系统内核中的代码中都有体现。
![](https://static001.geekbang.org/resource/image/9c/f9/9c931084500bff407ef9c764755987f9.jpg?wh=2248x1305)
好了,现在我们已经成功地读入了字节码文件,接下来就让我们运行它吧!
## 用C语言实现栈机
现在我们要用C语言再重新实现一遍栈机这也很快因为机理我们前面都梳理清楚了。实际上整个C语言版本的虚拟机的代码我就用了一个周末就从TypeScript版本中移植到了C语言中。我也鼓励你用自己熟悉的语言多做几次移植的工作每做一遍你对虚拟机的内在机制的理解就会更加深入。
好,我来描述一下这个移植过程中的重点工作。
**首先,你仍然可以建立一个枚举数据,来描述我们所使用的指令集,也让程序更容易阅读:**
![图片](https://static001.geekbang.org/resource/image/6d/cc/6d88b6af53a1cdb0c858fb86281343cc.png?wh=816x1992)
**第二,设计运行栈的数据结构。**我用了一个链表来把各个栈桢链接在一起:
```plain
typedef struct _StackFrame{
//本栈桢对应的函数,用来找到代码
FunctionSymbol* functionSym;
//返回地址
int returnIndex;
//本地变量数组
NUMBER* localVars;
//操作数栈
OprandStack* oprandStack;
//指向前一个栈桢的链接
struct _StackFrame * prev;
}StackFrame;
```
在运行的时候,我们只要保持一个指向栈顶的指针就能访问栈桢中的数据。
![](https://static001.geekbang.org/resource/image/85/68/85a95efce93297783e335a48bb3a5068.jpg?wh=2248x664)
**第三,设计操作数栈。**在栈桢中,你会看到有一个操作数栈,我们用一个数组来表示操作数栈就可以了,然后用一个字段指向当前的栈顶。
```plain
/**
* 操作数栈
* 当栈为空的时候top = -1;
* */
typedef struct _OprandStack{
NUMBER * data; //数组
int top; //栈顶的索引值
}OprandStack;
```
**最后,就是做一个解释器,解释执行字节码。**这里解释器的实现跟TypeScript版本的没啥区别就是一个无穷循环不断读取字节码指令并根据指令做相关的动作我就不重复介绍了你可以回顾一下08节的内容。
到这里整个移植工作就算完成了。整个代码也就1000来行其中大部分代码都是House Keeping类型的代码用于完成字节码读写、打印调试信息等啰嗦的工作核心代码并不多。所以你在阅读代码的时候也不要有什么心理负担。
现在到了检验我们新版本虚拟机的时候了。我们像之前一样运行斐波那契数列的示例程序检验一下基于C语言实现的虚拟机的性能到底如何。
## 性能比拼和优化设计
其实我在一开头是期望新版虚拟机的性能是能够马上碾压前两个运行时的也就是AST解释器和TypeScript栈机。可是实际运行结果却出乎我的预料**新版虚拟机的性能只有TypeScript栈机的一半甚至连AST解释器的性能也比不上**。
你可能不相信,这怎么可能呢?不过,性能测试的数字是不会说谎的,你自己也可以去运行一下测试用例试试看。
在测试用例中我打印了n从30到36的斐波那契数列对比了各个解释器的性能
![](https://static001.geekbang.org/resource/image/1a/10/1abdb8f9f595ddbc3b6836c861d52210.jpg?wh=2248x1202)
我还绘制成了图表,这样能够让性能比较看上去更直观:
![图片](https://static001.geekbang.org/resource/image/32/5e/326553a6cee67528570538f7c2691f5e.png?wh=1418x756)
你在前一节已经看过了AST解释器和TypeScript版栈机的性能对比曲线了。现在加上了C语言版栈机的数据它的曲线竟然是在最上面的也就是最慢的。
**接下来的问题来了如何评价这个评测结果呢难道用C语言编写性能还不如JavaScript吗你能帮我思考一下原因吗**
对于这个问题的分析和解决,我想放到下一节课。在分析和解决这个问题的过程中,你也会对虚拟机设计上的一些重要因素产生更深入的了解。
## 课程小结
好了,今天这节课到这里就结束了,让我们来简单回顾一下。
这一节课,我们实现了一个简单版本的虚拟机,主要工作包括几个方面:
首先,我们设计了一个字节码文件的格式,并分析了需要保存哪些信息,才能让字节码的程序运行起来,这些分析有助于你理解二进制目标文件的设计。
为了让程序运行起来,我们其实只需要有每个函数编译后形成的字节码指令,以及指令中会引用到的常量值就可以了。但我们还会保存一些符号信息和类型信息,以便支持模块之间连接,让虚拟机未来能够加载和运行多个模块。在后面的课程中,我们会把汇编代码编译成二进制目标文件,这个原理其实是相通的。
第二我们需要编写程序实现内存中的数据结构与顺序保存的文件数据之间的转换。这是一个程序员需要掌握的基本功重建对象之间的引用关系是其中的难点往往需要引入一些辅助的数据结构比如示例代码中的SimpleTypeInfo和FunctionTypeInfo。
第三用C语言实现栈桢、操作数栈和一个栈机这个过程我们只是做代码移植与TypeScript版的栈机的原理是一样的。
最后我们实现了一个初版的C语言栈机但很遗憾这个虚拟机性能并没有达到我们的预期。关于这个版本的虚拟机性能不佳的问题我们会在下一节课去详细分析并解决。
## 思考题
今天我们的思考题是通过阅读和分析代码你能找出C语言版本的栈机性能比较低的原因吗希望你能在进入下一节课前独立分析一下并在下一节课验证一下你的想法。
感谢你和我一起学习,也欢迎你把这节课分享给更多对字节码虚拟机感兴趣的朋友。我是宫文学,我们下节课再见!
## 资源链接
[这节课的示例代码在这里!](https://gitee.com/richard-gong/craft-a-language/tree/master)