虚拟机
魔术师保护他们的秘密,不是因为秘密很大很重,而是因为它们太小太微不足道。舞台上产生的奇妙效果,往往源于一个如此荒谬的秘密,以至于魔术师会羞于承认这就是它的运作方式。
克里斯托弗·普里斯特,The Prestige
我们花了很长时间讨论如何将程序表示为一系列字节码指令,但这感觉就像只用填充的、死去的动物来学习生物学。我们理论上知道指令是什么,但我们从未亲眼目睹过它们在行动,因此很难真正理解它们的作用。当我们不了解字节码的行为时,编写输出字节码的编译器会很困难。
所以,在我们开始构建新解释器的前端之前,我们将从后端—执行指令的虚拟机开始。它赋予字节码生命。观察指令四处跳跃,让我们更清楚地了解编译器如何将用户的源代码转换为一系列指令。
15 . 1指令执行机器
虚拟机是我们解释器内部架构的一部分。你将一段代码—实际上是一个Chunk—交给它,它就会运行它。VM 的代码和数据结构驻留在一个新的模块中。
创建新文件
#ifndef clox_vm_h #define clox_vm_h #include "chunk.h" typedef struct { Chunk* chunk; } VM; void initVM(); void freeVM(); #endif
像往常一样,我们从简单开始。VM 将逐渐获得需要跟踪的大量状态,因此我们现在定义一个结构来将所有这些状态都塞进去。目前,我们只存储它执行的块。
就像我们对创建的大多数数据结构所做的那样,我们还定义了用于创建和销毁 VM 的函数。以下是实现
创建新文件
#include "common.h" #include "vm.h" VM vm; void initVM() { } void freeVM() { }
好吧,将这些函数称为“实现”有点牵强。我们还没有任何有趣的状态需要初始化或释放,因此这些函数是空的。相信我,我们会做到的。
这里稍微有趣一点的是vm
的声明。这个模块最终将有一系列函数,将 VM 的指针传递给所有函数将是一件苦差事。相反,我们声明了一个全局 VM 对象。我们只需要一个,这使书中的代码在页面上稍微轻便一些。
在我们开始将有趣代码注入 VM 之前,让我们先将它连接到解释器的主要入口点。
int main(int argc, const char* argv[]) {
在main()中
initVM();
Chunk chunk;
解释器第一次启动时,我们启动 VM。然后,当我们即将退出时,我们将其关闭。
disassembleChunk(&chunk, "test chunk");
在main()中
freeVM();
freeChunk(&chunk);
最后一个仪式上的义务
#include "debug.h"
#include "vm.h"
int main(int argc, const char* argv[]) {
现在,当您运行 clox 时,它会在创建上一章中手工编写的块之前启动 VM。VM 已准备就绪,让我们教它做点什么。
15 . 1 . 1执行指令
当我们命令 VM 解释一段字节码时,它就会开始行动。
disassembleChunk(&chunk, "test chunk");
在main()中
interpret(&chunk);
freeVM();
这个函数是 VM 的主要入口点。它被声明如下
void freeVM();
在freeVM()之后添加
InterpretResult interpret(Chunk* chunk);
#endif
VM 运行块,然后使用此枚举中的值进行响应
} VM;
在 struct VM之后添加
typedef enum { INTERPRET_OK, INTERPRET_COMPILE_ERROR, INTERPRET_RUNTIME_ERROR } InterpretResult;
void initVM(); void freeVM();
我们还没有使用结果,但是当我们有一个报告静态错误的编译器和一个检测运行时错误的 VM 时,解释器将使用它来了解如何设置进程的退出代码。
我们正在逐步接近实际的实现。
在freeVM()之后添加
InterpretResult interpret(Chunk* chunk) { vm.chunk = chunk; vm.ip = vm.chunk->code; return run(); }
首先,我们将正在执行的块存储在 VM 中。然后我们调用run()
,这是一个实际运行字节码指令的内部辅助函数。在这两个部分之间是一行有趣的代码。什么是ip
?
当 VM 处理字节码时,它会跟踪它所在的位置—正在执行的指令的位置。我们不会在run()
内部使用局部变量来实现这一点,因为最终其他函数将需要访问它。相反,我们将其存储为 VM 中的一个字段。
typedef struct { Chunk* chunk;
在 struct VM中
uint8_t* ip;
} VM;
它的类型是字节指针。我们使用一个真正的 C 指针直接指向字节码数组的中间,而不是像整数索引那样的东西,因为取消引用指针比通过索引查找数组中的元素更快。
名称“IP”是传统的,并且—与 CS 中许多传统名称不同—实际上是有意义的:它是一个指令指针。几乎所有现实世界和虚拟世界中的指令集都包含一个像这样的寄存器或变量。
我们通过将ip
指向块中代码的第一个字节来初始化它。我们还没有执行该指令,因此ip
指向即将执行的指令。在 VM 运行的整个过程中,这将始终为真:IP 始终指向下一个指令,而不是当前正在处理的指令。
真正的乐趣在run
()中发生。
在freeVM()之后添加
static InterpretResult run() { #define READ_BYTE() (*vm.ip++) for (;;) { uint8_t instruction; switch (instruction = READ_BYTE()) { case OP_RETURN: { return INTERPRET_OK; } } } #undef READ_BYTE }
这是 clox 中所有函数中最重要的函数,远远超过其他函数。当解释器执行用户的程序时,它将花费大约 90% 的时间在run()
中。它是 VM 的跳动的心脏。
尽管有如此戏剧性的介绍,但它在概念上非常简单。我们有一个外部循环,它会一直运行。循环中的每次迭代,我们都会读取并执行一条字节码指令。
为了处理指令,我们首先弄清楚我们正在处理什么类型的指令。READ_BYTE
宏读取当前由ip
指向的字节,然后前进指令指针。任何指令的第一个字节都是操作码。给定一个数字操作码,我们需要找到实现该指令语义的正确 C 代码。这个过程被称为解码或分派指令。
我们对每条指令都执行这个过程,每次执行一条指令时都执行一次,因此这是整个虚拟机中性能最关键的部分。编程语言传说充满了巧妙的技术,可以有效地进行字节码分派,这些技术可以追溯到计算机的早期阶段。
唉,最快的解决方案要么需要 C 的非标准扩展,要么需要手写汇编代码。对于 clox,我们将保持简单。就像我们的反汇编程序一样,我们有一个包含每个操作码的 case 的大型switch
语句。每个 case 的主体实现该操作码的行为。
到目前为止,我们只处理一条指令OP_RETURN
,它唯一做的就是完全退出循环。最终,该指令将用于从当前 Lox 函数返回,但我们还没有函数,因此我们将暂时将其重新用于结束执行。
让我们继续支持我们的一条其他指令。
switch (instruction = READ_BYTE()) {
在run()中
case OP_CONSTANT: { Value constant = READ_CONSTANT(); printValue(constant); printf("\n"); break; }
case OP_RETURN: {
我们还没有足够的机制来做任何有用的事情。现在,我们只将其打印出来,以便我们可以让解释器黑客看到我们的 VM 内部正在发生的事情。对printf()
的调用需要一个包含文件。
添加到文件顶部
#include <stdio.h>
#include "common.h"
我们还需要定义一个新的宏。
#define READ_BYTE() (*vm.ip++)
在run()中
#define READ_CONSTANT() (vm.chunk->constants.values[READ_BYTE()])
for (;;) {
READ_CONSTANT()
从字节码中读取下一个字节,将结果数字视为索引,并在块的常量表中查找相应的 Value。在后面的章节中,我们将添加一些更多具有引用常量的操作数的指令,因此我们现在正在设置这个辅助宏。
与之前的READ_BYTE
宏一样,READ_CONSTANT
仅在run()
中使用。为了使该范围更明确,宏定义本身被限制在该函数中。我们在开头定义它们,并且—因为我们关心—在结尾取消定义它们。
#undef READ_BYTE
在run()中
#undef READ_CONSTANT
}
15 . 1 . 2执行跟踪
如果您现在运行 clox,它将执行我们在上一章中手工编写的块,并将1.2
输出到您的终端。我们可以看到它正在工作,但这仅仅是因为我们对OP_CONSTANT
的实现具有临时代码来记录值。一旦该指令完成它应该做的事情,并将该常量传递给想要使用它的其他操作,VM 将变成一个黑盒子。这使我们作为 VM 实现者变得更加困难。
为了帮助我们自己,现在是给 VM 添加一些诊断日志的好时机,就像我们对块本身所做的那样。事实上,我们甚至会重复使用相同的代码。我们不希望始终启用此日志记录—它只供我们 VM 黑客使用,而不是供 Lox 用户使用—因此,首先我们创建一个标志来隐藏它。
#include <stdint.h>
#define DEBUG_TRACE_EXECUTION
#endif
当定义了这个标志时,VM 会在执行每条指令之前对其进行反汇编并打印出来。我们之前反汇编程序一次静态地遍历整个块,而这个反汇编程序会动态地、在运行时反汇编指令。
for (;;) {
在run()中
#ifdef DEBUG_TRACE_EXECUTION disassembleInstruction(vm.chunk, (int)(vm.ip - vm.chunk->code)); #endif
uint8_t instruction;
由于 disassembleInstruction()
使用整数字节 _偏移量_,并且我们将当前指令引用存储为直接指针,因此我们首先进行一些指针运算,将 ip
转换回从字节码开头开始的相对偏移量。然后,我们反汇编从该字节开始的指令。
一如既往,我们需要在调用函数之前引入函数的声明。
#include "common.h"
#include "debug.h"
#include "vm.h"
我知道到目前为止这段代码并不令人印象深刻—它实际上是一个在 for
循环中包装的 switch 语句,但,信不信由你,这是我们 VM 的两个主要组件之一。有了它,我们就可以命令式地执行指令。它的简洁是美德—它做的工作越少,执行速度就越快。将它与我们在 jlox 中使用访问者模式遍历 AST 所产生的所有复杂性和开销进行对比。
15 . 2值栈操作器
除了命令式副作用之外,Lox 还具有生成、修改和使用值的表达式。因此,我们编译的字节码需要一种方法在需要它们的指令之间传递值。例如
print 3 - 2;
我们显然需要针对常量 3 和 2、print
语句和减法运算的指令。但是,减法指令如何知道 3 是 被减数,而 2 是减数呢?打印指令如何知道打印该运算的结果呢?
为了更清晰地说明这一点,请看这里
fun echo(n) { print n; return n; } print echo(echo(1) + echo(2)) + echo(echo(4) + echo(5));
我将每个子表达式包装在一个 echo()
调用中,该调用打印并返回其参数。该副作用意味着我们可以看到确切的操作顺序。
不要担心 VM 一分钟。只考虑 Lox 本身的语义。算术运算符的操作数显然需要在执行运算本身之前进行求值。(如果你不知道 a
和 b
是什么,就很难进行 a + b
的加法运算。)此外,当我们在 jlox 中实现表达式时,我们 决定 左操作数必须在右操作数之前进行求值。
以下是 print
语句的语法树
考虑到左到右求值以及表达式的嵌套方式,任何正确的 Lox 实现都 _必须_ 按此顺序打印这些数字
1 // from echo(1) 2 // from echo(2) 3 // from echo(1 + 2) 4 // from echo(4) 5 // from echo(5) 9 // from echo(4 + 5) 12 // from print 3 + 9
我们旧的 jlox 解释器通过递归遍历 AST 来实现这一点。它执行后序遍历。首先,它递归遍历左操作数分支,然后是右操作数分支,最后它对节点本身进行求值。
在对左操作数进行求值之后,jlox 需要将该结果暂时存储在某个地方,以便它能够忙于遍历右操作数树。我们使用 Java 中的局部变量来实现这一点。我们的递归树遍历解释器为每个正在求值的节点创建一个唯一的 Java 调用帧,因此我们可以拥有任意数量的这些局部变量。
在 clox 中,我们的 run()
函数不是递归的—嵌套的表达式树被扁平化为一系列线性指令。我们没有使用 C 局部变量的便利,那么我们应该在哪里存储这些临时值呢?你可能已经 猜到 了,但我希望真正深入探讨这一点,因为这是我们习以为常的编程方面,但我们很少了解计算机为什么以这种方式构建。
让我们做个奇怪的练习。我们将一步一步地逐步执行上述程序。
左侧是代码步骤。右侧是我们跟踪的值。每个条形图代表一个数字。它从值首次生成—常量或加法的结果—开始。条形图的长度跟踪以前生成的值需要保留的时间,并在该值最终被操作消耗时结束。
在逐步执行时,你会看到值出现,然后被消耗。寿命最长的值是加法左侧生成的那些值。当我们处理右侧操作数表达式时,这些值会保留下来。
在上图中,我为每个唯一数字都分配了一个单独的视觉列。让我们稍微节俭一点。一旦一个数字被消耗,我们就允许它的列被另一个数字重复使用。换句话说,我们将上面的所有间隙填满,从右侧推入数字
这里发生了一些有趣的事情。当我们将所有内容都向左移时,每个数字仍然能够在整个生命周期内保持在一个单独的列中。此外,没有剩余的间隙。换句话说,只要一个数字比另一个数字先出现,那么它的寿命至少要比第二个数字长。第一个出现的数字也是最后一个被消耗的数字。嗯 . . . 后进先出 . . . 为什么,那是一个 堆栈!
在第二个图中,每次我们引入一个数字时,我们都会从右侧将其压入堆栈。当数字被消耗时,它们总是从最右侧到左侧弹出。
由于我们需要跟踪的临时值自然具有堆栈式行为,因此我们的 VM 将使用堆栈来管理它们。当一个指令 “生成” 一个值时,它会将其压入堆栈。当它需要消耗一个或多个值时,它会通过从堆栈中弹出它们来获取它们。
15 . 2 . 1VM 的堆栈
也许这看起来并不像一个重大发现,但我 _很喜欢_ 基于堆栈的 VM。当你第一次看到魔术表演时,你会觉得它实际上很神奇。但当你了解了它的运作原理—通常是某种机械装置或障眼法—之后,这种奇妙的感觉就会消失。在计算机科学中,有一些想法,即使在我将它们分解并了解了所有细节之后,最初的一些闪光仍然存在。基于堆栈的 VM 就是其中之一。
正如你将在本章中看到的那样,在基于堆栈的 VM 中执行指令非常 简单。在后面的章节中,你还会发现将源语言编译成基于堆栈的指令集非常容易。然而,这种架构的速度足以被生产语言实现使用。它几乎感觉像是作弊一样。
好了,是时候编写代码了!这是堆栈
typedef struct { Chunk* chunk; uint8_t* ip;
在 struct VM中
Value stack[STACK_MAX]; Value* stackTop;
} VM;
我们在原始 C 数组之上自行实现了堆栈语义。堆栈的底部—第一个被压入的值,也是最后一个被弹出的值—位于数组中的元素零,后面是后来压入的值。如果我们将 “crepe”—我最喜欢的可叠加早餐食物—的字母按顺序压入堆栈,则生成的 C 数组如下所示
由于堆栈在值被压入和弹出时会增长和缩小,因此我们需要跟踪数组中堆栈顶部的的位置。与 ip
一样,我们使用直接指针而不是整数索引,因为取消引用指针比每次需要时计算从索引的偏移量更快。
指针指向数组中 _紧邻_ 存储堆栈顶部值的元素的元素。这似乎有点奇怪,但几乎所有实现都这样做了。这意味着我们可以通过指向数组中的元素零来表明堆栈为空。
如果我们指向顶部元素,那么对于空堆栈,我们需要指向元素 -1。这在 C 中是 未定义 的。当我们将值压入堆栈 . . .
. . . stackTop
始终指向最后一个元素的下一位置。
我这样记住:stackTop
指向下一个要压入的值将要放置的位置。我们可以在堆栈上存储的最大值数量(至少目前是这样)是
#include "chunk.h"
#define STACK_MAX 256
typedef struct {
为我们的 VM 指定固定大小的堆栈意味着某些指令序列可能会压入过多的值,并导致堆栈空间不足—经典的 “堆栈溢出”。我们可以根据需要动态扩展堆栈,但现在我们将其保持简单。由于 VM 使用 Value,因此我们需要包含其声明。
#include "chunk.h"
#include "value.h"
#define STACK_MAX 256
现在 VM 有一些有趣的状态,我们可以对其进行初始化。
void initVM() {
在 initVM() 中
resetStack();
}
它使用以下辅助函数
在变量 vm 后添加
static void resetStack() { vm.stackTop = vm.stack; }
由于堆栈数组是直接在 VM 结构体中声明的,因此我们不需要分配它。我们甚至不需要清除数组中未使用的单元格—我们只会在值存储到这些单元格之后访问它们。我们唯一需要做的初始化是将 stackTop
设置为指向数组的开头,以表明堆栈为空。
堆栈协议支持两种操作
InterpretResult interpret(Chunk* chunk);
在 interpret() 后添加
void push(Value value); Value pop();
#endif
你可以将一个新值压入堆栈顶部,也可以将最近压入的值弹出。这是第一个函数
在freeVM()之后添加
void push(Value value) { *vm.stackTop = value; vm.stackTop++; }
如果你对 C 指针语法和操作不太熟悉,这是一个很好的热身练习。第一行将 value
存储在堆栈顶部的数组元素中。记住,stackTop
指向 _紧邻_ 最后一个使用元素的下一位置,也就是下一个可用的位置。这会将值存储在该位置。然后,我们递增指针本身,使其指向数组中下一个未使用的位置,因为上一个位置现在已被占用。
弹出是镜像操作。
在 push() 后添加
Value pop() { vm.stackTop--; return *vm.stackTop; }
首先,我们将堆栈指针 _移回_ 以到达数组中最新的使用位置。然后,我们查找该索引处的值并将其返回。我们不需要明确地将其 “删除” 出数组—将 stackTop
向下移动足以将其标记为不再使用。
15 . 2 . 2堆栈跟踪
我们有一个正在运行的堆栈,但很难 _看到_ 它正在运行。当我们开始实现更复杂的指令并编译和运行更大的代码段时,最终将有很多值塞进该数组中。如果我们能够看到堆栈的内容,那么我们的 VM 黑客生活将会变得更加轻松。
为此,无论何时跟踪执行情况,我们都会在解释每个指令之前显示堆栈的当前内容。
#ifdef DEBUG_TRACE_EXECUTION
在run()中
printf(" "); for (Value* slot = vm.stack; slot < vm.stackTop; slot++) { printf("[ "); printValue(*slot); printf(" ]"); } printf("\n");
disassembleInstruction(vm.chunk,
我们循环遍历数组,从第一个元素(栈底)开始打印每个值,直到到达栈顶。这让我们可以观察每条指令对栈的影响。输出非常详细,但在我们从解释器内部清除一个棘手的错误时非常有用。
有了栈,让我们重新审视我们的两个指令。首先
case OP_CONSTANT: { Value constant = READ_CONSTANT();
在run()中
替换两行
push(constant);
break;
在上一章中,我对 OP_CONSTANT
指令如何“加载”常量进行了粗略的解释。现在我们有了栈,你应该知道实际生成值的含义:它被压入栈中。
case OP_RETURN: {
在run()中
printValue(pop()); printf("\n");
return INTERPRET_OK;
然后我们让 OP_RETURN
弹出栈并打印栈顶的值,然后退出。当我们为 clox 添加对真实函数的支持时,我们将更改此代码。但现在,它让我们可以使 VM 执行简单的指令序列并显示结果。
15 . 3一个算术计算器
我们的 VM 的核心现在已经到位。字节码循环调度并执行指令。栈随着值流过它而增长和缩小。这两部分都起作用,但很难只用我们目前拥有的两个基本指令来了解它们是如何巧妙地相互作用的。所以让我们教我们的解释器进行算术运算。
我们将从最简单的算术运算,一元取反开始。
var a = 1.2; print -a; // -1.2.
前缀 -
运算符接受一个操作数,即要取反的值。它生成一个结果。我们还没有处理解析器,但我们可以添加上面语法将编译成的字节码指令。
OP_CONSTANT,
在枚举 OpCode 中
OP_NEGATE,
OP_RETURN,
我们像这样执行它
}
在run()中
case OP_NEGATE: push(-pop()); break;
case OP_RETURN: {
指令需要一个操作数来进行操作,它通过从栈中弹出获取操作数。它取反该值,然后将结果压回栈中供后面的指令使用。没有比这更简单的了。我们也可以反汇编它。
case OP_CONSTANT: return constantInstruction("OP_CONSTANT", chunk, offset);
在 disassembleInstruction() 中
case OP_NEGATE: return simpleInstruction("OP_NEGATE", offset);
case OP_RETURN:
我们可以尝试在测试代码块中使用它。
writeChunk(&chunk, constant, 123);
在main()中
writeChunk(&chunk, OP_NEGATE, 123);
writeChunk(&chunk, OP_RETURN, 123);
在加载常量后,但在返回之前,我们执行取反指令。这用取反后的值替换了栈中的常量。然后返回指令打印出来
-1.2
神奇!
15 . 3 . 1二元运算符
好吧,一元运算符并没有那么令人印象深刻。我们仍然只在栈中有一个值。为了真正看到一些深度,我们需要二元运算符。Lox 有四个二元 算术 运算符:加法、减法、乘法和除法。我们将同时实现它们。
OP_CONSTANT,
在枚举 OpCode 中
OP_ADD, OP_SUBTRACT, OP_MULTIPLY, OP_DIVIDE,
OP_NEGATE,
回到字节码循环中,它们是这样的执行的
}
在run()中
case OP_ADD: BINARY_OP(+); break; case OP_SUBTRACT: BINARY_OP(-); break; case OP_MULTIPLY: BINARY_OP(*); break; case OP_DIVIDE: BINARY_OP(/); break;
case OP_NEGATE: push(-pop()); break;
这四个指令之间的唯一区别是它们最终使用的底层 C 运算符来组合这两个操作数。围绕着核心算术表达式的,是一些用于从栈中拉出值并压入结果的样板代码。当我们稍后添加动态类型时,这些样板代码会变得更多。为了避免重复四次代码,我将其封装在一个宏中。
#define READ_CONSTANT() (vm.chunk->constants.values[READ_BYTE()])
在run()中
#define BINARY_OP(op) \ do { \ double b = pop(); \ double a = pop(); \ push(a op b); \ } while (false)
for (;;) {
我承认,这对 C 预处理器来说是一个相当 大胆 的用法。我犹豫过要不要这样做,但你会在后面的章节中感谢我,因为我们需要为每个操作数添加类型检查等操作。如果我带着你重复四次相同的代码,那将会是一件苦差事。
如果你不熟悉这个技巧,那么外面的 do while
循环可能看起来很奇怪。这个宏需要扩展为一系列语句。为了成为谨慎的宏作者,我们要确保这些语句在宏扩展时都位于相同的范围内。想象一下,如果你定义了
#define WAKE_UP() makeCoffee(); drinkCoffee();
然后像这样使用它
if (morning) WAKE_UP();
目的是只有当 morning
为真时才执行宏主体中的两个语句。但它扩展为
if (morning) makeCoffee(); drinkCoffee();;
哎呀。if
只附加到第一个语句。你可能会认为你可以使用块来解决这个问题。
#define WAKE_UP() { makeCoffee(); drinkCoffee(); }
这更好,但你仍然有风险
if (morning) WAKE_UP(); else sleepIn();
现在你因为宏块后的那个尾随 ;
而在 else
上得到了编译错误。在宏中使用 do while
循环看起来很奇怪,但它让你能够将多个语句包含在一个块中,同时允许在末尾使用分号。
我们到哪里了?对了,所以那个宏的主体所做的事情很简单。一个二元运算符接受两个操作数,所以它弹出两次。它对这两个值执行运算,然后压入结果。
请注意这两个弹出的顺序。注意我们把第一个弹出的操作数分配给 b
,而不是 a
。它看起来反了。当操作数本身被计算时,左边的操作数先被计算,然后是右边的操作数。这意味着左边的操作数会在右边的操作数之前被压入。因此,我们弹出的第一个值是 b
。
例如,如果我们编译 3 - 1
,那么指令之间的数据流如下所示
正如我们在 run()
中对其他宏所做的那样,我们在函数末尾清理自己。
#undef READ_CONSTANT
在run()中
#undef BINARY_OP
}
最后是反汇编支持。
case OP_CONSTANT: return constantInstruction("OP_CONSTANT", chunk, offset);
在 disassembleInstruction() 中
case OP_ADD: return simpleInstruction("OP_ADD", offset); case OP_SUBTRACT: return simpleInstruction("OP_SUBTRACT", offset); case OP_MULTIPLY: return simpleInstruction("OP_MULTIPLY", offset); case OP_DIVIDE: return simpleInstruction("OP_DIVIDE", offset);
case OP_NEGATE:
算术指令格式很简单,就像 OP_RETURN
一样。即使算术 运算符 接受操作数—它们在栈中找到—算术 字节码指令 却没有。
让我们通过评估一个更大的表达式来测试我们的一些新指令。
基于我们现有的示例代码块,这里是我们需要手动编译的 AST 到字节码的额外指令。
int constant = addConstant(&chunk, 1.2); writeChunk(&chunk, OP_CONSTANT, 123); writeChunk(&chunk, constant, 123);
在main()中
constant = addConstant(&chunk, 3.4); writeChunk(&chunk, OP_CONSTANT, 123); writeChunk(&chunk, constant, 123); writeChunk(&chunk, OP_ADD, 123); constant = addConstant(&chunk, 5.6); writeChunk(&chunk, OP_CONSTANT, 123); writeChunk(&chunk, constant, 123); writeChunk(&chunk, OP_DIVIDE, 123);
writeChunk(&chunk, OP_NEGATE, 123); writeChunk(&chunk, OP_RETURN, 123);
加法在先。左边的常量 1.2 的指令已经存在,所以我们再添加一个 3.4 的常量指令。然后我们使用 OP_ADD
将这两个常量相加,并将结果留在栈中。这涵盖了除法的左侧。接下来我们压入 5.6,并将加法结果除以它。最后,我们将除法的结果取反。
注意 OP_ADD
的输出是如何隐式地流入作为 OP_DIVIDE
的操作数的,而这两个指令并没有直接耦合在一起。这就是栈的魔力。它让我们可以自由地组合指令,而不需要它们具有任何复杂性或对数据流的感知。栈就像一个共享的工作区,所有指令都从它读取数据并写入它。
在这个很小的示例代码块中,栈的高度仍然只有两个值,但当我们开始将 Lox 源代码编译成字节码时,我们将拥有使用更多栈的代码块。在此之前,你可以尝试用这个手动编写的代码块来计算不同的嵌套算术表达式,看看值是如何通过指令和栈流动的。
你最好现在就将它从你的系统中清除。这是我们最后一次手动构建代码块。当我们下次重新审视字节码时,我们将编写一个编译器来为我们生成它。
挑战
-
你会为以下表达式生成什么样的字节码指令序列
1 * 2 + 3 1 + 2 * 3 3 - 2 - 1 1 + 2 * 3 - 4 / -5
(记住 Lox 没有负数字面量的语法,所以
-5
是对数字 5 取反。) -
如果我们真的想要一个最小的指令集,我们可以消除
OP_NEGATE
或OP_SUBTRACT
。显示你将为4 - 3 * -2
首先,不使用
OP_NEGATE
。然后,不使用OP_SUBTRACT
。鉴于以上情况,你认为同时拥有这两条指令有意义吗?为什么或者为什么不?你是否会考虑包含任何其他冗余的指令?
-
我们的 VM 的栈有固定的大小,我们不检查压入值是否会溢出。这意味着错误的指令序列会导致我们的解释器崩溃或进入未定义的行为。通过根据需要动态增长栈来避免这种情况。
这样做有哪些成本和收益?
-
为了解释
OP_NEGATE
,我们弹出操作数,取反值,然后压入结果。这是一个简单的实现,但它不必要地递增和递减了stackTop
,因为栈最终会回到相同的高度。直接在栈中取反值并将stackTop
保持不变可能更快。尝试一下,看看你是否能衡量性能差异。还有其他指令可以进行类似的优化吗?
设计说明:基于寄存器的字节码
在本书的剩余部分,我们将精心实现一个围绕基于栈的字节码指令集的解释器。那里还有另一个字节码架构家族—基于寄存器的。尽管名字如此,但这些字节码指令并不像实际芯片(如 x64)中的寄存器那样难以处理。对于真正的硬件寄存器,你通常只有少数几个供整个程序使用,所以你会花很多精力 尝试有效地使用它们并将东西在它们之间来回搬运。
在基于寄存器的 VM 中,你仍然有一个栈。临时值仍然被压入栈中,并在不再需要时弹出。主要的区别是指令可以从栈中的任何位置读取其输入,并将输出存储到特定的栈槽中。
拿这段小小的 Lox 脚本
var a = 1; var b = 2; var c = a + b;
在我们的基于栈的 VM 中,最后一条语句将被编译成类似于
load <a> // Read local variable a and push onto stack. load <b> // Read local variable b and push onto stack. add // Pop two values, add, push result. store <c> // Pop value and store in local variable c.
(如果你还不完全理解加载和存储指令,不要担心。当我们实现变量时,我们会 更详细地介绍它们。) 我们有四条独立的指令。这意味着要遍历字节码解释循环四次,解码和调度四条指令。它至少有七个字节的代码—四个用于操作码,另外三个用于标识要加载和存储的局部变量的操作数。三个压入和三个弹出。很多工作!
在基于寄存器的指令集中,指令可以直接从局部变量读取并存储到局部变量中。上面最后一条语句的字节码看起来像
add <a> <b> <c> // Read values from a and b, add, store in c.
加法指令更大—它有三个指令操作数,用于定义它从栈中的哪些位置读取输入并将结果写入哪个位置。但由于局部变量位于栈上,它可以直接从 a
和 b
中读取,然后将结果直接存储到 c
中。
只有一条指令需要解码和调度,并且整个过程都包含在四个字节中。由于有额外的操作数,因此解码更复杂,但这仍然是一个净收益。没有压入和弹出,也没有其他栈操作。
Lua 的主要实现以前是基于栈的。对于 Lua 5.0,实现者切换到基于寄存器的指令集,并注意到速度有所提高。当然,改进的程度在很大程度上取决于语言语义的细节、特定指令集和编译器的复杂性,但这应该引起你的注意。
这引出了一个显而易见的问题:为什么我要在本书的剩余部分使用基于堆栈的字节码?寄存器虚拟机很酷,但编写编译器要困难得多。对于很可能是你第一个编译器来说,我希望使用一个易于生成且易于执行的指令集。基于堆栈的字节码非常简单。
它在文献和社区中也更广为人知。即使你最终会转向更先进的东西,它也是与其他语言黑客同行交流的良好共同基础。