字节码
作为一个小白,要实现一个解释器,开始自然是一头雾水,无从下手。
好在上一节最后介绍了字节码,把整个解释器流程分为解析和执行两个阶段。那么我们就可以从字节码入手:
- 先确定字节码,
- 然后让解析过程(词法分析和语法分析)努力生成这套字节码,
- 再让执行过程(虚拟机)努力执行这套字节码。
生成 执行
解析 -------> 字节码 <------- 虚拟机
但字节码长什么样?如何定义?有什么类型?可以先参考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个字节码:
- VARARGPREP,暂时用不到,忽略。
- GETTABUP,这个有些复杂,可以暂时理解为:加载全局变量到栈上。3个参数分别是作为目标地址的栈索引(0)、忽略、全局变量名在常量表里的索引(0)。后面注释里列出了全局变量名是"print"。
- LOADK,加载常量到栈上。2个参数分别是作为目的地址的栈索引(1),和作为加载源的常量索引(1)。后面注释里列出了常量的值是"hello, world!"。
- CALL,函数调用。3个参数分别是函数的栈索引(0)、参数个数、返回值个数。后面注释说明是1个参数,0个返回值。
- 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的“值”这个概念,下一节介绍。