字节码

作为一个小白,要实现一个解释器,开始自然是一头雾水,无从下手。

好在上一节最后介绍了字节码,把整个解释器流程分为解析和执行两个阶段。那么我们就可以从字节码入手:

  • 先确定字节码,
  • 然后让解析过程(词法分析和语法分析)努力生成这套字节码,
  • 再让执行过程(虚拟机)努力执行这套字节码。
          生成             执行
    解析 -------> 字节码 <------- 虚拟机

但字节码长什么样?如何定义?有什么类型?可以先参考Lua的官方实现。

luac的输出

为方便叙述,这里再次列出目标代码:

print "hello, world!"

Lua官方实现自带一个非常好用的工具,luac,即Lua Compiler,把源代码翻译为字节码并输出。是我们这个项目的最得力助手。看下其对"hello, world!"程序的输出:

$ luac -l hello_world.lua

main <hello_world.lua:0,0> (5 instructions at 0x600000d78080)
0+ params, 2 slots, 1 upvalue, 0 locals, 2 constants, 0 functions
	1	[1]	VARARGPREP	0
	2	[1]	GETTABUP 	0 0 0	; _ENV "print"
	3	[1]	LOADK    	1 1	; "hello, world!"
	4	[1]	CALL     	0 2 1	; 1 in 0 out
	5	[1]	RETURN   	0 1 1	; 0 out

输出的前面2行看不懂,先忽略。后面应该就是字节码了,还有注释,太棒了。不过还是看不懂。查看Lua的官方手册,但是发现找不到任何关于字节码的说明。原来Lua的语言标准只是定义了语言的特性,而字节码属于“具体实现”的部分,就像解释器代码里的变量命名一样,并不属于Lua标准的定义范围。事实上完全兼容Lua 5.1的Luajit项目就用了一套完全不一样的字节码。我们甚至可以不用字节码来实现解释器,呃,扯远了。既然手册没有说明,那就只能查看Lua官方实现的代码注释。这里只介绍上面出现的5个字节码:

  1. VARARGPREP,暂时用不到,忽略。
  2. GETTABUP,这个有些复杂,可以暂时理解为:加载全局变量到栈上。3个参数分别是作为目标地址的栈索引(0)、忽略、全局变量名在常量表里的索引(0)。后面注释里列出了全局变量名是"print"。
  3. LOADK,加载常量到栈上。2个参数分别是作为目的地址的栈索引(1),和作为加载源的常量索引(1)。后面注释里列出了常量的值是"hello, world!"。
  4. CALL,函数调用。3个参数分别是函数的栈索引(0)、参数个数、返回值个数。后面注释说明是1个参数,0个返回值。
  5. RETURN,暂时用不到,忽略。

连起来再看一下,就是

  • 首先把名为print的全局变量加载到栈(0)位置;
  • 然后把字符串常量"hello, world!"加载到栈(1)位置;
  • 然后执行栈(0)位置的函数,并把栈(1)位置作为参数。

执行时的栈示意图如下:

  +-----------------+
0 | print           | <- 函数
  +-----------------+
1 | "hello, world!" |
  +-----------------+
  |                 |

我们目前只要实现上述的2、3、4这三个字节码即可。

字节码定义

现在定义字节码格式。

首先参考Lua官方实现的格式定义。源码里有对字节码格式的注释:

  We assume that instructions are unsigned 32-bit integers.
  All instructions have an opcode in the first 7 bits.
  Instructions can have the following formats:

        3 3 2 2 2 2 2 2 2 2 2 2 1 1 1 1 1 1 1 1 1 1 0 0 0 0 0 0 0 0 0 0
        1 0 9 8 7 6 5 4 3 2 1 0 9 8 7 6 5 4 3 2 1 0 9 8 7 6 5 4 3 2 1 0
iABC          C(8)     |      B(8)     |k|     A(8)      |   Op(7)     |
iABx                Bx(17)               |     A(8)      |   Op(7)     |
iAsBx              sBx (signed)(17)      |     A(8)      |   Op(7)     |
iAx                           Ax(25)                     |   Op(7)     |
isJ                           sJ(25)                     |   Op(7)     |

  A signed argument is represented in excess K: the represented value is
  the written unsigned value minus K, where K is half the maximum for the
  corresponding unsigned argument.

字节码用32bit的无符号整数表示。其中7bit是命令,其余25bit是参数。字节码一共5种格式,每种格式的参数不同。如果你喜欢这种精确到bit的控制感,也许会立即想到各种位操作,可能已经开始兴奋了。不过先不着急,先来看下Luajit的字节码格式:

A single bytecode instruction is 32 bit wide and has an 8 bit opcode field and
several operand fields of 8 or 16 bit. Instructions come in one of two formats:

+---+---+---+---+
| B | C | A | OP|
|   D   | A | OP|
+---+---+---+---+

也是32bit无符号整数,但字段的划分只精确到字节,而且只有2种格式,比Lua官方实现简单很多。在C语言里,通过定义匹配的struct和union,就可以较方便地构造和解析字节码,从而避免位操作。

既然Lua语言没有规定字节码的格式,那我们也可以设计自己的字节码格式。像这种不同类型命令,每个命令有独特关联参数的场景,最适合使用Rust的enum,用tag做命令,用关联的值做参数:

#[derive(Debug)]
pub enum ByteCode {
    GetGlobal(u8, u8),
    LoadConst(u8, u8),
    Call(u8, u8),
}

Luajit的字节码定义可以避免位操作,而使用Rust的enum可以更进一步,甚至都不用关心每个字节码的内存布局。可以用enum的创建语法来构造字节码,比如ByteCode::GetGlobal(1,2);用模式匹配match来解析字节码。在后面1.4节里的parse和vm模块分别构造和解析字节码。

不过也要注意保证这个enum不超过32bit,所以还是要了解一下enum的布局。Rust中enum的tag的大小是以字节为单位,并且是按需分配的。所以只要字节码种类少于2^8=256个,那么tag就只需要1个字节。Lua官方的字节码里只有7bit用来表示命令类型,所以256是足够的。然后就还有3个字节的空间可以存储参数。Luajit的两种字节码类型里,参数也都只占了3个字节,那就也是足够的。这个文章介绍了静态检查的方法,不过由于需要第三方库或宏,我们这里暂时不用。

Rust的enum真的很好用!

两个表

上面的分析里可以看到,除了字节码,我们还需要两个表。

一个是常量表,在解析过程中存储所有遇到的常量,生成的字节码通过索引参数来引用对应的常量;在执行过程中虚拟机通过字节码里的参数来读取表中的常量。在这个例子里,遇到两个常量,一个是全局变量print的名字,另外一个是字符串常量"hello, world!"。这也就是上述luac的输出第2行里2 constants的意思了。

另一个是全局变量表,根据变量名称保存全局变量。虚拟机执行时,先通过字节码中参数查询常量表里的全局变量名,然后再根据名字查询全局变量表。全局变量表只在执行过程中使用(添加,读取,修改),而跟解析过程无关。

这两个表的具体定义,需要依赖Lua的“值”这个概念,下一节介绍。