<自己动手实现Lua:虚拟机,编译器和标准库>读书笔记
本篇是《自己动手实现Lua:虚拟机、编译器和标准库》的读书笔记,持续更新中。
第一章:
第一章的内容本质上就是配环境,值得一提的一点是,如果下载了最新版的Go,可能会和书上的内容有些出入,所以为了避免不必要的麻烦,我还是按照书上的描述下载了Go(v1.10.2)。
第二章:二进制chunk
Lua脚本并不是直接被Lua解释器解释执行,而是先由Lua编译器编译成字节码,然后再交给Lua虚拟机去执行。
Lua解释器会在内部编译lua脚本(此处描述存疑),预编译可以加快脚本加载的速度,并可以在一定程度上保护源代码。
Lua本身提供了命令行工具luac来编译lua源代码,以函数为单位进行编译,每个函数都会被编译成名为Prototype
的结构,包含内容有下:
- 函数基本信息
- 字节码
- 常量表
- Upvalue表
- 调试信息
- 子函数原型列表
Lua编译器会为我们的脚本添加一个主函数,把整个程序都放进这个函数里,然后以其为起点进行编译。这个函数既是编译的起点,也是虚拟机解释执行程序的入口。对于一行代码print("Hello world!")
,使用luac反编译编译出来的.luac文件可以看到输出:
【图】
二进制chunk格式:
- 二进制chunk属于lua虚拟机内部实现细节,并没有标准化以及相关的说明文档。
- 二进制chunk没有考虑跨平台需求:编译lua脚本时,会按照本机的大小端方式生成二进制chunk文件,如果加载的文件大小端方式与本机不匹配就拒绝加载。
- 加载二进制chunk文件时,会检测被加载文件的版本号,如果和当前lua版本不匹配,就拒绝加载。
总体结构:
1 | type binaryChunk struct { |
头部:
1
2
3
4
5
6
7
8
9
10
11
12
13type header struct {
signature [4]byte // 魔数,用来标示文件开头(用来快速识别文件)
version byte // lua版本号 校验用
format byte // 格式号 校验用
luacData [6]byte // LUAC_DATA 校验用
cintSize byte // 指令宽度 cint 校验用
sizetSize byte // 指令宽度 size_t 校验用
instructionSize byte // 指令宽度 Lua虚拟机指令 校验用
luaIntegerSize byte // 指令宽度 Lua整数 校验用
luaNumberSize byte // 指令宽度 Lua浮点数 校验用
luacInt int64 // Lua整数值(用来检查大小端方式)
luacNum float64 // Lua浮点数(用来检查浮点数格式)
}【图】
函数原型:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15type Prototype struct {
Source string // 源文件名(仅在主函数中有值,避免重复)
LineDefined uint32 // 起止行号(对应函数的起止行号)
LastLineDefined uint32
NumParams byte // 固定参数个数
IsVararg byte // 是否有变长参数,0否1是
MaxStackSize byte // 寄存器数量:函数执行期间至少用到的
Code []uint32 // 指令表(每条指令4个字节)
Constants []interface{} // 常量表,存放Lua代码中出现的字面量
Upvalues []Upvalue // Upvalue表(第十章介绍,待补,不过我估计是把闭包用到的值的拷贝)
Protos []*Prototype // 子函数原型表(递归)
LineInfo []uint32 // 行号表(每条指令对应源代码中的行号)
LocVars []LocVar // 局部变量表(结构:变量名,起止行号)
UpvalueNames []string // Upvalue名列表(与Upvalues一一对应)
}
第二章中后续的部分都是用Go实现一个简单的编译器的细节,和Lua实现细节关系不大,故在此不做赘述。
第三章:指令集
虚拟机根据实现方式大致可以分为两类:
基于栈(Stack Based):Java虚拟机、.NET CLR、Python虚拟机、Ruby YARV等
使用PUSH类指令向栈顶推入值,其他指令是对栈顶进行操作,指令集相对较大,但指令长度较短。
基于寄存器(Register Based):Lua虚拟机(5.0版之后)
可以直接对寄存器进行寻址、指令集相对较小,但需要把寄存器地址编码到指令里,所以指令的平均长度较长。
指令长度是否固定,可以分为定长和变长两种指令集:Lua虚拟机是定长指令集,6bit为操作码,其余26bit为操作数。
编码模式:
Lua虚拟机是定长指令集,6bit为操作码,其余26bit为操作数。
Lua虚拟机指令可以分为四类:iABC、iABx、iAsBx、iAx
指令与操作数的关系如图所示:
【图】
只有iAsBx模式下的sBx操作数会被解释成有符号整数,其他情况下操作数均被解释为无符号整数。
本章剩余部分完善了上个部分实现的反编译器。
第四章:Lua API
Lua核心是以库的形式实现的,其他应用程序只需要链接Lua库就可以使用Lua提供的API。
Lua栈
Lua State内部封装的最为基础的一个状态就是虚拟栈,是宿主语言和Lua进行沟通的桥梁。
栈索引:
- Lua API中,栈索引是从1开始的;
- 索引可以是负数,正数从1(栈底)开始递增,负数从-1(栈顶)开始递减,Lua API会在内部把负数转换为正数。
- 假设栈的容量为n,位于[1,top]区间内的索引为有效索引,位于[1,n]区间内的索引为可接受索引。写入值必须在有效索引区间,读取值可以从可接受索引中读取。
本章剩余部分使用go语言实现了一个简单的库,包含了入栈出栈函数等。
第五章:Lua运算符
算术运算符(加减乘除,整除,取模,乘方):
除法运算符和乘方会先把操作数转换为浮点数再进行运算,计算结果也一定是浮点数。其他六个运算符会先判断操作数是不是整数,如果是则进行整数运算,结果也一定是整数;否则转换为浮点数计算,结果也是浮点数。
整除运算符会将结果向下取整。
1
2
3
4print(5 // 3) -- 1
print(-5 // 3) -- -2
print(5 // -3.0) -- -2.0
print(-5.0 // -3.0) -- -1.0
- 取模:
1
a % b == a - ((a // b) * b)
- 乘方和字符串拼接运算符为右结合性,其他二元运算符具有左结合性。
按位运算符:
- 按位运算符会先把操作数转换成整数再运算,结果也一定是整数。
- 右移运算符是无符号右移,空出来的bit补0。
- 移动-n bit 等于反向移动 n bit。
逻辑运算符:
Lua 会对逻辑与(and)和逻辑或(or)进行短路求值,运算结果为操作数之一,不一定是布尔值。
1
2t = t or {} -- if not t then t = {} end
max = a > b and a or b -- a > b ? a : b逻辑非(not)会把操作数转换为布尔值,结果也一定是布尔值。
本章接下来的内容为用 Go 实现基本的 LuaAPI 方法:
1 | // 覆盖了除逻辑运算符以外的所有 Lua 运算符 |