字节码块
如果你发现自己几乎把所有时间都花在了理论上,那就开始关注一些实际的东西吧;这会改善你的理论。如果你发现自己几乎把所有时间都花在了实践上,那就开始关注一些理论的东西吧;这会改善你的实践。
唐纳德·克努斯
我们已经拥有了完整的 Lox 实现,也就是 jlox,那么为什么这本书还没有结束呢?部分原因是 jlox 依赖于 JVM 为我们做了很多事情。如果我们想要了解解释器的工作原理,一直到底层,我们就需要自己构建这些零零碎碎的部分。
jlox 不够用的一个更根本的原因是它太慢了。对于一些高级的、声明式的语言来说,树遍历解释器是可以的。但对于通用的、命令式的语言—即使是像 Lox 这样的“脚本”语言—它行不通。看看这段小程序
fun fib(n) { if (n < 2) return n; return fib(n - 1) + fib(n - 2); } var before = clock(); print fib(40); var after = clock(); print after - before;
在我的笔记本电脑上,jlox 运行这段代码大约需要 72 秒。等效的 C 程序在半秒内完成。我们的动态类型脚本语言永远不可能像静态类型语言那样快,而且还有手动内存管理,但我们不需要比两个数量级更慢。
我们可以使用 jlox 在分析器中运行它,并开始调整和优化热点,但这只能让我们走那么远。执行模型—遍历 AST—从根本上来说是错误的设计。我们不能像把 AMC Gremlin 打磨成 SR-71 黑鸟一样,将它微优化到我们想要的性能。
我们需要重新思考核心模型。本章介绍了这个模型,字节码,并开始我们的新解释器 clox。
14 . 1字节码?
在工程学中,很少有选择是毫无权衡的。为了更好地理解我们为什么要使用字节码,让我们将它与几个替代方案进行比较。
14 . 1 . 1为什么不遍历 AST?
我们现有的解释器有几个优点
-
首先,我们已经写好了它。它已经完成了。它之所以完成,主要是因为这种类型的解释器非常容易实现。代码的运行时表示直接映射到语法。从解析器到我们在运行时需要的结构,几乎毫不费力。
-
它是可移植的。我们当前的解释器是用 Java 编写的,可以在任何支持 Java 的平台上运行。我们可以使用相同的方法用 C 编写一个新的实现,并编译和运行我们的语言,基本上可以运行在阳光下的所有平台上。
这些都是真正的优势。但另一方面,它不是内存高效的。每一段语法都成为一个 AST 节点。一个像 1 + 2
这样的小 Lox 表达式会变成一堆对象,它们之间有很多指针,类似于
每个指针都会给对象增加 32 或 64 位的额外开销。更糟糕的是,将我们的数据分散在堆中,形成一个松散连接的对象网络,不利于 空间局部性.
现代 CPU 处理数据的速度远远超过从 RAM 中提取数据的速度。为了弥补这一点,芯片有多层缓存。如果需要的一段内存已经在缓存中,那么它可以更快地加载。我们说的是快了 100 倍。
数据是如何进入缓存的?机器会根据推测为你将东西塞进去。它的启发式方法很简单。每当 CPU 从 RAM 中读取一些数据时,它会拉取一大块相邻的字节并将它们塞入缓存。
如果我们的程序接下来请求一些足够接近缓存行的内存数据,我们的 CPU 就会像工厂里润滑良好的传送带一样运行。我们真的想利用这一点。为了有效地使用缓存,我们用内存表示代码的方式应该像读取时一样密集且有序。
现在抬头看看那棵树。那些子对象可能在任何地方。树遍历器在沿着指向子节点的引用进行的每一步,都可能超出缓存的范围,并迫使 CPU 停顿,直到可以从 RAM 中吸入新的数据块。仅仅是这些树节点的所有指针字段和对象头带来的开销,就倾向于将对象彼此分开,并从缓存中移出。
我们的 AST 遍历器在接口调度和访问者模式方面也有一些其他开销,但仅凭局部性问题就足以证明需要更好的代码表示。
14 . 1 . 2为什么不编译成原生代码?
如果你想要真正的快,你就想把所有这些间接层都去掉。一直到金属层。机器代码。它听起来都很快。机器代码。
直接编译成芯片支持的原生指令集,是速度最快的语言所做的。自从工程师实际手写机器代码的早期时代开始,直接针对原生代码一直是最有效的选择。
如果你以前从未写过任何机器代码,或者它更人性化的表亲汇编代码,我将给你一个最温和的介绍。原生代码是一系列密集的操作,直接用二进制编码。每个指令的长度在 1 到几个字节之间,而且几乎是令人难以置信的低级。 “将一个值从这个地址移动到这个寄存器”。 “将这两个寄存器中的整数相加”。诸如此类。
CPU 逐个解码和执行指令,按照顺序执行。没有像我们的 AST 这样的树结构,控制流是通过从代码中的一个点直接跳转到另一个点来处理的。没有间接,没有开销,没有不必要的跳跃或追逐指针。
闪电般快,但这种性能是有代价的。首先,编译成原生代码并不容易。今天广泛使用的多数芯片都具有庞大的拜占庭架构,拥有几十年积累的大量指令。它们需要复杂的寄存器分配、流水线和指令调度。
当然,你已经把可移植性抛到一边了。花几年时间精通某种架构,这只能让你进入一个流行的指令集。为了让你的语言运行在所有这些指令集上,你需要学习所有这些指令集,并为每一个指令集编写一个单独的后端。
14 . 1 . 3什么是字节码?
记住这两个点。一方面,树遍历解释器简单、可移植,但速度慢。另一方面,原生代码复杂、特定于平台,但速度快。字节码位于中间。它保留了树遍历解释器的可移植性—我们这本书不会涉及汇编代码。它牺牲了一些简单性,以换取性能提升,尽管它不像完全原生代码那样快。
从结构上讲,字节码类似于机器代码。它是一个密集的、线性的二进制指令序列。这可以降低开销,并与缓存相兼容。然而,它比任何真实的芯片都简单得多、高级得多。(在许多字节码格式中,每个指令都只有一个字节长,因此得名“字节码”。)
想象一下,你正在为某种源语言编写一个原生编译器,并且可以自由地定义一个最容易针对的架构。字节码有点像这样。它是一个理想的幻想指令集,可以让你的编译器编写工作更容易。
当然,幻想架构的问题在于它不存在。我们通过编写一个模拟器—一个用软件编写的模拟芯片,它一次解释一条字节码指令来解决这个问题。如果你愿意,可以把它叫做虚拟机(VM)。
模拟层会增加开销,这是字节码比原生代码慢的一个主要原因。但作为回报,它给了我们可移植性。用像 C 这样的语言编写我们的 VM,这种语言已经在我们关心的所有机器上得到了支持,我们就可以在任何硬件上运行我们的模拟器。
我们将使用新的解释器 clox 来遵循这条路径。我们将追随 Python、Ruby、Lua、OCaml、Erlang 等主要实现的脚步。在许多方面,我们 VM 的设计将与我们之前的解释器的结构平行。
当然,我们不会严格按照顺序实现这些阶段。就像我们之前的解释器一样,我们将跳来跳去,一次构建一个语言功能。在本章中,我们将搭建应用程序的骨架,并创建存储和表示字节码块所需的数据结构。
14 . 2入门
从哪里开始呢?从 main()
开始吧!打开你 trusty 的文本编辑器并开始输入。
新建文件
#include "common.h" int main(int argc, const char* argv[]) { return 0; }
从这颗小小的种子开始,我们将培育出整个 VM。由于 C 提供给我们的东西太少,我们首先需要花一些时间来改良土壤。其中一部分包含在这个头文件中
新建文件
#ifndef clox_common_h #define clox_common_h #include <stdbool.h> #include <stddef.h> #include <stdint.h> #endif
我们将使用一些类型和常量贯穿整个解释器,这是一个方便的地方。现在,它包括久经考验的 NULL
、size_t
、漂亮的 C99 布尔类型 bool
,以及显式大小的整型类型—uint8_t
及其朋友。
14 . 3指令块
接下来,我们需要一个模块来定义我们的代码表示。我一直使用“chunk”来指代字节码序列,所以让我们将它作为该模块的正式名称。
新建文件
#ifndef clox_chunk_h #define clox_chunk_h #include "common.h" #endif
在我们的字节码格式中,每个指令都有一个字节的操作码(通常缩写为opcode)。这个数字控制着我们处理的是哪种指令—加、减、查找变量等。我们在这里定义它们
#include "common.h"
typedef enum { OP_RETURN, } OpCode;
#endif
现在,我们从一个指令开始,OP_RETURN
。当我们拥有功能齐全的 VM 时,这条指令将意味着“从当前函数返回”。我承认这现在没什么用,但我们必须从某个地方开始,而这对于我们以后会了解的原因来说,是一个非常简单的指令。
14 . 3 . 1指令的动态数组
字节码是一系列指令。最终,我们将在指令中存储一些其他数据,所以让我们先创建一个结构体来保存所有数据。
} OpCode;
在枚举 OpCode 之后添加
typedef struct { uint8_t* code; } Chunk;
#endif
目前,这只是一个字节数组的包装器。由于我们在开始编译块之前不知道数组需要多大,它必须是动态的。动态数组是我最喜欢的几种数据结构之一。这听起来像是在说香草是我最喜欢的冰淇淋口味,但请听我说。动态数组提供了
-
缓存友好的、密集的存储
-
常数时间的索引元素查找
-
常数时间的将元素追加到数组末尾
这些特性正是我们在 jlox 中一直使用动态数组的原因,它们伪装成 Java 的 ArrayList 类。现在我们在 C 中,我们可以自己实现。如果你对动态数组不太熟悉,这个概念很简单。除了数组本身,我们还保留两个数字:数组中我们分配的元素数量(“容量”)以及实际使用的分配条目数量(“计数”)。
typedef struct {
在结构体 Chunk 中
int count; int capacity;
uint8_t* code; } Chunk;
当我们添加元素时,如果计数小于容量,则数组中已经有可用空间。我们将新元素存储在那里,并将计数增加。
如果我们没有剩余容量,那么这个过程会稍微复杂一些。
- 分配一个容量更大的新数组。
- 将现有元素从旧数组复制到新数组。
- 存储新的
capacity
。 - 删除旧数组。
- 更新
code
以指向新数组。 - 现在数组中有空间了,将元素存储在新数组中。
- 更新
count
。
我们的结构体已经准备好了,所以让我们来实现用于处理它的函数。C 没有构造函数,所以我们声明一个函数来初始化一个新的块。
} Chunk;
在结构体 Chunk 之后添加
void initChunk(Chunk* chunk);
#endif
并像这样实现它
新建文件
#include <stdlib.h> #include "chunk.h" void initChunk(Chunk* chunk) { chunk->count = 0; chunk->capacity = 0; chunk->code = NULL; }
动态数组一开始是完全空的。我们甚至还没有分配一个原始数组。要将一个字节追加到块的末尾,我们使用一个新函数。
void initChunk(Chunk* chunk);
在 initChunk() 之后添加
void writeChunk(Chunk* chunk, uint8_t byte);
#endif
这就是有趣的工作发生的地方。
在 initChunk() 之后添加
void writeChunk(Chunk* chunk, uint8_t byte) { if (chunk->capacity < chunk->count + 1) { int oldCapacity = chunk->capacity; chunk->capacity = GROW_CAPACITY(oldCapacity); chunk->code = GROW_ARRAY(uint8_t, chunk->code, oldCapacity, chunk->capacity); } chunk->code[chunk->count] = byte; chunk->count++; }
我们需要做的第一件事是查看当前数组是否已经为新字节提供容量。如果没有,那么我们首先需要扩展数组以腾出空间。(当数组为 NULL
且 capacity
为 0 时,我们在第一次写入时也会遇到这种情况。)
要扩展数组,首先我们需要确定新的容量,并将数组扩展到该大小。这两个较低级的内存操作是在一个新的模块中定义的。
#include "chunk.h"
#include "memory.h"
void initChunk(Chunk* chunk) {
这足以让我们开始。
新建文件
#ifndef clox_memory_h #define clox_memory_h #include "common.h" #define GROW_CAPACITY(capacity) \ ((capacity) < 8 ? 8 : (capacity) * 2) #endif
这个宏根据给定的当前容量计算新的容量。为了获得我们想要的性能,重要的是它根据旧的大小进行扩展。我们扩展了 2 倍,这很典型。1.5 倍是另一个常见的选择。
我们还处理当前容量为零的情况。在这种情况下,我们直接跳转到 8 个元素,而不是从 1 开始。这样可以避免在数组非常小时多余的内存抖动,但代价是在非常小的块上浪费一些字节。
一旦我们知道了所需的容量,就可以使用 GROW_ARRAY()
创建或扩展数组到该大小。
#define GROW_CAPACITY(capacity) \ ((capacity) < 8 ? 8 : (capacity) * 2)
#define GROW_ARRAY(type, pointer, oldCount, newCount) \ (type*)reallocate(pointer, sizeof(type) * (oldCount), \ sizeof(type) * (newCount)) void* reallocate(void* pointer, size_t oldSize, size_t newSize);
#endif
这个宏美化了对 reallocate()
的函数调用,真正的工作是在这里完成的。宏本身负责获取数组元素类型的尺寸,并将结果 void*
转换回正确类型的指针。
这个 reallocate()
函数是我们将在 clox 中使用的所有动态内存管理的唯一函数—分配内存、释放内存以及更改现有分配的大小。将所有这些操作都通过一个函数进行路由,这对于我们以后添加垃圾收集器(它需要跟踪有多少内存正在使用)至关重要。
传递给 reallocate()
的两个大小参数控制着要执行的操作
oldSize | newSize | 操作 |
0 | 非零 | 分配新的块。 |
非零 | 0 | 释放分配。 |
非零 | 小于 oldSize |
缩小现有分配。 |
非零 | 大于 oldSize |
扩展现有分配。 |
听起来似乎要处理很多情况,但这里是实现
新建文件
#include <stdlib.h> #include "memory.h" void* reallocate(void* pointer, size_t oldSize, size_t newSize) { if (newSize == 0) { free(pointer); return NULL; } void* result = realloc(pointer, newSize); return result; }
当 newSize
为零时,我们会通过调用 free()
来自己处理释放情况。否则,我们将依赖 C 标准库的 realloc()
函数。该函数方便地支持我们策略的其他三个方面。当 oldSize
为零时,realloc()
等同于调用 malloc()
。
有趣的情况是 oldSize
和 newSize
都不为零时。它们告诉 realloc()
调整先前分配的块的大小。如果新尺寸小于现有内存块,则它会简单地更新块的大小,并返回你给它的同一个指针。如果新尺寸更大,它会尝试扩展现有的内存块。
它只能在该块后面的内存尚未使用的情况下做到这一点。如果没有空间来扩展块,realloc()
将改为分配一个大小为所需尺寸的新内存块,复制旧字节,释放旧块,然后返回指向新块的指针。请记住,这正是我们想要实现动态数组的行为。
由于计算机是有限的物质,而不是计算机科学理论所认为的完美数学抽象,因此如果内存不足,分配可能会失败,realloc()
会返回 NULL
。我们应该处理这种情况。
void* result = realloc(pointer, newSize);
在 reallocate() 中
if (result == NULL) exit(1);
return result;
如果我们的 VM 无法获取它需要的内存,它实际上并不能做任何有用的事情,但我们至少会检测到这一点,并立即中止进程,而不是返回 NULL
指针,并让它以后脱轨。
好的,我们可以创建新的块并将指令写入它们。我们完成了吗?不!我们现在在 C 中,请记住,我们需要自己管理内存,就像在旧时代一样,这意味着也需要释放内存。
void initChunk(Chunk* chunk);
在 initChunk() 之后添加
void freeChunk(Chunk* chunk);
void writeChunk(Chunk* chunk, uint8_t byte);
实现如下
在 initChunk() 之后添加
void freeChunk(Chunk* chunk) { FREE_ARRAY(uint8_t, chunk->code, chunk->capacity); initChunk(chunk); }
我们释放所有内存,然后调用 initChunk()
将字段清零,使块处于定义良好的空状态。为了释放内存,我们添加了一个宏。
#define GROW_ARRAY(type, pointer, oldCount, newCount) \ (type*)reallocate(pointer, sizeof(type) * (oldCount), \ sizeof(type) * (newCount))
#define FREE_ARRAY(type, pointer, oldCount) \ reallocate(pointer, sizeof(type) * (oldCount), 0)
void* reallocate(void* pointer, size_t oldSize, size_t newSize);
与 GROW_ARRAY()
一样,这也是对 reallocate()
的包装。这个宏通过将新尺寸传递为零来释放内存。我知道,这些底层的东西很无聊。别担心,我们将在后面的章节中大量使用它们,并能够在更高的级别进行编程。不过,在我们做到这一点之前,我们必须打好自己的基础。
14 . 4反汇编块
现在我们有了一个用于创建字节码块的小模块。让我们通过手动构建一个示例块来试用一下。
int main(int argc, const char* argv[]) {
在 main() 中
Chunk chunk; initChunk(&chunk); writeChunk(&chunk, OP_RETURN); freeChunk(&chunk);
return 0;
不要忘记包含文件。
#include "common.h"
#include "chunk.h"
int main(int argc, const char* argv[]) {
运行它并试用一下。成功了吗?呃 . . . 谁知道呢?我们所做的只是在内存中推入一些字节。我们没有友好的方式来查看我们制作的块内部实际上是什么。
为了解决这个问题,我们将创建一个 **反汇编器**。**汇编器** 是一种老式的程序,它接收包含人类可读的 CPU 指令助记符名称(如“ADD”和“MULT”)的文件,并将它们转换为等效的二进制机器代码。**反汇编器** 做相反的事情—给定一段机器代码,它会输出指令的文本列表。
我们将实现类似的东西类似。给定一个代码块,它将打印其中的所有指令。Lox **用户** 不会使用它,但我们 Lox **维护者** 肯定会从中受益,因为它让我们可以了解解释器对代码的内部表示。
在 main()
中,在我们创建代码块之后,我们将它传递给反汇编器。
initChunk(&chunk); writeChunk(&chunk, OP_RETURN);
在 main() 中
disassembleChunk(&chunk, "test chunk");
freeChunk(&chunk);
同样,我们快速创建了另一个 模块。
#include "chunk.h"
#include "debug.h"
int main(int argc, const char* argv[]) {
这是头文件
新建文件
#ifndef clox_debug_h #define clox_debug_h #include "chunk.h" void disassembleChunk(Chunk* chunk, const char* name); int disassembleInstruction(Chunk* chunk, int offset); #endif
在 main()
中,我们调用 disassembleChunk()
来反汇编整个代码块中的所有指令。它是在另一个函数的基础上实现的,后者只反汇编单个指令。它出现在这里是因为我们将在后面的章节中从 VM 调用它。
这是实现文件的开头
新建文件
#include <stdio.h> #include "debug.h" void disassembleChunk(Chunk* chunk, const char* name) { printf("== %s ==\n", name); for (int offset = 0; offset < chunk->count;) { offset = disassembleInstruction(chunk, offset); } }
为了反汇编代码块,我们打印一个小标题(这样我们就可以知道我们正在查看 **哪个** 代码块),然后遍历字节码,反汇编每个指令。我们遍历代码的方式有点奇怪。我们没有在循环中递增 offset
,而是让 disassembleInstruction()
为我们完成。当我们调用该函数时,它会在反汇编给定偏移处的指令后,返回 **下一条** 指令的偏移量。这是因为,正如我们稍后将看到的那样,指令的大小可能不同。
“debug” 模块的核心是这个函数
添加到 disassembleChunk() 之后
int disassembleInstruction(Chunk* chunk, int offset) { printf("%04d ", offset); uint8_t instruction = chunk->code[offset]; switch (instruction) { case OP_RETURN: return simpleInstruction("OP_RETURN", offset); default: printf("Unknown opcode %d\n", instruction); return offset + 1; } }
首先,它打印给定指令的字节偏移量—这告诉我们这条指令在代码块中的位置。当我们开始进行控制流并在字节码中跳来跳去时,这将是一个有用的路标。
接下来,它从给定偏移量的字节码中读取一个字节。这就是我们的操作码。我们根据 它进行操作。对于每种指令,我们都会调用一个小的实用函数来显示它。如果给定的字节根本不像指令—编译器中的错误—我们也会打印出来。对于我们现有的唯一指令 OP_RETURN
,显示函数是
添加到 disassembleChunk() 之后
static int simpleInstruction(const char* name, int offset) { printf("%s\n", name); return offset + 1; }
返回指令没有太多内容,所以它只是打印操作码的名称,然后返回这条指令之后下一个字节的偏移量。其他指令将有更多操作。
如果我们现在运行我们正在开发的解释器,它实际上会打印一些东西
== test chunk == 0000 OP_RETURN
它工作了!这有点像我们代码表示的“Hello, world!”。我们可以创建一个代码块,向它写入指令,然后提取回该指令。我们对二进制字节码的编码和解码正在起作用。
14 . 5常量
现在我们已经让一个基本的代码块结构正常工作,让我们开始让它更有用。我们可以在代码块中存储 **代码**,但是 **数据** 呢?解释器使用的大多数值是在运行时作为操作的结果创建的。
1 + 2;
值 3 在这里没有出现在代码中。但是,字面量 1
和 2
出现了。为了将该语句编译成字节码,我们需要某种指令,其含义是“产生一个常量”,并且这些字面量值需要存储在代码块中的某个位置。在 jlox 中,Expr.Literal AST 节点保存了该值。现在我们没有语法树,所以我们需要一个不同的解决方案。
14 . 5 . 1表示值
我们不会在本章中 **运行** 任何代码,但由于常量在解释器的静态和动态世界中都占有一席之地,因此它们迫使我们至少要开始思考我们的 VM 应该如何表示值。
现在,我们将从最简单的地方开始—我们只支持双精度浮点数。这显然会随着时间的推移而扩展,因此我们将创建一个新的模块来为我们提供扩展空间。
新建文件
#ifndef clox_value_h #define clox_value_h #include "common.h" typedef double Value; #endif
这个 typedef 抽象了 Lox 值在 C 中的具体表示方式。这样,我们就可以更改该表示方式,而无需返回并修复传递值的现有代码。
回到关于在代码块中存储常量的位置的问题。对于像整数这样的小型固定大小的值,许多指令集将值直接存储在操作码后面的代码流中。这些被称为 **立即指令**,因为值位的位紧随操作码之后。
对于像字符串这样的较大或可变大小的常量,这并不适用。在针对机器代码的本机编译器中,这些较大的常量存储在二进制可执行文件的单独“常量数据”区域中。然后,加载常量的指令将具有指向该值在该部分中存储位置的地址或偏移量。
大多数虚拟机都会做类似的事情。例如,Java 虚拟机 将 **常量池** 与每个已编译的类相关联。这对我来说对 clox 来说已经足够好了。每个代码块都将附带一个列表,其中包含在程序中作为字面量出现的那些值。为了让事情更简单,我们将在那里放入所有常量,即使是简单的整数。
14 . 5 . 2值数组
常量池是值数组。加载常量的指令通过该数组中的索引查找该值。与我们的 字节码 数组一样,编译器事先不知道数组需要多大的大小。因此,我们再次需要一个动态数组。由于 C 没有泛型数据结构,我们将编写另一个动态数组数据结构,这次用于 Value。
typedef double Value;
typedef struct { int capacity; int count; Value* values; } ValueArray;
#endif
与代码块中的字节码数组一样,此结构体包装了指向数组的指针以及其分配的容量和正在使用的元素数量。我们还需要相同的三個函数来处理值数组。
} ValueArray;
添加到结构体 ValueArray 之后
void initValueArray(ValueArray* array); void writeValueArray(ValueArray* array, Value value); void freeValueArray(ValueArray* array);
#endif
这些实现可能让你产生似曾相识的感觉。首先,要创建一个新的
新建文件
#include <stdio.h> #include "memory.h" #include "value.h" void initValueArray(ValueArray* array) { array->values = NULL; array->capacity = 0; array->count = 0; }
一旦我们有了初始化的数组,我们就可以开始添加 值到它中。
添加到 initValueArray() 之后
void writeValueArray(ValueArray* array, Value value) { if (array->capacity < array->count + 1) { int oldCapacity = array->capacity; array->capacity = GROW_CAPACITY(oldCapacity); array->values = GROW_ARRAY(Value, array->values, oldCapacity, array->capacity); } array->values[array->count] = value; array->count++; }
我们之前编写的内存管理宏确实让我们可以重复使用代码数组中的一些逻辑,所以这并不太糟糕。最后,要释放数组使用的所有内存
添加到 writeValueArray() 之后
void freeValueArray(ValueArray* array) { FREE_ARRAY(Value, array->values, array->capacity); initValueArray(array); }
现在我们有了可扩展的值数组,我们可以向代码块添加一个数组来存储代码块的常量。
uint8_t* code;
在结构体 Chunk 中
ValueArray constants;
} Chunk;
不要忘记包含文件。
#include "common.h"
#include "value.h"
typedef enum {
啊,C,以及它那史前时代的模块化故事。我们之前在做什么?对了。当我们初始化一个新的代码块时,我们也初始化它的常量列表。
chunk->code = NULL;
在 initChunk() 中
initValueArray(&chunk->constants);
}
同样,当我们释放代码块时,我们也会释放常量。
FREE_ARRAY(uint8_t, chunk->code, chunk->capacity);
在 freeChunk() 中
freeValueArray(&chunk->constants);
initChunk(chunk);
接下来,我们定义一个方便的方法来向代码块添加一个新的常量。我们还没有编写的编译器可以直接写入代码块中的常量数组—C 没有私有字段之类的—但添加一个显式函数会更好一些。
void writeChunk(Chunk* chunk, uint8_t byte);
添加到 writeChunk() 之后
int addConstant(Chunk* chunk, Value value);
#endif
然后我们实现它。
添加到 writeChunk() 之后
int addConstant(Chunk* chunk, Value value) { writeValueArray(&chunk->constants, value); return chunk->constants.count - 1; }
在添加常量后,我们将返回常量追加到的索引,以便我们以后可以定位同一个常量。
14 . 5 . 3常量指令
我们可以在代码块中 **存储** 常量,但我们还需要 **执行** 它们。在类似于以下的代码段中
print 1; print 2;
编译后的代码块不仅需要包含值 1 和 2,还需要知道 **何时** 产生它们,以便它们按正确的顺序打印。因此,我们需要一个产生特定常量的指令。
typedef enum {
在枚举 OpCode 中
OP_CONSTANT,
OP_RETURN,
当 VM 执行常量指令时,它将“加载” 该常量以供使用。这条新指令比 OP_RETURN
稍微复杂一些。在上面的例子中,我们加载了两个不同的常量。一个简单的操作码不足以知道要加载 **哪个** 常量。
为了处理这种情况,我们的字节码—与大多数其他字节码一样—允许指令具有操作数。这些操作数以二进制数据的形式存储在指令流中的操作码之后,并允许我们参数化指令的操作方式。
每个操作码都决定了它有多少个操作数字节以及它们的含义。例如,像“返回”这样简单的操作可能没有操作数,而“加载局部变量”指令需要一个操作数来识别要加载的变量。每次我们向 clox 添加一个新的操作码时,我们都会指定其操作数的格式—它的 **指令格式**。
在本例中,OP_CONSTANT
接受一个字节操作数,该操作数指定从代码块的常量数组中加载哪个常量。由于我们还没有编译器,因此我们在测试代码块中“手工编译”了一条指令。
initChunk(&chunk);
在 main() 中
int constant = addConstant(&chunk, 1.2); writeChunk(&chunk, OP_CONSTANT); writeChunk(&chunk, constant);
writeChunk(&chunk, OP_RETURN);
我们将常量值本身添加到代码块的常量池中。这将返回常量在数组中的索引。然后,我们编写常量指令,从其操作码开始。在那之后,我们编写一个字节的常量索引操作数。请注意,writeChunk()
可以编写操作码或操作数。对于该函数来说,它们都是原始字节。
如果我们现在尝试运行它,反汇编器会向我们大喊大叫,因为它不知道如何解码新的指令。让我们解决这个问题。
switch (instruction) {
在 disassembleInstruction() 中
case OP_CONSTANT: return constantInstruction("OP_CONSTANT", chunk, offset);
case OP_RETURN:
这条指令具有不同的指令格式,因此我们编写了一个新的辅助函数来反汇编它。
添加到 disassembleChunk() 之后
static int constantInstruction(const char* name, Chunk* chunk, int offset) { uint8_t constant = chunk->code[offset + 1]; printf("%-16s %4d '", name, constant); printValue(chunk->constants.values[constant]); printf("'\n"); }
这里还有更多操作。与 OP_RETURN
一样,我们打印操作码的名称。然后,我们从代码块中的后续字节中提取常量索引。我们打印该索引,但这对我们人类读者来说没有太大用处。因此,我们还查找了实际的常量值—毕竟常量在编译时是已知的—并显示了该值本身。
这需要一种方法来打印 clox Value。该函数将在“value”模块中,因此我们将包含该模块。
#include "debug.h"
#include "value.h"
void disassembleChunk(Chunk* chunk, const char* name) {
在那个头文件中,我们声明了
void freeValueArray(ValueArray* array);
添加到 freeValueArray() 之后
void printValue(Value value);
#endif
以下是实现
添加到 freeValueArray() 之后
void printValue(Value value) { printf("%g", value); }
很棒吧?正如您所想象的,一旦我们在 Lox 中添加了动态类型并且具有不同类型的值,这将变得更加复杂。
回到 constantInstruction()
,唯一剩下的部分就是返回值。
printf("'\n");
在 constantInstruction() 中
return offset + 2;
}
请记住,disassembleInstruction()
也返回一个数字,以告诉调用者 下一个指令开头的偏移量。OP_RETURN
只是一个字节,而 OP_CONSTANT
是两个—一个用于操作码,一个用于操作数。
14 . 6行信息
块包含运行时从用户源代码中所需的大部分信息。令人难以置信的是,我们可以将我们在 jlox 中创建的所有不同的 AST 类缩减为一个字节数组和一个常量数组。我们只缺少一条数据。我们需要它,即使用户希望永远不会看到它。
当发生运行时错误时,我们会向用户显示出现错误的源代码的行号。在 jlox 中,这些数字保存在标记中,而我们又将标记存储在 AST 节点中。现在我们已经放弃了语法树,转而使用字节码,因此我们需要一个不同的解决方案。对于任何字节码指令,我们需要能够确定它来自用户源程序的哪一行。
我们可以用很多巧妙的方法来编码这一点。我采用了我能想到的最简单的方法,即使它在内存方面非常低效。在块中,我们存储一个单独的整数数组,它与字节码并行。数组中的每个数字都是对应于字节码中对应字节的行号。当发生运行时错误时,我们查找与当前指令在代码数组中的偏移量相同的索引处的行号。
为了实现这一点,我们在 Chunk 中添加了另一个数组。
uint8_t* code;
在结构体 Chunk 中
int* lines;
ValueArray constants;
由于它与字节码数组完全平行,因此我们不需要单独的计数或容量。每次我们接触代码数组时,我们都会对行号数组进行相应的更改,从初始化开始。
chunk->code = NULL;
在 initChunk() 中
chunk->lines = NULL;
initValueArray(&chunk->constants);
同样,也要处理释放
FREE_ARRAY(uint8_t, chunk->code, chunk->capacity);
在 freeChunk() 中
FREE_ARRAY(int, chunk->lines, chunk->capacity);
freeValueArray(&chunk->constants);
当我们将一个字节的代码写入块时,我们需要知道它来自哪一行源代码,因此我们在 writeChunk()
的声明中添加了一个额外的参数。
void freeChunk(Chunk* chunk);
函数 writeChunk()
替换 1 行
void writeChunk(Chunk* chunk, uint8_t byte, int line);
int addConstant(Chunk* chunk, Value value);
在实现中
函数 writeChunk()
替换 1 行
void writeChunk(Chunk* chunk, uint8_t byte, int line) {
if (chunk->capacity < chunk->count + 1) {
当我们分配或扩展代码数组时,我们对行信息也执行相同的操作。
chunk->code = GROW_ARRAY(uint8_t, chunk->code, oldCapacity, chunk->capacity);
在 writeChunk() 中
chunk->lines = GROW_ARRAY(int, chunk->lines, oldCapacity, chunk->capacity);
}
最后,我们将行号存储在数组中。
chunk->code[chunk->count] = byte;
在 writeChunk() 中
chunk->lines[chunk->count] = line;
chunk->count++;
14 . 6 . 1反汇编行信息
好了,让我们用我们的小巧的、手工制作的块来尝试一下。首先,由于我们在 writeChunk()
中添加了一个新参数,因此我们需要修复这些调用以传递一些—目前是任意的—行号。
int constant = addConstant(&chunk, 1.2);
在 main() 中
替换 4 行
writeChunk(&chunk, OP_CONSTANT, 123); writeChunk(&chunk, constant, 123); writeChunk(&chunk, OP_RETURN, 123);
disassembleChunk(&chunk, "test chunk");
当然,一旦我们拥有一个真正的前端,编译器将在解析时跟踪当前行,并将该行传递进来。
现在我们为每条指令都有了行信息,让我们充分利用它。在我们的反汇编程序中,显示每个指令编译自哪一行源代码很有帮助。这为我们提供了一种方法,可以在尝试弄清楚字节码块应该做什么时映射回原始代码。在打印指令的偏移量—从块开头到该指令的字节数—之后,我们会显示其源代码行。
int disassembleInstruction(Chunk* chunk, int offset) { printf("%04d ", offset);
在 disassembleInstruction() 中
if (offset > 0 && chunk->lines[offset] == chunk->lines[offset - 1]) { printf(" | "); } else { printf("%4d ", chunk->lines[offset]); }
uint8_t instruction = chunk->code[offset];
字节码指令往往非常细粒度。一行源代码通常会编译成一整套指令。为了使这一点更加清晰,我们对来自与前一个指令相同的源代码行的任何指令显示一个 |
。我们手写的块的最终输出如下
== test chunk == 0000 123 OP_CONSTANT 0 '1.2' 0002 | OP_RETURN
我们有一个 3 字节的块。前两个字节是一个常量指令,它从块的常量池中加载 1.2。第一个字节是 OP_CONSTANT
操作码,第二个字节是常量池中的索引。第三个字节(偏移量为 2)是一个单字节的返回指令。
在接下来的章节中,我们将使用更多类型的指令来完善这一点。但是基本结构已经在这里,我们现在拥有了在运行时在我们的虚拟机中完全表示可执行代码片段所需的一切。还记得我们在 jlox 中定义的整个 AST 类族吗?在 clox 中,我们将它们简化为三个数组:代码字节、常量值和调试行信息。
这种简化是我们的新解释器比 jlox 更快的一个关键原因。您可以将字节码视为 AST 的一种紧凑序列化,它针对解释器执行时按需反序列化的方式进行了高度优化。在下一章中,我们将看到虚拟机是如何做到的。
挑战
-
我们对行信息的编码在内存方面是极其浪费的。考虑到一系列指令通常对应于同一行源代码,一个自然的解决方案是类似于游程编码的行号。
设计一种编码,用于压缩同一行上的一系列指令的行信息。更改
writeChunk()
以写入此压缩形式,并实现getLine()
函数,该函数在给定指令的索引时确定指令所在的行。提示:
getLine()
不需要特别高效。由于它仅在发生运行时错误时被调用,因此它远离性能至关重要的关键路径。 -
由于
OP_CONSTANT
仅使用一个字节来存储其操作数,因此块可能仅包含最多 256 个不同的常量。这太小了,以至于编写实际代码的人会遇到这个限制。我们可以使用两个或多个字节来存储操作数,但这会让每个常量指令都占用更多空间。大多数块不需要那么多唯一的常量,因此这会浪费空间,并且为了支持罕见的情况而牺牲了常见情况下的局部性。为了平衡这两个相互竞争的目标,许多指令集提供了多个指令来执行相同的操作,但操作数的大小不同。保持我们现有的单字节
OP_CONSTANT
指令不变,并定义第二个OP_CONSTANT_LONG
指令。它将操作数存储为一个 24 位数字,这应该足够了。实现此函数
void writeConstant(Chunk* chunk, Value value, int line) { // Implement me... }
它将
value
添加到chunk
的常量数组中,然后写入适当的指令以加载常量。另外,为OP_CONSTANT_LONG
指令添加对反汇编程序的支持。定义两个指令似乎是两全其美。它是否强迫我们做出任何牺牲,如果有的话,会是什么?
-
我们的
reallocate()
函数依赖于 C 标准库进行动态内存分配和释放。malloc()
和free()
不是魔法。找到几个它们的开源实现,并解释它们的工作原理。它们如何跟踪哪些字节是分配的,哪些是空闲的?分配内存块需要什么?释放它?它们如何使之高效?它们对碎片化做了什么?硬核模式:实现
reallocate()
而不调用realloc()
、malloc()
或free()
。您被允许在解释器执行开始时调用malloc()
一次,以分配单个大内存块,您的reallocate()
函数可以访问该块。您负责定义它如何执行此操作。
设计说明:测试您的语言
我们已经进行到这本书的半途,但我们还没有谈论到测试您的语言实现。这不是因为测试不重要。我无法过分强调拥有一个良好、全面的语言测试套件的重要性。
我在编写这本书的任何一个字之前就为 Lox 写了一个测试套件(欢迎您在您自己的 Lox 实现中使用),这些测试在我的实现中发现了无数错误。
测试在所有软件中都很重要,但它们对于编程语言而言更为重要,至少有以下几个原因
-
用户期望他们的编程语言非常稳定。我们已经习惯了成熟、稳定的编译器和解释器,因此“是您的代码,而不是编译器”已经成为软件文化中根深蒂固的一部分。如果您的语言实现中存在错误,用户将在弄清楚发生了什么之前经历悲伤的五个阶段,您不希望他们经历所有这些。
-
语言实现是深度互联的软件。一些代码库很宽泛,但很浅。如果您的文本编辑器中的文件加载代码出现故障,它—希望如此!—不会导致屏幕上的文本呈现失败。语言实现更窄、更深,尤其是处理语言实际语义的解释器核心。这使得微妙的错误很容易潜入,这些错误是由系统各个部分之间的奇怪交互引起的。需要良好的测试才能将它们清除。
-
语言实现的输入本质上是组合的。用户可以编写无数种可能的程序,您的实现需要正确运行它们。您显然无法对它们进行详尽的测试,但您需要努力涵盖尽可能多的输入空间。
-
语言实现通常很复杂、不断变化,并且充满了优化。这会导致混乱的代码,其中有很多隐藏错误的角落。
所有这些都意味着您将需要很多测试。但是什么样的测试?我见过的项目主要集中在端到端的“语言测试”上。每个测试都是用该语言编写的程序以及它预期产生的输出或错误。然后您有一个测试运行器,它将测试程序推送到您的语言实现中,并验证它是否按预期执行。用语言本身编写测试有几个优点
-
测试不会耦合到任何特定的 API 或实现的内部架构决策。这使您可以自由地重组或重写解释器或编译器的部分,而无需更新大量的测试。
-
您可以对语言的多个实现使用相同的测试。
-
测试通常可以简明扼要,易于阅读和维护,因为它们只是您语言中的脚本。
但并非所有都是美好的
-
端到端测试可以帮助你判断是否出现错误,但不能确定错误的位置。因为测试只会告诉你正确的输出没有出现,所以要找出代码实现中存在错误的代码段可能会比较困难。
-
精心设计一个程序来触发实现中的某个不为人知的角落可能会是一件很繁琐的事。对于高度优化的编译器来说,尤其如此,你可能需要编写复杂的代码来确保你走上了正确的优化路径,而错误可能隐藏在该路径中。
-
启动解释器、解析、编译和运行每个测试脚本都需要付出高昂的成本。对于大量的测试套件(你确实需要大量的测试套件,请记住这一点),这可能意味着你需要花费大量时间等待测试运行完成。
我还可以继续说下去,但我不想把这变成一场说教。此外,我并不想假装自己是语言测试方面的专家。我只是希望你能够意识到测试你的语言是多么重要。说真的,测试你的语言。你会感谢我的。