用Go语言自制编译器
上QQ阅读APP看书,第一时间看更新

在构建虚拟机和一个与之匹配的编译器之前,我们需要先解决“鸡生蛋和蛋生鸡”的哲学问题:到底应该先构建哪一个?是编译器,为一个不存在的虚拟机输出字节码,还是虚拟机,但是此时又没有任何代码可以执行?

我在本书中给的答案是:虚拟机与编译器同时构建!

在一个组件之前构建另一个组件(不管这个组件是什么)是一件令人沮丧的事,因为很难预判后续事情的进展,也无法了解当下所做事情的真正目的。如果优先构建编译器并定义字节码,那么在不知道后续虚拟机如何执行它时,你很难理解当下的事情为什么如此。在编译器之前构建虚拟机也存在着天然的缺陷,因为字节码需要事先定义,虚拟机才能执行。如果不仔细查看字节码旨在表示的源语言结构,很难提前定义字节码,这意味着无论如何都要优先构建编译器。

当然,如果已经拥有构建其中一个组件的经验,并且很清楚地知道你所要的最终效果,那你可以选择任意一个组件来构建。但是,对本书而言,我们的目标是学习如何同时从零开始构建两个组件。

这就是我们从小处着手的原因。我们将构建一个只支持少量指令的微型虚拟机,以及一个与之匹配的微型编译器,仅用于输出这些指令。如此一来,我们会对自己所构建的内容及其相互之间如何适配有很深的理解。我们将拥有一个从零开始构建而成的可运行系统。该系统能提供快速的反馈周期,使我们能调整、试验并逐步完善虚拟机和编译器。整个旅程因此变得充满乐趣!

现在知道了全部的计划,而且足够了解编译器和虚拟机的基本原理,因此我们不会一直迷茫无助。让我们开始吧!

提及虚拟机,你可能会联想到VMWare或者VirtualBox一类的软件。这些是模拟计算机的程序,包括模拟磁盘驱动器、硬盘驱动器、图形卡等。例如,它们使你可以在此仿真计算机上运行其他操作系统。这些确实是虚拟机,但不是本书要讨论的内容。这是另一种形式的虚拟机。

我们即将讨论并构建的虚拟机是用来实现编程语言特性的。在这类虚拟机中,有些仅由几个函数组成,有些由几个模块组成,还有些是类和对象的集合。很难描述这类虚拟机的表现形式,但是这并不重要。重要的是,它并不是模拟已经存在的机器。它自身就是机器。

“虚拟”一词体现在,它仅存在于软件中,不存在于硬件中,因此它是纯抽象的构造。“机(器)”描述了它的行为。这些软件的结构就像一台机器,但不仅仅是机器,它们模仿的是计算机的硬件行为。

这意味着,为了理解和构建虚拟机,我们需要学习真实物理机的工作原理。

一台计算机到底如何工作呢?

这听起来是一个令人生畏的问题,实际上5分钟之内就可以在一张纸上画出答案。我不知道你的理解速度有多快,但我无法提前告诉你我在纸片上画的内容。无论如何,请让我尝试一下。

你在生命中遇到的几乎每一台计算机都遵循冯·诺依曼体系结构,该体系结构描述了一种用数量很少的组件构建功能强大的计算机的方法。

在冯·诺依曼模型中,计算机包括两个核心部分:一个是包括算术逻辑单元(ALU)和多个处理器寄存器的处理单元,另一个是包括指令寄存器和程序计数器的控制单元。它们统一被称为中央处理器,通常简称为CPU。除此之外,计算机还包括内存(RAM)、大容量存储(硬盘)和输入输出(I/O)设备(键盘和显示器)。

图1-3是一台计算机的工作简图。

图 1-3

在计算机打开的一瞬间,CPU会执行以下操作。

(1) 从内存中预取指令。程序计数器告知CPU从内存的哪个位置获取下一条指令。

(2) 解析指令。甄别需要执行什么操作。

(3) 执行指令。这一步可能会修改寄存器的内容,将数据从寄存器输出到内存,数据在内存中移动,生成输出,读取输入……

随后计算机会再次从(1)开始循环执行。

以上3步称为取指-解码-执行周期,也称为指令周期。名词“周期”来自于语句“计算机的时钟速度以每秒的周期数表示,例如500 MHz”或者“我们在浪费CPU周期”。

这是对计算机原理简短且易于理解的描述,但是我们可以使它变得更加简单。在本书中,我们不关注大容量存储组件,只关注I/O机制。我们感兴趣的是CPU和内存之间的交互。这意味着我们可以为此集中精力,忽略硬盘和显示器。

我们从以下问题开始研究:CPU如何处理内存的不同部分?或者换句话问:CPU如何知道在何处存储和检索内存中的内容?

我们首先了解CPU如何取指。作为CPU的一部分,程序计数器始终追踪从何处获取下一条指令。“计数器”的字面意思是:计算机直接利用数字对内存中的不同部分进行寻址。

关于这一点,我很想写“把内存想象成一个巨大的数组即可”,但是我害怕别人用书敲我的头:你真是个傻瓜,内存也毫无疑问不是一个数组。所以我不会这么写。但是,就像程序员使用索引访问数组元素一样,CPU也利用数字作为地址访问内存中的内容。

计算机内存并非“数组元素”,而是被分割成了一个个“字”。什么是“字”?它是计算机内存中的最小可寻址区域,是寻址时的基本单位。字的大小取决于CPU的类型,但是在我们使用的计算机中,标准字的大小是32位和64位。

假设有一台虚构的计算机,其字的大小为8位,内存大小为13字节。内存中一字可以包含一个ASCII字符,将Hello, World!存储在内存中则如图1-4所示。

图 1-4

字母“H”的内存地址为0,“e”的内存地址为1,第一个“l”的内存地址为2,“W”的内存地址为7,以此类推。我们可以通过内存地址0到12访问Hello, World!的每个字母。“嘿,CPU,获取内存地址4处的字母”这个指令会返回字母“o”。很简单吧!看到这里,我知道你在想什么,如果将数字(内存地址)保存到内存中的另一个位置,我们就完成了一个指针的创建。

这就是在内存中寻址数据以及CPU如何知道在何处获取和存储数据的基本思想,但是现实比这复杂很多。

前文提到过,不同计算机字的大小不同。有的是8位,有的是16位、24位、32位或者64位。有时CPU使用字的大小与地址大小无关。还有些计算机做着完全不同的事,它们采用字节寻址,而不是字寻址。

如果你正在使用字寻址,并希望寻址单字节(这并不罕见),你不仅需要处理不同的字长,还需要处理偏移量。这种操作的开销很大,必须进行优化。

除此之外,我们直接告诉CPU在内存中存储和检索数据的行为就像是一个童话。它在概念层面上是正确的,并且在学习时有助于理解,但如今的内存访问是抽象化的,并且位于一层又一层的安全和性能优化问题之后。内存不再是能够随意访问的区域,安全规则和虚拟机内存机制会尽力阻止这种情况发生。

以上就是计算机工作方式的简单介绍,毕竟这不是本书的重点。之后讨论一下虚拟内存的工作原理。你可以从本书中了解到,如今的内存访问不仅仅是将数字传递给CPU。不仅存在安全规则,在过去几十年中,还出现了一系列关于内存使用的约定,虽然不太严格。

冯·诺依曼体系结构的创新之处在于,计算机的内存不仅包含数据,还包含由CPU指令构成的程序。对现在的程序员来说,混合数据和程序听起来就是一个让人流泪的想法。几代以前的程序员听到这个想法应该也会有同样的反应,因为他们所做的事情都是努力建立内存使用协议,以防这种情况发生。

虽然这些程序与任何其他数据存储在相同的内存中,但它们通常不会存储在相同的位置。特定的内存区域用于存储特定的内容。这不仅是约定俗成的行为,而且受操作系统、CPU和计算机体系结构其余部分的支配。

“无意义数据”,如“文本文件的内容”或“HTTP请求的响应”,位于内存的某个区域中。构成程序的指令存储在另一个区域中,CPU可以从该区域直接获取它们。此外,有一个区域保存程序使用的静态数据;还有一个区域是空的且未初始化,但属于保留区域,程序运行后就可以使用它。操作系统内核的指令在内存中也有自己的特定区域。

顺便多说一句,程序和“无意义数据”可能存储在内存中的不同位置,但重要的是它们都存在于同一个内存中。“数据和程序都存在于同一个内存中”,听起来它们好像是不同的,实际上由指令构成的程序也是数据的一种。数据只有经过CPU从内存中预取、解码、确认正确并执行这一过程,才会成为指令。如果CPU解码的数据不是有效的指令,那么后果取决于CPU的设计。有些会触发事件并给程序一次发送正确指令的机会,有些则直接停止执行程序。

对我们来说,最有趣的是,这是一个特定的内存区域,一个用于存放栈的内存区域。强调一下,它是。你可能听说过它。“栈溢出”可能是它最著名的工作,其次让它出名的还有“栈追踪”。

栈到底是什么呢?它是内存中的一个区域,以后进先出(LIFO)的方式管理数据,以压栈和弹栈实现数据的伸缩,就像栈数据结构一样。但与这种通用数据结构不同的是,只专注于一个目的:实现调用栈

在这里停一下,这真的让人很困惑。“栈”“栈数据结构”“调用栈”,这些都不太容易理解,尤其是这些名词经常随意混合互换使用。但是,值得庆幸的是,如果仔细分辨这些名称并注意它们背后的“原理”,事情就会变得很清晰。因此,让我们一步步地解释一次。

我们拥有一个内存区域,CPU以LIFO方式访问和存储其中的数据。这样做是为了实现一个专门的,叫作调用栈

为什么需要调用栈?因为CPU(或者是期望CPU按照预期工作的程序员)需要追踪某些信息才能执行程序。调用栈对此会有所帮助。追踪什么信息?首先也是最重要的:当前正在执行哪个函数,以及接下来执行哪个指令。当前函数之后需要执行的指令信息,被称为返回地址。这是CPU执行当前函数之后返回的地方。如果没有这一信息,CPU只会把程序计数器加一并执行下一高地址处的指令。而这可能与应该发生的事情完全相反。指令在内存中并不是按照执行顺序存放的。想象一下,如果Go语言中的return语句丢失了会发生什么——这就是CPU需要追踪返回地址的原因。调用栈还有助于保存函数局部的执行相关数据:函数调用的参数和仅在函数中使用的局部变量。

返回地址、参数和局部变量,理论上我们可以将这些信息保存在内存中其他合适的可访问区域。但事实证明,使用来保存是完美的解决方案,因为函数调用通常是嵌套的。当进入一个函数时,相关数据被压栈。执行当前函数时,就不必通过调用外部函数来访问局部化相关数据,只需要访问栈顶相关元素即可。如果当前函数返回,则将局部化相关数据弹栈(因为这些数据不会再使用)。现在栈顶保留的是所调用外部函数的局部化相关数据。非常干净整洁,对吧?

这就是为什么需要调用栈,以及为什么用来实现它。现在唯一的问题是:为什么选这个臭名昭著的名字?为什么是?并不是因为它存储的是栈,而是因为使用这个内存区域来实现调用栈是一个如此牢固且广泛的约定,以至于现在它已被转换为硬件。甚至某些CPU仅支持压栈和弹栈的指令。在它们上面运行的程序都以这种方式使用这个内存区域来实现此机制

切记,调用栈只是一个概念,它不受特定内存区域特定实现的约束。没有硬件和操作系统强制支持和约束时,在内存中的任何一个区域都可以实现调用栈。事实上,这就是我们要做的。我们将实现自己的调用栈—— 一个虚拟调用栈。但在这样做并从物理机切换到虚拟机之前,我们需要理解另一个概念以做好充分准备。

现在你已经知道栈是如何工作的,那你想象一下执行一个程序时,CPU访问这个内存区域的频率。肯定相当高。这说明CPU访问内存的速度决定了程序运行的速度。虽然内存访问速度很快(眨一次眼的时间,CPU可以访问主内存大约一百万次),但它不是即时的,仍然有成本。

这就是为什么计算机在另一个地方存储数据:处理器寄存器。寄存器是CPU的一部分,访问寄存器的速度要远快于访问内存的速度。人们可能会问,为什么不把所有东西都存在寄存器中?因为寄存器的数目很小,而且它们不能容纳与内存一样多的数据,通常每个寄存器只能存储一个字。例如,一个x86-64架构的CPU包含16个通用寄存器,每个寄存器可以存储64位的数据。

寄存器用于存储小且被经常访问的数据。例如,指向栈顶部的内存地址通常存储在寄存器中——至少是“通常”。寄存器的这种特定用法非常普遍,以至于大多数CPU有一个专门用于存储该指针的指定寄存器,即所谓的栈指针。某些CPU指令的操作数和结果也可以存储在寄存器中。如果CPU需要将两个数字相加,则它们都将存储在寄存器中,并且相加的结果也将保存在某个寄存器中。但这还不是全部。寄存器还有更多用例。如果经常访问某一程序中的大量数据,则可以将其地址存储到寄存器中,这样CPU就可以非常快速地访问它。不过,对我们来说最重要的是栈指针。我们很快会再次遇见它。

现在,你可以深呼吸并放松一下,因为物理机的工作原理大概就是上面描述的这样。了解了寄存器和栈指针,有关物理机工作原理的知识就介绍完了。是时候开始抽象化了,我们将从物理机走向虚拟机。

直截了当地说,虚拟机是由软件实现的计算机。它是模拟计算机工作的软件实体。当然,“软件实体”并不能表示虚拟机的全部,但我使用这个词的主要目的是想说明,虚拟机可以表示所有:一个函数、一个结构体、一个对象、一个模块,甚至整个程序。它能表示什么并不重要,重要的是它担当什么角色。

虚拟机跟物理机一样,有特定的运行循环,即通过循环执行“取指取解码解执行”来完成运转。它有一个程序计数器,可以获取指令,然后解析并执行它。与物理机类似,它同样拥有栈,有时是调用栈,有时是寄存器。所有的一切全部内置在软件中。

多说无益,代码为上。下面是一个用几十行JavaScript代码完成的虚拟机:

let virtualMachine = function(program) {
let programCounter = 0;
let stack = [];
let stackPointer = 0;
while (programCounter < program.length) {
let currentInstruction = program[programCounter];
switch (currentInstruction) {
case PUSH:
stack[stackPointer] = program[programCounter+1];
stackPointer++;
programCounter++;
break;
case ADD:
right = stack[stackPointer-1]
stackPointer--;
left = stack[stackPointer-1]
stackPointer--;
stack[stackPointer] = left + right;
stackPointer++;
break;
case MINUS:
right = stack[stackPointer-1]
stackPointer--;
left = stack[stackPointer-1]
stackPointer--;
stack[stackPointer] = left - right;
stackPointer++;
break;
}
programCounter++;
}
console.log("stacktop: ", stack[stackPointer-1]);
}

它拥有一个程序计数器programCounter、一个栈stack,以及一个栈指针stackPointer。它有一个运行循环,只要程序中有指令,它就会执行。先取出程序计数器指向的指令,然后解析并执行它。这个循环每迭代一次,就是虚拟机的一个“循环周期”。

我们可以为这个虚拟机构建一个程序并执行它:

let program = [
PUSH, 3,
PUSH, 4,
ADD,
PUSH, 5,
MINUS
];
virtualMachine(program);

你是否能识别出这些指令中编码的表达式?是这样的:

(3 + 4) - 5

如果你没有识别出也没关系,你很快就能理解这一切。一旦习惯在栈上进行算术运算,这个program就不难理解。首先PUSH34添加到栈顶,然后ADD将它从栈顶弹出,相加后将结果压栈,接着PUSH5添加到栈顶,然后MINUS将栈顶第2个元素减去5,之后将结果压栈。

循环完成后,虚拟机会将存在栈顶的结果打印出来:

$ node virtual_machine.js
stacktop:  2

现在,这是一个可以正常工作的虚拟机,只是它相当简单。可以预见,它并没有展示出虚拟机的全部功能。构建一个虚拟机,可以像前文那样,用约50行代码,也可以用5万行甚至更多。二者之间的主要区别是功能和性能的不同选择。

一个最重要的设计选择是使用栈式虚拟机还是寄存器式虚拟机。这个选择非常重要,因为虚拟机是根据此架构进行分类的,就像编程语言从根源上分为“编译型”和“解释型”一样。简单来说,栈式虚拟机和寄存器式虚拟机的区别是:虚拟机是利用栈(前文例子所演示的那样)还是利用寄存器(虚拟寄存器)来完成计算。关于哪种选择更好(读取速度更快)的争论一直存在,因为需要权衡取舍并针对不同选择做好准备。

一般认为栈式虚拟机及其相应的编译器更易于构建。虚拟机需要的组件更少,其执行的指令也更加简单,因为它们“仅”使用了。缺点在于,需要执行指令的频率更高,因为所有操作必须通过压栈和弹栈才能完成。这就限制了人们可以采用性能优化的基本规则的程度:与其尝试做得更快,不如先尝试做得更少。

构建寄存器式虚拟机需要做更多的工作,因为寄存器是辅助添加的。它也拥有栈,不过不像栈式虚拟机那样频繁地使用栈,只是仍然有必要实现调用栈。寄存器式虚拟机的优点是指令可以使用寄存器,因此与栈式虚拟机相比,其指令密度更高。指令可以直接使用寄存器,而不必将它们放到栈上,保证压栈和弹栈的顺序正确。一般来说,与栈式虚拟机相比,寄存器式虚拟机使用的指令更少。这会带来更好的性能。但是,构建产生这样密集指令的编译器需要花费更多精力。正如前文所述,需要权衡取舍。

除了以上主要架构选择之外,构建虚拟机还涉及许多其他决策。如何使用内存,以及如何确定值的中间表示(在上一本书中,为Monkey求值器构建对象系统时已经讨论过)也是很重要的决策。此外还有无尽微小的决策,就像蜿蜒的兔子洞,可能会让你迷失其中。让我们选一个,一探究竟。

在上文的例子中,我们利用switch表达式完成了虚拟机运行循环中的分派工作。在虚拟机中,分派意味着在指令执行之前,为该指令选择一个合理的实现。在switch表达式中,指令的实现紧接着case语句。MINUS负责两个值相减,ADD负责两个值相加。这就是分派。虽然switch表达式似乎是唯一的选择,但实际差之甚远。

switch表达式只是兔子洞的入口而已。当寻求更高的性能时,你需要走到更深处。之后你发现,分派会使用跳转表,会使用GOTO表达式,会使用直接或间接的线程代码,因为不管你是否相信,在case分支足够多的情况下(数百个或更多),switch可能是这些解决方案中最慢的一种。为了减少分派的开销,从性能的角度来看,switch语句的性能表现就像是取指-解码-执行过程中的取指-解码部分消失了。以上足以让你体会到兔子洞到底有多深。

现在,我们大致了解了什么是虚拟机,以及构建虚拟机的整个过程。如果你仍然不明白一些细节,不用担心。为了构建自己的虚拟机,我们会再次讨论许多主题和想法,当然,还有兔子洞。

让我们分析一下刚刚学到的内容。为什么要构建虚拟机来实现编程语言?必须承认,这是困扰我时间最长的问题。即使在构建了一些小型虚拟机并阅读了一些大型虚拟机的源代码之后,我仍然在思考:为什么?

当实现一种编程语言时,我们希望它是通用的,能够执行所有可能遇到的程序,而不仅仅是我们提供的示例函数。通用计算是我们追求的目标,而计算机为此提供了坚实的模型。

如果基于此模型来构建编程语言,它将拥有与计算机相同的计算能力。当然这也是使程序执行最快的一种方式。

但是,如果像计算机一样执行程序是最好且最快的方式,为什么不让计算机自身来执行程序,反而要构建一个虚拟机呢?答案是:可移植性!我们可以为我们的编程语言编写一个编译器,以便在计算机上本地执行翻译后的程序。这些程序确实很快。但是对于每一种不同的计算机体系结构,我们都需要为其重新构建一个新的编译器。这将带来大量的工作。所以,我们可以将程序转换成虚拟机指令。虚拟机本身可以在与其实现语言一样多的架构上运行。对于Go编程语言而言,它非常便于移植。

通过虚拟机来实现编程语言,还有一个我认为极具吸引力的理由:虚拟机是领域特定的。这使它们与非虚拟机完全不同。计算机为我们提供了一个满足所有计算需求的通用解决方案,并且不是领域特定的。这正是我们对一台计算机的需求,因为要在其上运行各种程序。但是,如果我们不需要一台通用的计算机怎么办?如果程序员只需要计算机为其提供部分功能子集,又该怎么办呢?

作为程序员,我们知道任何功能都需要付出代价。复杂度的增加和性能的下降只是常见的两种代价。当今的计算机具有很多功能。x86-64的CPU支持900~4000条指令,具体数字取决于你如何计算指令数。这包括两个操作数进行按位XOR的至少6种方法。这使计算机变得方便和通用。但这不是免费的。像其他所有功能一样,多功能性也需要付出代价。回想前文中那个微型虚拟机里涉及的switch表达式,花一秒钟的时间来思考增加3997个case分支会对性能有什么影响。如果不确定虚拟机是否真的会变慢,那请问问自己,为该虚拟机维护代码或编程的难度怎样。好消息是我们可以扭转这一局面。如果摈弃不需要的功能,会速度更快,复杂性更低,维护性更强,结构更轻便。这就是虚拟机发挥作用的地方。

虚拟机就像一台定制计算机。它拥有自定义组件和自定义的机器语言。相当于它优化为只能使用单一的编程语言。所有不必要的功能都被裁剪,剩下的都是高度专业化的功能。由于不需要像通用计算机那样通用,因此它的功能更集中。你可以集中精力使这台高度专业化和定制化的机器发挥最大作用,并尽可能地快。高度专业化和领域特定性与裁剪不必要的功能一样重要。

当看到虚拟机执行的指令时,这些为什么如此重要就变得愈加清晰,而这些正是我们前文一直避而不谈的东西。还记得我们为微型虚拟机提供的信息吗?如下所示:

let program = [
PUSH, 3,
PUSH, 4,
ADD,
PUSH, 5,
MINUS
];
virtualMachine(program);

现在,是否已经理解了呢?什么是PUSH,什么是ADD,什么又是MINUS?下面是它们的定义:

const PUSH = 'PUSH';
const ADD = 'ADD';
const MINUS = 'MINUS';

PUSHADDMINUS只是引用字符串的常量。没有任何神奇之处。是不是很失望?这些定义就像玩具一样,仅与虚拟机的其余部分一起用于说明。它们并没有回答这里出现的更大、更有趣的问题:虚拟机究竟执行了什么操作?

虚拟机执行字节码。就像计算机执行的机器码一样,字节码也是由机器指令构成的。之所以叫字节码,是因为所有指令的操作码大小均为一字节。

“操作码”是指令的“操作”部分。前文提到的PUSH就是一种操作码,不过在我们的示例代码中,它是一个多字节操作码,不是一字节的。在正常的实现中,PUSH只是一个引用操作码的名称,该操作码本身是一字节宽。这些名称(PUSH或者POP)被称为助记符。它们的存在价值是帮助程序员记住操作码。

操作码的操作数(也称作参数)也包含在字节码中。操作数紧跟着操作码,它们彼此并列在一起。不过操作数的大小并不一定是一字节。如果操作数是一个大于255的整数,那么就需要多个字节来表示它。有些操作码有多个操作数,有的只有一个操作数,有些甚至一个操作数都没有。不管字节码被设计成寄存器式还是栈式,它都有重大影响。

你可以把字节码想象成一系列的操作码和操作数,一个接一个并排分布在内存中,如图1-5所示。

图 1-5

图1-5能帮助理解基本意思。字节码是几乎毫无可读性的二进制格式,无法像读文本一样阅读。助记符,例如PUSH,并不会显示在实际的字节码中。取而代之的是它所引用的操作码,这些操作码以数字表示,具体是什么数字完全取决于定义字节码的人。例如,PUSH助记符由0表示,POP则由23表示。

操作数同样依赖于它自身的值决定用多少字节来进行编码。如果操作数需要多字节来表示,编码顺序就显得格外重要。目前存在两种编码顺序:大端编码小端编码。小端编码的意思是原始数据中的低位放在最前面并存储在最低的内存中。大端编码则相反:高位存储在最低的内存中。

假如我们是字节码设计者,我们将PUSH助记符用1表示,ADD2表示,整型采用大端存储。对上面实例进行编码并布局在内存中,情况如图1-6所示。

图 1-6

我们刚刚所做的——将一个人类可读的字节码转换成二进制数据——由叫作汇编器的程序完成。你在非虚拟的机器代码中可能听说过它们,这里也是一样。汇编语言是字节码的可读版本,包含助记符和可读操作数,汇编器能将其转换为二进制字节码。反之,将二进制表示转换成可读表示的程序,称为反汇编器。

对于字节码纯技术部分的介绍到此为止。任何更进一步的探索都会变得更专业、更具体。字节码格式过于多样化和专业化,我们无法在此处给出更通用的说明和描述。就像执行字节码的虚拟机一样,字节码在创建时也需要有一个具体的目标。

字节码是一种领域特定的语言。它是定制虚拟机的定制机器语言。这就是它的魔力所在。字节码可以是专业化的,它不是通用的,不必支持所有可能的情况。它只需支持可以编译为字节码的源语言所需要的功能。

不仅如此,除了仅支持少数指令之外,字节码还包括在领域特定虚拟机上下文中才有意义的领域特定指令。例如,Java虚拟机(JVM)的字节码包括以下指令:invokeinterface用于调用接口方法,getstatic用于获取类的静态字段,new用于为指定的类创建对象。Ruby的字节码有:putself指令用于将self压入栈,send用于向对象发送消息,putobject用于将对象压入栈。Lua的字节码具有访问和操作表和元组的专用指令。在x86-64的通用指令集中找不到以上任何指令。

这种通过使用自定义字节码格式实现专业化的能力是构建虚拟机的重要原因之一。这不仅使编译、维护和调试变得更加容易,而且所得到的代码也更加密集,因为它表达某些内容所使用的指令更少,从而使代码执行起来更快。

现在,如果所有关于自定义虚拟机、量身定制的机器代码、手工构建编译器的讨论都没能引起你的兴趣,那么这是你放弃本书的最后机会。我们将正式开始。