虚拟机与字节码
6.1 编译的两个阶段
我们的编译器项目分为两个阶段:
- 1. 将语法树编译为一个假想的虚拟机(VM)的指令。
- 2. 将虚拟指令映射到 x64 指令,并生成 Linux ELF 可执行文件。
将我们的编译器分为两个阶段有很多优点,而生产级别的编译器可能会有两个以上的阶段。其中一个优点是,我们的虚拟机是真实机器的一种抽象表示;这意味着它没有特定于架构的细节。在第二阶段,虚拟机可以转换为任何真实的 CPU 架构,比如 x64 或 ARM(不过我们只使用 x64)。
这种两阶段的设计也允许进行增量式实现,这使得本书更易于理解和遵循。
虚拟指令在编译器和解释器中被广泛使用,并且通常被称为 “字节码”。一个高效的解释器通过解释字节码而不是语法树来执行程序,尽管这不是本书的目标。
6.2 虚拟机设计
有许多可能的字节码设计方案。但如果我们将字节码设计得尽可能接近真实机器,那么向真实机器的转换将会更容易。我们将采用以下设计:
6.2.1 数据栈
有一个数据栈用于存储局部变量和临时变量。函数调用的栈区域被称为 “帧”。
| function 0 | function 1 | function 2 |
+------------+------------+---------------------------------+
| | | arguments ... | variables ... |
| | | 0 | 1 | 2 | ... | n | n+1 | ... |
+------------+------------+---------------------------------+
| frame 0 | frame 1 | frame 2 |
|=> top
(上图:函数 0 调用函数 1,函数 1 调用函数 2)
当前(栈顶)帧上的变量使用从 0 开始的索引。参数就像局部变量一样在帧上传递。函数的返回值(如果有)会被放置在第一个参数的位置。
你可能已经注意到与真实机器的一个区别:它不在栈中存储返回地址。如何保存返回地址是一个特定于架构的细节,将在第二阶段处理。
6.2.2 虚拟指令
我们的虚拟机也没有寄存器,而是使用栈上的变量。在第二阶段可以添加寄存器分配,以便将变量映射到寄存器。不过这是可选的,因为我们可以简单地将变量加载到固定的临时寄存器中进行算术运算,然后再将结果放回。
我们将使用以下符号来表示字节码:
- • 变量通过它们在当前帧上从 0 开始的索引来引用。
- •
mov src dst
:将变量src
复制到变量dst
中。 - •
const val dst
:将一个常量加载到变量dst
中。 - •
binop op a1 a2 dst
:执行二元算术运算(op a1 a2)
,并将结果存储到dst
中。 - •
unop op a1 dst
:用于一元运算。 - •
jmpf a1 L
:如果a1
的值为假,则跳转到位置L
。 - •
jmp L
:无条件跳转到位置L
。 - •
call func arg_start level_cur level_new
:调用函数func
。arg_start
是第一个参数(也是返回值)的索引。level_cur
和level_new
稍后会解释。 - •
ret a1
:从函数调用中返回,并使用变量a1
作为返回值。 - •
ret -1
:返回一个没有返回值的函数。 - •
syscall dst num args...
:调用系统调用num
,并将返回值存储到dst
中。 - •
get_env level var dst
:将一个非局部变量获取到dst
中。 - •
set_env level var src
:使用src
更新一个非局部变量。
6.2.3 非局部变量
在许多编程语言中,函数除了可以访问局部变量和参数外,还可以访问其他变量。在 C 语言中,所有函数都是全局且顶级的,变量可以是局部的或全局的。全局变量在顶级定义,并且可以被函数访问。
在其他语言中,比如 Python 或 Go,函数可以嵌套,嵌套函数可以读取和更新父函数中的变量,而且这些语言甚至允许传递函数。这个特性被称为 “闭包” —— 函数捕获函数外部的变量,并作为一个值被传递。
允许嵌套函数访问自身外部的变量会带来一些额外的复杂性。如果这些变量存在于栈上,那么在函数离开其作用域后调用捕获这些变量的函数是非法的。在支持垃圾回收(GC)的语言中,通过将变量提升到堆上来解决这个问题。有些语言允许你按值捕获变量,这样变量总是可访问的,但你不能通过值来更新外部变量。
我们要实现的编译器不属于上述任何一种情况。我们的编译器允许嵌套函数并且可以更新函数外部的变量,但不支持传递函数;函数总是在其定义的作用域内执行。这比支持垃圾回收的语言更容易实现,因为我们不需要运行时来支持程序。而且它只比 C 语言的函数稍微复杂一点。
6.3 静态类型
在编写简单解释器时,我们没有考虑类型问题,因为我们将类型处理交给了宿主语言:Python。Python 中的几乎所有东西都是在堆上分配的 “对象”,并且都有一个类型。Python 解释器会不断检查对象的类型,以便决定对它们进行何种操作。这被称为 “动态类型”。
这与 “静态类型” 形成对比,在静态类型中,编译器在编译时就知道值的类型,并且知道如何处理它们。例如,要在 Python 中执行表达式 a + b
,解释器必须检查两个操作数的类型,并且根据它们的类型采取不同的执行路径:解释器可能会抛出异常、对两个数字进行加法运算、连接两个字符串等等。
这就是为什么你不能像编译 C/C++ 那样将 Python 编译成本机机器码,因为编译器没有太多工作可做,大部分工作都在运行时完成。然而,有即时(JIT)编译器可以在运行时收集类型信息,并将 Python 函数编译成机器码。这凸显了类型的重要性。
我们将在语言设计中使用静态类型;它更容易编译。而且它也有助于理解事物的工作原理,因为 CPU 并不处理类型或对象,CPU 主要处理整数和指针!
我们的语言只支持几种类型:
- •
int
:表示int64
,有符号 64 位整数。 - •
byte
:表示uint8
,无符号 8 位整数。 - •
ptr int
:指向int
的指针。 - •
ptr byte
:指向byte
的指针。 - •
void
:仅用于不返回任何值的函数。