<自己动手实现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格式:

  1. 二进制chunk属于lua虚拟机内部实现细节,并没有标准化以及相关的说明文档。
  2. 二进制chunk没有考虑跨平台需求:编译lua脚本时,会按照本机的大小端方式生成二进制chunk文件,如果加载的文件大小端方式与本机不匹配就拒绝加载。
  3. 加载二进制chunk文件时,会检测被加载文件的版本号,如果和当前lua版本不匹配,就拒绝加载。

总体结构:

1
2
3
4
5
type binaryChunk struct {
header // 头部
sizeUpvalues byte // 主函数upvalue数量
mainFunc *Prototype // 主函数原型
}
  1. 头部:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    type 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浮点数(用来检查浮点数格式)
    }

    【图】

  2. 函数原型:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    type 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实现细节关系不大,故在此不做赘述。

第三章:指令集

虚拟机根据实现方式大致可以分为两类:

  1. 基于栈(Stack Based):Java虚拟机、.NET CLR、Python虚拟机、Ruby YARV等

    使用PUSH类指令向栈顶推入值,其他指令是对栈顶进行操作,指令集相对较大,但指令长度较短。

  2. 基于寄存器(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. 算术运算符(加减乘除,整除,取模,乘方):

    • 除法运算符和乘方会先把操作数转换为浮点数再进行运算,计算结果也一定是浮点数。其他六个运算符会先判断操作数是不是整数,如果是则进行整数运算,结果也一定是整数;否则转换为浮点数计算,结果也是浮点数。

    • 整除运算符会将结果向下取整。

      1
      2
      3
      4
      print(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)
- 乘方和字符串拼接运算符为右结合性,其他二元运算符具有左结合性。
  1. 按位运算符:

    • 按位运算符会先把操作数转换成整数再运算,结果也一定是整数。
    • 右移运算符是无符号右移,空出来的bit补0。
    • 移动-n bit 等于反向移动 n bit。
  2. 逻辑运算符:

    • Lua 会对逻辑与(and)逻辑或(or)进行短路求值,运算结果为操作数之一,不一定是布尔值。

      1
      2
      t = t or {} 						-- if not t then t = {} end
      max = a > b and a or b -- a > b ? a : b
    • 逻辑非(not)会把操作数转换为布尔值,结果也一定是布尔值。

本章接下来的内容为用 Go 实现基本的 LuaAPI 方法:

1
2
3
4
5
// 覆盖了除逻辑运算符以外的所有 Lua 运算符
Arith() // 算术与按位运算
Compare() // 比较运算
Len() // 取长度运算
Concat() // 字符串拼接运算