<自己动手实现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版本不匹配,就拒绝加载。

总体结构:

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

    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. 函数原型:

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

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

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

      print(5 // 3)				-- 1
      print(-5 // 3)			-- -2
      print(5 // -3.0)		-- -2.0
      print(-5.0 // -3.0)	-- -1.0
- 取模:
  
a % b == a - ((a // b) * b)
- 乘方和字符串拼接运算符为右结合性,其他二元运算符具有左结合性。
  1. 按位运算符:

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

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

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

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

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