一、理解Lua的执行

lua虽然是脚本语言,但其内部执行方式和Java语言相似。

  1. lua编译器先将lua脚本文件(纯文本文件)通过词法分析、生成抽象语法树、语法分析,代码生成,生成为二进制的chunk文件,然后保存成文件。
  2. lua解析器加载解析二进制chunk文件,然后通过chunk里的指令码找到对应的lua底层C函数执行。

二、Lua编译器

编译器往往会把编译过程分为不同的阶段,每个阶段单独编写,各个阶段通过输入和输出串联起来最终形成完整的编译器。主要的编译阶段包括预处理、词法分析、语法分析、语义分析、中间代码生成、中间代码优化、目标代码生成等。

上述编译阶段又可以进一步分为三个大的阶段:
前端(Front End):预处理、词法分析、语法分析、语义分析、中间代码
中端(Middle End):中间代码优化
后端(Back End):目标代码生成

注:以下伪代码由go语言实现。

2.1 词法分析器

词法分析器一般使用有限状态机(Finite-state Machine,FSM)实现。

定义Lexer结构体,伪代码如下:

package lexer

const (
	//token kind
	TOKEN_EOF =		iota 			//end-of-file
	TOKEN_VARARG					// ...
	TOKEN_SEP_SEMI					// ;
	TOKEN_SEP_COMMA					// ,
	TOKEN_SEP_DOT					// .
	TOKEN_SEP_COLON					// :
	TOKEN_SEP_LABEL					// ::
	TOKEN_SEP_LPAREN				// (
	TOKEN_SEP_RPAREN				// )
	TOKEN_SEP_LBRACK				// [
	TOKEN_SEP_RBRACK				// ]
	TOKEN_OP_ASSIGN					// =
	TOKEN_OP_MINUS					// - (sub or unm)
	TOKEN_OP_WAVE					// ~ (bnot or bxor)
	TOKEN_OP_ADD					// +
	TOKEN_OP_MUL					// *
	TOKEN_OP_DIV					// /
	TOKEN_OP_IDIV					// //
	TOKEN_OP_POW					// ^
	TOKEN_OP_MOD					//  %
	TOKEN_OP_BAND					// &
	TOKEN_OP_BOR					// /
	TOKEN_OP_SHR					// >>
	TOKEN_OP_SHL					// <<
	TOKEN_OP_CONCAT					// ..
	TOKEN_OP_LT						// <
	TOKEN_OP_LE						// <=
	TOKEN_OP_GT						// >
	TOKEN_OP_GE						// >=
	TOKEN_OP_EQ						// ==
	TOKEN_OP_NE						// ~=
	TOKEN_OP_LEN					// #
	TOKEN_OP_AND					// and
	TOKEN_OP_OR						// or
	TOKEN_OP_NOT					// or
	TOKEN_OP_BREAK					// break
	TOKEN_OP_DO						//  do
	TOKEN_OP_ELSE					// else
	TOKEN_OP_ELSEIF					// elseif
	TOKEN_OP_END					// end
	TOKEN_OP_FALSE					// false
	TOKEN_OP_FOR					// for
	TOKEN_OP_FUNCTION				// function
	TOKEN_OP_GOTO					// goto
	TOKEN_OP_IF						// if
	TOKEN_OP_IN						// in
	TOKEN_OP_LOCAL					// local
	TOKEN_OP_NIL					// nil
	TOKEN_OP_REPEAT					// repeat
	TOKEN_OP_RETURN					// return
	TOKEN_OP_THEN					// then
	TOKEN_OP_TRUE					// true
	TOKEN_OP_UNTIL					// until
	TOKEN_OP_WHILE					// while
	TOKEN_IDENTIFIER				// identifire
	TOKEN_NUMBER					// number literal
	TOKEN_STRING					// string literal
	TOKEN_OP_UNM = TOKEN_OP_MINUS					// unary minus
	TOKEN_OP_SUB = TOKEN_OP_MINUS
	TOKEN_OP_BNOT = TOKEN_OP_WAVE
	TOKEN_OP_BXOR = TOKEN_OP_WAVE 
)

var keyworks = map[string]int{
	"and":				TOKEN_OP_AND,
	“break":			TOKEN_KW_BREAK,
	...
}

定义一系列函数用于词法分析

空白字符:
定义skipWhiteSpaces()方法用于跳过空白字符(更新行号),也一并跳过注释。

注释:
定义skipComment()方法跳过注释。

token:
定义NextToken()方法分析代码的每个token(如上定义的结构体)分隔符和运算符、数字字面量、标识符和关键字。

长字符串字面量:
定义scanLongString()方法,先寻找左右长方括号,如果任何一个找不到则说明源代码有语法错误,调用error()方法汇报错误并终止分析。接下来提供字符串字面量,把左右长方括号去掉,把换行符序列同意转换成换行符\n,再把开头的第一个换行符(如果有的话)去掉,就是最终字符串。

短字符串字面量:
定义scanShortString()方法,使用正则表达式提取短字符串,如果提取失败,说明源代码语法有问题,调用error()方法汇报错误。接下来去掉字面量两端的引号,并在必须时调用escape()方法对转义序列进行处理,得到最终的字符串。

定义好Lexer结构体,实现了NextToken()方法,这个方法提供了最基本的词法分析功能,每次调用都会读取并返回下一个token。

词法分析器伪代码

func textLexer(chunk, chunkname) {
	lexer := NewLexer(chunk, chunkname)
	for {
		line, kind, token := lexer.NextToken()
		fmt.Printf("[%2d] [%-10s] %s\n", line, kindToCategory(kind), token)
		if kind == TOKEN_EOF {
			break
		}
	}
}

testLexer()函数先根据源文件名和脚本创建Lexer结构体实例,然后再循环调用NextToken()方法对脚本进行词法分析,打印出每个token的行号、种类和内容,直到整个脚本都分析完毕为止。

2.2 抽象语法树

字符可以任意组合,词法规则定义了怎么样的组合可以构成合法的token。同理,token也可以任意组合,语法规则定义了怎样的组合可以构成合法的程序。

词法分析阶段根据词法规则将字符串序列分解为token序列,接下来的语法分析阶段根据语法规则将token序列解析为抽象语法树(AST)。

与抽象语法树相对应的时具体语法树(Concrete Syntax Tree,CST)也叫做解析树(Parse Tree,或者Parsing Tree)。顾名思义,CST,也叫做解析树,是源代码的解析结果,把CST里多余的信息去掉,仅留下必要的信息,那么得到的就是一颗AST。

大部分编译器并不是先把源代码解析为CST,然后再转化为AST,而是直接产生AST。因此,CST往往只存在于概念阶段。此外,AST也不一定非要用”树“这种数据结构来表示,这里我们直接使用结构体来表示AST的各种结点。

Chunk和块
再lua的行话里,一段完整的(可以被lua虚拟机解析执行)lua代码就称为chunk。

定义Block结构体:

package ast

type Block struct {
	LastLine int
	Stats []Stat
	RetExps []Exp
}

由于chunk实际上等同于代码块,所以我们只定义了Block结构体。Block结构体包含语句序列和返回语句里的表达式序列等,至于关键字、分号、逗号等信息全部丢弃。LastLine字段记录了代码块的末尾行号,在代码生成阶段需要使用这个信息。

语句

在命令式编程语言里,语句(Statement)是最基本的执行单位,表达式(Expression)则是构成语句的要素之一。语句和表达式的主要区别在于:语句只能执行不能用于求值,而表达式只能用于求值不能单独执行。

Lua一共有15种语句。下面定义各种语句的结构体,用于在词法分析后,对分析好的词法进行语法分析,放到对应的语句结构体中,然后生成抽象语法树。

简单语句:

type EmptyStat struct {}  // ;
type BreakStat struct { Line int }  // break
type LabelStat struct {Name string} // ::Name::
type GotoStat strcut {Name strting} // goto Name
type DoStat struct {Block *Block} //do block end
type FuncCallStat = FuncCallExp // functioncall

while和repeat语句:表达式收集到Exps字段里,把语句块收集到Blocks字段里。

type WhileStat struct {
	Exps Exp
	Block *Block
}

type RepeatStat struct {
	Block *Block
	Exps Exp
}

if语句:表达式收集到Exps字段里,把语句块收集到Blocks字段里。

type IfStat struct {
	Exps []Exp
	Block []*Block
}

数值for循环语句:需要把关键字for和do所在的行号记录下来。

type ForNumStat struct {
	LineOfFor int
	LineOfDo int
	VarName string
	InitExp Exp
	LimitExp Exp
	StepExp Exp
	Block *Block
}

通用for循环语句:需要把关键字do所在的行号记录下来。把关键字in左侧的标识符列表记录在NameList字段里。右侧的表达式列表记录在ExpList字段里。

type ForInStat struct {
	LineOfDo int 
	NameList []string
	ExpList []Exp
	Block *Block
}

局部变量声明语句:需要把等号左边的标识符列表记录在NameList字段里,右侧表达式列表记录在ExpList字段里。

type LocalVarDeclStat struct {
	LastLine int 
	NameList []string
	ExpList []Exp
}

赋值语句:需要把等号左边的var表达式列表记录在VarList字段里,右侧表达式列表记录在ExpList字段里。

type AssignStat struct {
	LastLine int
	VarList []Exp
	ExpList []Exp
}

非局部函数定义语句:在语法分析阶段会把非局部函数定义语句的冒号语法糖去掉,并且会把它转换成赋值语句,所以不用给它定义专门的结构体。

局部函数定义语句:Name字段对应函数名,Exp字段对应函数定义表达式

type LocalFuncDefStat struct {
	Name string
	Exp *FuncDefExp
}

表达式

lua共有11种表达式,分为5类:字面量表达式、构造器表达式、运算符表达式、vararg表达式、前缀表达式。

简单表达式:

type NilExp struct {Line int}
type TrueExp struct {Line int}
type FalseExp struct {Line int}
type VarargExp struct {Line int}
type IntegerExp struct {Line int; Val int64}
type FloatExp struct {Line int, Val float64}
type StringExp struct {Line int, Str string}
type NameExp struct {Line int, Name string}

运算符表达式:

//一元运算符
type UnopExp struct {
	Line int  	//line of operator
	Op int 	//operator
	Exp Exp
}

//二元运算符
type BinopExp struct{
	Line int 	//line of operator
	Op int 	//operator
	Exp1 Exp
	Exp2 Exp
}

//拼接运算符
type ConcatExp struct {
	Line int 	//line of last ..
	Exps []Exp
}

表构造表达式:

type TableConstructorExp struct {
	Line int 	// line of '{'
	LastLine int 	// line of '}'
	KeyExps []Exp
	ValExps []Exp
}

函数定义表达式:

type FuncDefExp struct {
	Line int
	LastLine int 	// line of 'end'
	ParList []string
	IsVararg bool
	Block *Block
}

前缀表达式:在语法分析阶段把记录访问表达式转换成表访问表达式。所以没有必要专门定义记录访问表达式。

圆括号表达式:用途有二,其一是改变运算符的优先级或者结合性。其二是在多重赋值时将vararg和函数调用的返回值固定为1。

type ParensExp struct {
	Exp Exp
}

表访问表达式:

type TableAccessExp struct {
	LastLine int	// line of '['
	PrefixExp Exp
	KeyExp Exp
}

函数调用表达式:

type FuncCallExp struct {
	Line int 	// line of '('
	LastLine int	// line of ')'
	PrefixExp Exp
	NameExp *StringExp
	Args []Exp
}

2.3 语法分析

语法分析器的作用就是按照某种语言的BNF描述将这种语言的源代码转换成抽象语法树(AST)以供后续阶段使用。

对于任何一段L源代码,如果仅能被转换成唯一一颗CST,那么我们称L语言无歧义(Unambiguous)。反之,我们称L语言有歧义(Ambiguous)。

前瞻和回溯
假设我们需要手动把一段Lua脚本转换为语法树,我们会怎么办?我们肯定先扮演此法分析器的角色,从源代码中提取第一个token,然后切换到语法分析器的角色,根据这个token看看下一步该怎么办。比如这个token是关键字for,我们会尝试解析一个for循环语句,然后继续。可是如果拿到的token是关键字local怎么办呢?是尝试解析局部变量声明语句还是局部函数定义语句呢?我们只能再从源代码多提取一个token,如果它的关键字是function,那就尝试解析局部函数定义语句,否则尝试解析局部变量声明语句。

像这种通过预先读取后面几个token来决定下一步解析策略的做法叫作前瞻(Lookahead),前瞻失败后记录状态进行尝试并可能回退的做法叫做回溯(Backtracking)

如果上下文无关语言L不需要借助回溯就可以完成解析,那么我们称L为确定性(Deterministic)语言。确定性语言一定没有歧义。

解析方法
树根画在上面,这种方式叫做自顶向下(Top-down)法。
另一种方式,从先构造叶节点,然后是非叶节点,最后是根节点,这样构造出的语法树的方式叫自底向上(Bottom-up)法。

解析块

我们将采用递归下降解析器。递归下降解析器采用自顶向下的方式进行解析,由于Lua脚本实际上就是一个代码块,所以解析结果应该就是一个Block结构体实例。

func parseBlock(lexer *Lexer) *Block {
	return &Block{
		Stats:	parseStats(lexer),
		RetExps:	parseRetExps(lexer),
		LastLine:	lexer.Line(),
	}
}

我们创建Block结构体实例,调用parseStats()函数解析语句序列,调用parseRetExps()函数解析可选的返回语句,并记录末尾行号,这样五行代码就完成了Lua脚本解析。

func parseStats(lexer *Lexer) []Stat {
	stats := make([]Stat, 0, 8)
	for !_isReturnOrBlockEnd(lexer.LookAhead()) {
		stat := parseStat(lexer)
		if _,ok := stat.(*EmptyStat); !ok {
			stats = append(stats, stat)
		}	
	}
	return stats
}

我们循环调用parseStat()函数解析语句,直到通过前瞻看到关键字return或者发现块已经结束为止。

func parseStat(lexer *Lexer) Stat {
	switch lexer.LookAhead() {
	case TOKEN_SEP_SEMI:	return parseEmptyStat(lexer)
	case TOKEN_KW_BREAK:	return parseBreakStat(lexer)
	case TOKEN_SEP_LABEL:	return parseLabelStat(lexer)
	case TOKEN_KW_GOTO:	return parseGotoStat(lexer)
	case TOKEN_KW_DO:	return parseDoStat(lexer)
	case TOKEN_KW_WHILE:	return parseWhileStat(lexer)
	case TOKEN_KW_REPEAT:	return parseRepeatStat(lexer)
	case TOKEN_KW_IF:	return parseIfStat(lexer)
	case TOKEN_KW_FOR:	return parseForStat(lexer)
	case TOKEN_KW_FUNCTION:	return parseFunctionStat(lexer)
	case TOKEN_KW_LOCAL:	return parseLocalAssignOrFuncDefStat(lexer)
	default:	return parseAssignOrFuncCallStat(lexer)
	}
}


对于空语句,跳过空语句即可
func parseEmptyStat(lexer *Lexer) *EmptyStat {
	lexer.NextTokenOfKind(TOKEN_SEP_SEMI)	// ';'
	return &EmptyStat{}
}

对于break语句,跳过关键字并记录行号即可
func parseBreakStat(lexer *Lexer) *BreakStat {
	lexer.NextTokenOfKind(TOKEN_KW_BREAK)	// break
	return &BreakStat{lexer.Line()}
}

//对于label语句,跳过分隔符并记录签名即可
func parseLabelStat(lexer *Lexer) *LabelStat {
	lexer.NextTokenOfKind(TOKEN_SEP_LABEL)	// '::'
	_,name := lexer.NextIdentifier()	// Name
	lexer.NextTokenOfKind(TOKEN_SEP_LABEL)	// '::'
	return &LabelStat{name}
}

//对于goto语句,跳过关键词并记录标签即可
func parseGotoStat(lexer *Lexer) *GotoStat {
	lexer.NextTokenOfKind(TOKEN_SEP_GOTO)	// goto
	_, name := lexer.NextIdentifier()	// Name
	return &GotoStat{name} 
}

//对于do语句,先跳过do关键字,然后调用parseBlock()函数解析块,最后跳过关键字end
func parseDoStat(lexer *Lexer) *DoStat {
	lexer.NextTokenOfKind(TOKEN_KW_DO)	//do
	block := parseBlock(lexer)	// block
	lexer.NextTokenOfKind(TOKEN_KW_END)	// end
	return &DoStat{block}
}

//对于while语句,先跳过关键字while,然后调用parseExp()函数解析表达式,然后跳过关键字do,然后调用parseBlock()函数解析块,最后跳过关键字end
func parseWhileStat(lexer *Lexer) *WhileStat {
	lexer.NextTokenOfKind(TOKEN_KW_WHILE)	// while
	exp := parseExp(lexer)	// exp
	lexer.NextTokenOfKind(TOKEN_KW_DO)	// do
	block := parseBlock(lexer)	// block
	lexer.NextTokenOfKind(TOKEN_KW_END)	// end
	return &WhileStat{exp, block}
}

//对于repeat语句,先跳过关键字repeat,然后调用parseBlock()函数解析块块,然后跳过关键字until,然后调用parseExp()函数解析表达式
func parseRepeatStat(lexer *Lexer) *RepeatStat {
	lexer.NextTokenOfKind(TOKEN_KW_REPEAT)	// repeat
	block := parseBlock(lexer)	// block
	lexer.NextTokenOfKind(TOKEN_KW_UNTIL)	//until
	exp := parseExp(lexer)	// exp
	return &RepeatStat{block, exp}
}

// if 语句
func parseIfStat(lexer *Lexer) *IfStat {
	
}

接下来,看下parseRetExps()函数

func parseRetExps(lexer *Lexer) []Exp {
	if lexer.LookAhead() != TOKEN_KW_RETURN {
		return nil
	}
	
	lexer.NextToken()
	switch lexer.LookAhead() {
		case TOKEN_EOF, TOKEN_KW_END, TOKEN_KW_ELSE, TOKEN_KW_ELSEIF, TOKEN_KW_UNTIL:
			return []Exp{}
		case TOKEN_SEP_SEMI:
			lexer.NextToken()
			return []Exp{}
		default:
			exps := parseExpList(lexer)
			if lexer.LookAhead() == TOKEN_SEP_SEMI {
				lexer.NextToken()
			}
			return exps
	}
}

我们通过词法分析器前瞻下一个token,如果不是关键字return,说明没有返回语句,直接返回nil即可;否则跳过关键字return,前瞻下一个token。如果发现块已经结束或者分号,那么返回语句没有任何表达式,跳过分号(如果有),返回空的Exp列表即可;否则,调用parseExpList()函数解析表达式序列,并跳过可选的分号。

func parseExpList(lexer *Lexer) []Exp {
	exps := make([]Exp, 0, 4)
	exps = append(exps, parseExp(lexer))	// exp
	for lexer.LookAhead() == TOKEN_SEP_COMMA {	// {
		lexer.NextToken()	// ','
		exps = append(exps, parseExp(lexer))		// exp
	}	\\ }
	return exps
}

2.4 代码生成

代码生成阶段是对抽象语法树(AST)进行处理,利用它生成Lua字节码和函数原型,并最终输出二进制chunk文件。

每个Lua函数都会被编译为函数原型存放在二进制chunk里,另外Lua编译器还会为我们生成一个主函数。为了降低难度,我们把代码生成分为两个阶段:第一个阶段对AST进行处理,生成自定义的内部结构(如下funcInfo结构体);第二个阶段把内部结构转换为函数原型。

定义funcInfo结构体

type funcInfo struct {

	// 常量表
	// 说明:每个函数原型都有自己的常量表,里面存放函数体出现的nil、布尔、数字或者字符串字面量。
	constants map[interface{}]int	// map存储变量,其中键时常量值,值时常量在表中的索引

	// 寄存器分配
	// 说明:Lua虚拟机时基于寄存器的机器,因此我们在生成指令时需要进行寄存器分配。简单来说,我们需要给每一个局部变量和临时边路都分配一个寄存器,在局部变量退出作用域或临时变量使用完毕之后,回收寄存器。
	usedRegs  int	// 已分配寄存器数量
	maxRegs  int	// 需要的最大寄存器数量

	// 局部变量表
	// 说明:Lua采用词法作用域。在函数内部,某个局部变量的作用域是包围该变量的最内层语句块。简单来说,每个块都会制作一个新的作用域,在块的内部可以使用局部变量声明语句声明(并初始化)局部变量,每个局部变量都会占用一个寄存器索引。当块结束以后,作用域也随之消失,局部变量不复存在,占用的寄存器也会被回收。
	type locVarInfo strcut {
		prev  *locVarInfo	// 由于同一个局部变量名可以先后绑定不同的寄存器,我们使用单向链表来串联同名的局部变量。
		name  string	// 记录局部变量名
		scopeLv  int	// 记录局部变量所在的作用域层次
		slot  int	// 记录与局部变量名绑定的寄存器索引
		captured  bool	// 表示局部变量是否被闭包捕获
	}
	scopeLv  int	//当前作用域层次
	locVars  []*locVarInfo	// 按顺序记录函数内部声明的全部局部变量
	locNames  map[string]*locVarInfo	//记录当前生效的局部变量

	// Break表
	breaks  [][]int	// 我们使用数组记录循环块内部待处理的跳转指令,数组长度和块的深度对应,通过判断数组元素(也是数组)是否为nil,可以判断对应的块是否是循环块。
	
	// Upvalue表
	// 说明:Upvalue实际上就是闭包按照词法作用域捕获的外围函数中的局部变量。和局部变量类似,我们也需要把Upvalue名和外围函数的局部变量绑定。
	type upvalInfo struct {
		locVarSlot  int	// 记录该局部变量所占用的寄存器索引
		upvalIndex  int	// 记录该Upvalue在直接外围函数Upvalue表中的索引
		index  int	// 记录Upvalue在函数中出现的顺序
	}
	parent  *funcInfo	// 使我们可以定位到外围函数的局部变量表和Upvalue表。
	upvalues map[string]upvalInfo	// 存放Upvalue表
	
	// 字节码
	// 说明:存储编码后的指令
	insts  []uint32

	// 其他信息
	subFuncs  []*funcInfo	// 存放子函数信息
	numParams  int	// 参数个数
	isVararg  bool	// 是否包含省略参数
}

生成中间结构

定义编译块、编译语句、编译表达式等一系列函数,把AST树节点内容全部放入上述funcInfo结构体中。

生成函数原型

把上述funcInfo结构体的内容转换为函数原型:

func toProto(fi *funcInfo) *Prototype {
	proto := &binchunk.Prototype {
		NumParams:			byte(fi.numParams),	//最大使用参数
		MaxStackSize:		byte(fi.maxRegs),	//使用最大寄存器
		Code:				fi.insts,	//存储编码后的指令
		Constants:			getConstants(fi),	//常量表
		Upvalues:			getUpvalues(fi),	//Upvalue表
		Protos:				toProtos(fi.subFuncs),	//子函数原型
		LineInfo:			[]uint32{},	//debug
		LocVars:			[]LocVar{},	//debug
		UpvalueNames:		[]string{},	//debug
	}
	if fi.isVararg { proto.IsVararg = 1 }
	return proto
}

使用编译器

Compile()函数:把语法分析和代码生成阶段合二为一:

func Compile(chunk, chunkName string) *binchunk.Prototype {
	ast := parser.Parse(chunk, chunkName)
	return codegen.GenProto(ast)
}
func Parse(chunk, chunkName string) *Block {
	lexer := NewLexer(chunk, chunkName)
	block := parseBlock(lexer)
	lexer.NextTokenOfKind(TOKEN_EOF)
	return block
}
func GenProto(chunk *Block) *Prototype {
	fd := &FuncDefExp{
		LastLine: chunk.LastLine,
		IsVararg: true,
		Block:    chunk,
	}

	fi := newFuncInfo(nil, fd)
	fi.addLocVar("_ENV", 0)
	cgFuncDefExp(fi, fd, 0)
	return toProto(fi.subFuncs[0])
}

三、Lua解析器

3.1 luac命令

luac命令主要有两个用途:第一,作为编译器。第二,作为反编译器。

luac [options] filename
Available options are:
-l				list(use -l -l for full listing)
-o name			output to file 'name'(default is "luac.out")
-p				parse only
-s				strip debug information
-v				show version information
--				show handling options
-				show handling options and process stdin

举例:

//luac作为编译器:(将Lua文件生成为二进制文件)
luac hello_world.lua	//生成luac.out
luac -o hw.luac hello_world	//生成hw.luac
luac -s hello_world.lua	//不包含调试信息
luac -p hello_world.lua	//只进行语法检查

//luac作为反编译器:
luac -l hello_world.luac	//查看二进制chunk

//以上例子以二进制chunk文件为参数,实际上也可以直接以Lua源文件为参数,luac会先编译源文件,生成二进制chunk文件,然后再进行反编译,产生输出:
luac -l hello_world.lua

//如果使用两个“-l”选项,则可以进入详细模式,这样,luac会把常量表、局部变量表和upvalue表的信息也打印出来:
luac -l -l hello_world.lua

Lua编译器以函数为单位进行编译,每一个函数都会被编译为一个内部结构,这个结构叫做“原型”(Prototype)。原型主要包含6个部分内容,分别是:函数基本信息(包括参数数、局部变量数量等)、字节码、常量表、Upvalue表、调试信息、子函数原型列表。

Lua编译器会自动为我们脚本添加一个main函数,并且把整个程序都放进这个函数里,然后再以它为起点进行编译,那么自然就把整个程序都编译出来了。这个主函数不仅是编译的起点,也是未来Lua虚拟机解析执行程序时的入口。

把主函数编译好后,Lua编译器会给它再添加一个头部,然后一起dump成luac.out,这样,一份热乎的二进制chunk文件就新鲜出炉了。

3.2 二进制chunk格式

总体而言,二进制chunk分为头部和主函数原型两部分。

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

//头部
type header struct {
	signature  [4]byte	//签名
	version  byte	//版本号
	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	//LUAC_INT
	luacNum  float64	//LUAC_NUM
}

//函数原型
type Prototype struct {
	Source  string	//源文件名
	LineDefined  uint32	//起止行号,用于记录原型对应的函数在源文件的起止行号(如果是普通函数,起止行号大于0,如果是主函数,则起止函数都是0)
	LastLineDefined  uint32	//起止行号
	NumParams  byte	//固定参数个数
	IsVararg  byte	//是否是Vararg函数
	MaxStackSize  byte	//寄存器数量
	Code  []uint32	//指令表
	Constants  []interface{}	//常量表
	Upvalues  []Upvalue	//Upvalue表
	Protos  []*Prototype	//(重要!)子函数原型表(里面包含一系列的子函数原型)
	LineInfo  []uint32	//行号表(调试信息)
	LocVars  []LocVar	//局部变量表(调试信息)
	UpvalueNames  []string	//Upvalue名表(调试信息)
}

3.3 解析二进制chunk格式

3.3.1 检查头部
checkHeader()方法从字节流中读取并检查二进制chunk头部的各个字段。如果发现某个字段和期望不符,则调用panic()函数终止加载。

func (self *reader) checkHeader() {
	if string(self.readByte(4) != LUA_SIGNATURE) {
		panic("not a precompiled chunk!")
	} else if self.readByte() != LUAC_VERSION {
		panic("Version mismatch")
	} else if self.readByte() != LUAC_FORMAT {
		panic("format mismatch")
	} else if string(self.readBytes(6)) != LUAC_DATA {
		panic("corrupted")
	} else if self.readBytes() != CINT_SIZE {
		panic("int size mismatch")
	} else if self.readBytes() != CSIZET_SIZE {
		panic("size_t size mismatch")
	} else if self.readBytes() != INSTRUCTION_SIZE{
		panic("instruction size mismatch")
	} else if self.readBytes() != LUA_INTEGER_SIZE {
		panic("lua_Integer mismatch")
	} else if self.readBytes() != LUA_NUMBER_SIZE {
		panic("lua_Number size mismatch")
	} else if self.readLuaInteger() != LUAC_INT {
		panic("endianness mismatch")
	} else if self.readLuaNumber() != LUAC_NUM {
		panic("float format mismatch")
	}
}

3.3.2 读取函数原型

readProto()方法从字节流中读出函数原型

func (self *reader) readProto(parentSource string) *Prototype {
	source := self.readString()
	if source == "" {source = parentSource}
	return &Prototype {
		Source:	source,
		LineDefined:	self.readUint32(),
		LastLineDefined	self.readUint32(),
		NumParams:	self.readByte(),
		IsVararg:	self.readByte(),
		MaxStackSize:	self.readByte(),
		Code:	self.readCode(),
		Constants:	self.readConstants(),
		Upvalues:	self.readUpvalues(),
		Protos:	self.readProtos(source),
		LineInfo:	self.readLineInfo(),
		LocVars:	self.readLocVars(),
		UpvalueNames:	self.readUpvalueNames(),
	}
}

3.4 指令集

虚拟机大致可以分为两大类:基于栈(Stack Based)和基于寄存器(Register Based)。

如同真是机器有一套指令集(Instruction Set)一样,虚拟机也有自己的指令集:基于栈的虚拟机需要使用PUSH类指令往栈顶推入值,使用POP类指令从栈顶弹出值,其他指令则是对栈顶值进行操作。因此指令集相对较大,但是指令的平均长度比较短;基于寄存器的虚拟机由于可以直接对寄存器进行寻址,所以不需要PUSH或者POP类指令,指令集相对较小,但是由于需要把寄存器地址编码进指令里,所以指令的平均长度比较长。

3.4.1 编码格式
每条Lua虚拟机指令占用4个字节,共32比特,其中低6个比特用于操作码,高26个比特用于操作数。

Lua虚拟机指令可以分为4类,分别对应四种编码模式(Mode):
iABC:可以携带A、B、C三个操作数,分别占用8、9、9个比特
iABx:可以携带A和Bx两个操作数,分别占用8和18个比特
iAsBx:可以携带A和sBx两个操作数,分别占用8和18个比特
iAx:只携带一个操作数,占用全部26个比特
请添加图片描述

3.4.2 操作码
由于Lua虚拟机指令使用6个比特表示操作码,所以最多只能有64条指令。Lua5.3一共定义了47条指令,操作码从0开始,到46截至。

const {
	OP_MOVE = iota;	
	OP_LOADK;	
	OP_LOADKX;	
	OP_LOADBOOL;
	OP_LOADNIL;	
	OP_GETUPVAL;	OP_GETTABUP;	OP_GETTABLE;
	OP_SETTABUP;	OP_SETUPVAL;	OP_SETTABLE;	
	OP_NEWTABLE;
	OP_SELF;	OP_ADD;	OP_SUB;	OP_MUL;
	OP_MOD;	OP_POW;	OP_DIV;	OP_IDIV;
	OP_BAND;	OP_BOR;	OP_BXOR;	OP_SHL;
	OP_SHR;	OP_UNM;	OP_BNOT;	OP_NOT;
	OP_LEN;	OP_CONCAT;	OP_JMP;	OP_EQ;
	OP_LT;	OP_LE;	OP_TEST;	OP_TESTSET;
	OP_CALL;	OP_TAILCALL;	OP_RETURN;	OP_FORLOOP;
	OP_FORPREP;	OP_TFORCALL;	OP_TFORLOOP;	OP_SETLIST;
	OP_CLOSURE;	OP_VARARG;	OP_EXTRAARG;
}

3.4.3 操作数
操作数是指令的参数,每条指令可以携带1至3个操作数。其中操作数A主要用来表示目标寄存器索引,其他操作数按照其表示的信息,可以粗略分为四种类型:OpArgN、OpArgU、OpArgR、OpArgK。
OpArgN:不表示任何信息,也就是说不会被用到。
OpArgU:可以表示布尔值、整数值、upvalue索引、子函数索引等。
OpArgR:在iABC模式下表示寄存器索引,在iAsBx模式下表示跳转偏移。
OpArgK:表示常量表索引或者寄存器索引。

3.4.4 指令表

var opcodes = []opcode {
	//			T   A  B   C     mode  name  action
	opcode{0, 1, OpArgR, OpArgN, IABC, "MOVE", move}
	opcode{0, 1, OpArgK, OpArgN, IABx, "LOADK", loadK}
	opcode{0, 1, OpArgN, OpArgN, IABx, "LOADKX", loadKx}
	opcode{0, 1, OpArgU, OpArgU, IABC, "LOADBOOL", loadBool}
	opcode{0, 1, OpArgU, OpArgN, IABC, "LOADNIL", loadNil}
	... //其他指令省略
}

3.4.5 指令解码
给Instruction类型定义5个方法,用于解码指令。

type Instruction uint32 //Lua指令
//Opcode()方法从指令中提取操作码。
func (self Instruction) Opcode() int {
	return int(self & 0x3F)
}

//ABC()方法从iABC模式指令中提取参数
func (self Instruction) ABC(a, b, c int) () {
	a = int(self >> 6 && 0xFF)
	c = int(self >> 14 && 0x1FF)
	b = int(self >> 23 && 0x1FF)
	return
}

//ABx()方法从iABx模式指令中提取参数
func (self Instruction) ABx() (a, bx int) {
	a = int(self >> 6& 0xFF)
	bx = int(self >> 14)
	return
}

//AsBx()方法从iAsBx模式指令中提取参数
func (self Instruction) AsBx() (a, sbx int) {
	a, bx := self.ABx()
	return a, bx -MAXARG_sBx
}

//Ax()方法从iAx模式指令中提取参数
func (self Instruction) Ax() int {
	return int(self >> 6)
}

总结:使用位移和逻辑与运算符从指令中提取信息。

3.4 LuaAPI

Lua是嵌入式脚本语言,因此Lua需要宿主语言。为了很方便嵌入宿主环境中,Lua核心是以库(Library)的形式被实现的,其他应用程序只需要链接Lua库就可以使用Lua提供的API轻松获得脚本执行能力。举个例子,Lua发布版的两个命令行程序,就是lua和luac,实际上就是Lua库的两个特殊应用程序。

Lua一共提供了一百多个函数,以lua_开头的基本函数除外,Lua还提供了LuaL_开头的辅助函数。辅助函数完全是在基本函数之上实现的,目的在于提供一些便利的操作。

3.4.1 Lua栈
全部API函数都是围绕LuaState进行操作。而LuaState内部封装的最为基本的一个状态就是虚拟栈。LuaAPI函数很大一部分就是专门用来操作Lua栈的。

type luaStack struct {
	slots  []luaValue	//存放值
	top  int	//记录栈顶索引
	
	openuvs map[int]*upvalue	//记录所有暂时还处于开放状态的Upvalues。键是索引,值是Upvalue指针
}

Lua栈是和Lua语言进行沟通的桥梁。举例说明,虚拟机将二进制chunk翻译之后,解析指令,把指令类型对应执行的C语言方法,参数等入栈,然后调用C语言方法得到结果后返回到luaStack中,lua可以调用LuaAPI取得栈顶的返回值。

3.4.2 LuaState

3.4.2.1 定义LuaState结构
luaState拥有一个LuaStack,因此对luaState的操作其实就是对LuaStack的操作。

type luaState struct {
	stack *luaStack //Lua栈
	register *luaTable	//注册表
	
	prev *luaStack	//让调用帧变成了链表节点。我们使用单向链表来实现函数调用栈。
	type Closure struct {
		proto  *binchunk.Prototype	//lua closure 保存函数原型,这样就可以从中提取指令或者常量
		goFunc GoFunction	//go closure
		upvals  []*upvalue	//存放Upvalue值(闭包内部捕获的非局部变量)
	}
	closure  *Closure	//闭包
	pc  int	//程序计数器

	coStatus  int	//协程状态
	coCaller  *luaState	//调用这个协程的协程
	coChan  chan  int	//两个线程之间通过这个字段来互相协助
}

3.4.2.2 定义LuaState接口
由于Lua官方是用C语言编写,所以LuaAPI体现为一系列操作lua_state结构体的函数(和宏定义)。

这些函数大致可以分为基础栈操作方法、栈访问方法、压栈方法三类。运算类方法主要对栈中数据取出并进行算数操作,然后将结果重新压回栈顶。

type LuaState interface {
	// basic stack manipulation
	GetTop() int
	AbsIndex(idx int) int
	CheckStack(n int) bool
	Pop(n int)
	Copy(formIdx, toIdx int)
	PushValue(idx int)
	Replace(idx int)
	Insert(idx int)
	Remove(idx int)
	Rotate(idx int)
	SetTop(idx int)
	// access functions
	TypeName(tp LuaType) string
	Type(idx int) LuaType
	IsNone(idx int) bool
	IsNil(idx int) bool
	IsNoneOrNil(idx int) bool
	IsBoolean(idx int) bool
	IsInteger(idx int) bool
	IsNumber(idx int) bool
	IsString(idx int) bool
	ToBoolean(idx int) bool
	ToInteger(idx int) int64
	ToIntegerX(idx int) (int64, bool)
	ToNumber(idx int) float64
	ToNumberX(idx int) (float64, bool)
	ToString(idx int) string
	ToStringX(idx int) (string, bool)
	// push functions
	pushNil()
	pushBoolean(b bool)
	pushInteger(n int64)
	PushNumber(n float64)
	pushString(a string)
	// 运算类方法
	Arith(op ArithOp)	//执行算术和按位操作
	Compare(idx1, idx2, int, op CompareOp) bool	//执行比较运算
	Len(idx int)	//执行取长度运算
	Concat(n int)	//执行字符串拼接
	//全局环境(luastate的注册表下的_G表)操作
	pushGlobalTable()	//把全局环境(注册表下面的_G表)推入栈顶以备后续操作使用
	GetGlobal(name string) LuaType	//由于全局环境主要是用来实现Lua全局变量的,所以里面的键基本上都是字符串。该方法可以把全局变量中的某个字段推入栈顶
	SetGlobal(name string)	//往全局环境(注册表下的_G表)写入一个值,其中字段名由参数决定,值从栈顶弹出
	Register(name string, f GoFunction)	//专门用于给全局环境(注册表下的_G表)注册Go函数值。该方法仅操作全局环境,字段名和Go函数从参数传入,不改变Lua栈的状态。
	//异常错误
	Error() int	//从栈顶弹出一个Lua值,把该值作为错误抛出
	PCall(nArgs, nResults, msgh int) int	//如果有错误产生,那么PCall()会捕捉错误,把错误对象留在栈顶,并且会返回相应的错误码。
}

3.5 Lua运算符

Lua语言层面一共有25个运算符,按类别可以分为算术(Arithmetic)运算符、按位(Bitwise)运算符、比较(Comparison)运算符、逻辑(Logical)运算符、长度运算符和字符串拼接运算符。

3.6 Lua虚拟机(LuaVM)

虚拟机的核心任务就是执行指令。

Lua解析器在执行一段Lua脚本之前,会先把它包在一个主函数里编译成Lua虚拟机指令序列,然后连同其他信息一起, 打包成一个二进制chunk,然后Lua虚拟机会接管二进制chunk,执行里面的指令。和真实机器一样,Lua虚拟机也需要使用程序计数器。可使用如下伪代码表示Lua虚拟机的内部循环。

loop {
	1.计算pc
	2.取出当前指令
	3.执行当前指令
}

3.6.1 定义LuaVM接口
LuaVM接口扩展了LuaState接口

type LuaVM interface {
	LuaState
	PC() int	//返回当前PC
	AddPC(n int)	//修改PC(用于实现跳转)
	Fetch() uint32	//取出当前指令;将PC指向下一条指令
	GetConst(idx int)	//将指定常量推入栈顶
	GetRK(idx int)	//将指定常量或栈值推入栈顶

	RegisterCount() int	//返回当前Lua函数所操作的寄存器数量
	LoadVararg(n int)	//把传递给当前Lua函数的变长参数推入栈顶(多退少补)
	LoadProto(idx int)	//把当前Lua函数的子函数的原型实例化为闭包推入栈顶

	PushGoFunction(f GoFunction) //接受一个Go函数参数,把它转变成Go闭包后推入栈顶
	IsGoFunction(idx int) bool	//判断指定索引处的值是否可以转换成Go函数,该方法以栈索引为参数,返回布尔值,不改变栈的状态
	ToGoFunction(idx int) GoFunction	//把指定索引处的值转变为Go函数并返回,如果值无法转换为Go函数,返回nil即可。该方法以栈索引为参数,不改变栈的状态

	CloseUpvalues(a int) //闭合Upvalue
}

3.6.2 实现Lua虚拟机指令
主要就是通过从luastate的闭包的proto函数原型中得到解码指令后,得到目标寄存器和源寄存器索引,常量表等信息,然后把需要的变量转换成luastate中luastack的栈索引,最后调用LuaAPI提供的方法对luastate中的luastack进行操作(如放入变量,取出变量,对变量进行运算后放回栈顶等操作),然后改变luastate的pc,然后执行下一条指令。

3.6.3 指令分派

//指令表
var opcodes = []opcode {
	//			T   A  B   C     mode  name  action
	opcode{0, 1, OpArgR, OpArgN, IABC, "MOVE", move}
	opcode{0, 1, OpArgK, OpArgN, IABx, "LOADK", loadK}
	opcode{0, 1, OpArgN, OpArgN, IABx, "LOADKX", loadKx}
	opcode{0, 1, OpArgU, OpArgU, IABC, "LOADBOOL", loadBool}
	opcode{0, 1, OpArgU, OpArgN, IABC, "LOADNIL", loadNil}
	... //其他指令省略
}

//根据指令,执行指令的实现方法
func (self Instruction) Execute(vm api.LuaVM) {
	action := opcodes[self.Opcode()].action
	if action != nil {
		action(self, vm)
	} else {
		panic(self.OpName())
	}
}

Execute()方法先从指令里提取操作码,然后根据操作码从指令表里查找对应的指令实现方法,最后调用指令实现方法执行指令。

3.6.4 简易虚拟机实现

func luaMain(proto *binchunk.Prototype) {
	nRegs := int(proto.MaxStackSize) //从函数原型中获取最大栈数量
	ls := state.New(nRegs+8, proto) //新建一个luastate
	ls.SetTop(nRegs)
	for {
		pc := ls.PC() //当前程序计数器
		inst := Instrcution(ls.Fetch())  //通过当前程序计数器,构建Lua指令
		if inst.Opcode() != OP_RETURN {
			inst.Execute(ls)	//执行指令
			printStack(ls)
		} else {
			break
		}
	}
}

由于指令实现函数也需要少量的栈空间,所以实际创建的Lua栈容量要比寄存器稍微大一些。luastate结构体实例创建好后,调用SetTop()方法在栈里预留出寄存器空间,剩余的栈空间留给指令实现函数使用。剩下代码就是指令循环了,取出指令(同时递增PC)、执行指令,直到遇到返回指令为止。

3.7 表

Lua5.0后采用混合数据结构来实现表。简单来说,这种混合数据结构同时包含了数组和哈希表两部分。

type luaTable struct {
	metatable  *luaTable //存放元表
	arr  []luaValue	//存放数组部分
	_map  map[luaValue]luaValue	//存放哈希表部分
}

3.7.1 表相关API

由于表的实现完全属于Lua解析器内部细节,所以LuaAPI并没有把表直接暴露给用户,而是提供一系列创建和操作表的方法。

type LuaState interface {
	// get functions
	NewTable()
	CreateTable(nArr, nRen int)
	GetTable(idx int) LuaType
	GetField(idx int, k string) LuaType
	GetI(idx int, i int64)
	// set functions
	SetTable(idx int)
	SetField(idx int, k string)
	SetI(idx int, n int64)
}

这些方法也是对luastate的luastack进行操作,比如GetTable()方法,根据键从表里取值。具体做法是,先将table入栈,然后入栈table的k,然后调用C函数获得表中键对应的值,然后把k出栈,值入栈。

3.7.2 表相关指令

NEWTABLE:创建空表,并将其放入指定寄存器。寄存器索引由操作数A指定,表的初始数组容量和哈希表容量分别由操作数B和C指定。

func newTable(i Instruction, vm LuaVM) {
	a, b, c := i.ABC()
	a += 1

	vm.CreateTable(Fb2int(b), Fb2int(2))
	vm.Replace(a)
}

GETTABLE:根据键从表里取值,并放入目标寄存器中。其中表位于寄存器中,索引由操作数B指定;键可能位于寄存器中,也可能在常量表里,所引诱操作数C指定。

func getTable(i Instruction, vm LuaVM) {
	a, b, c := i.ABC()
	a += 1; b += 1
	
	vm.GetRK(c)
	vm.GetTable(b)
	vm.Replace(a)
}

SETTABLE:根据键往表里赋值,其中表位于寄存器中,索引由操作数A指定;键和值可能位于寄存器中,也可能在常量表中,索引分别由操作数B和C指定。

func setTable(i Instruction, vm LuaVM) {
	a,  b, c := i.ABC()
	a += 1
	
	vm.GetRK(b)
	vm.GetRK(c)
	vm.SetTable(a)
}

SETLIST:专门给数组准备的,用于按索引批量设置数组元素。其中数组位于寄存器中,索引由操作数A指定;需要写入数组的一系列值也在寄存器中,紧挨着数组,数量由操作数B指定;数组起始索引由操作数C指定。

func setList(i Instruction, vm LuaVM) {
	a, b, c := i.ABC()
	a += 1

	if c > 0 {
		c = c -1 
	} else {
		c = Instruction(vm.Fetch()).Ax()
	}
	
	idx := int64(c * LFIELDS_PER_FLUSH)
	for j := 1; j <= b; j++ {
		idx++
		vm.PushValue(a+j)
		vm.SetI(a, idx)
	} 

	bIsZero := b == 0
	if bIsZero {
		b = int(vm.ToInteger(-1)) -a -1
		vm.pop(1)
	}

	if bIsZero {
		for j := vm.RegisterCount() + 1; j <= vm.GetTop(); j++ {
			idx++
			vm.PushValue(j)
			vm.SetI(a, idx)
		}
	}
}

3.8 函数调用

为了区别Lua栈(luastate的luastack),我们称函数调用栈,简称调用栈(Call Stack)。Lua栈里面存放的是Lua值,调用栈里存放的则是调用栈帧,简称为调用帧(Call Frame)。

当我们调用一个函数时,要先往调用栈里推入一个调用帧,然后把参数传递给调用帧。函数依托调用帧执行指令,可能会调用其他函数,以此类推。当函数执行完毕后,调用帧里会留下函数需要返回的值。我们把调用帧从调用栈弹出,并且把返回值返回给底部的调用帧,这样一次函数调用就结束了。

后面我们称当前正在执行的函数为当前函数,其使用的调用帧为当前帧,称调用其他函数的函数为主调函数请添加图片描述
3.8.1 调用帧实现

luastate的luastack里的prev字段, 该字段和函数执行没有关系,但是让调用帧变成了链表节点。我们使用这个单向链表来实现函数调用帧

type luaState struct {
	// ..
	
	prev *luaStack	//让调用帧变成了链表节点。我们使用单向链表来实现函数调用栈。

	// ..
}

3.8.2 调用栈实现

func (self *luaState) pushLuaStack(stack *luaStack) {
	stack.prev = self.stack
	self.stack = stack
}

这个链表的头部是栈顶,尾部是栈底。往栈顶推入一个调用帧相当于在链表头部插入一个节点,并让这个节点成为新的头部。

func (self *luaState) popLuaStack() {
	stack := self.stack
	self.stack = stack.prev
	stack.prev = nil
}

从栈顶弹出一个调用帧也很简单,只要从链表头部删除掉一个节点就可以了。

3.8.3 函数调用API

Lua解析器在执行脚本之前,需要先把脚本装进一个主函数,然后把主函数编译成函数原型,最后交给Lua虚拟机去执行。函数原型就相当于面向对象语言里的类,其作用是实例化出真正可执行的函数,也就是前面提到的闭包。

3.8.3.1 Load()
Load()方法加载二进制chunk,把主函数原型实例化为闭包并推入栈顶。该方法不仅可以加载预编译的二进制chunk,也可以直接加载Lua脚本。如果加载的是二进制chunk,那么只要读取文件、解析主函数原型、实例化闭包、推入栈顶就可以了。如果加载的是Lua脚本,则要先进行编译。

func (self *luaState) Load(chunk []byte, chunkName, mode string) int {
	proto := binchunk.Undump(chunk)
	c := newLuaClosure(proto)
	self.stack.push(c)
	if len(proto.Upvalues) > 0 { //设置_env
		env := self.registry.get(LUA_RIDX_GLOBALS)
		c.upvalues[0] = &upvalue{&env}
	}
	return 0
}

3.8.3.2 Call()
Call()方法对Lua函数进行调用。在执行Call()方法之前,必须先把被调函数推入栈顶,然后把参数值依次推入栈顶。Call()方法结束后,参数值和函数会被弹出栈顶,取而代之的是指定数量的返回值。

func (self *luaState) Call(nArgs, nResults int) {
	val := self.stack.get(-(nArgs + 1))
	if c, ok := val.(*closure); ok {
		if c.proto != nil { 
			//lua closure
			self.callLuaClosure(nArgs, nResults, c)
		} else { 
			//go closure
			self.callGoClosure(nArgs, nResults, c)
		}
	} else {
		panic("not function!")
	}
}

func (self *luaState) callLuaClosure(nArgs, nResults int, c *closure) {
	nRegs := int(c.proto.MaxStackSize)
	nParams := int(c.proto.NumParams)
	isVararg := c.proto.IsVararg == 1

	newStack := newLuaStack(nRegs + 20)	//创建一个新的luastack
	newStack.closure = c 	//给luastack指定closure

	if nArgs > nParams && isVararg {
		newStack.varargs = funcAndArgs[nParams+1:]
	}

	self.pushLuaStack(newStack)	//把新调用帧推入栈顶,让它成为当前帧
	self.runLuaClosure()	//执行被调函数的指令
	self.popLuaStack()	//指令执行完毕后,新调用帧的使命完成了,把它从调用栈顶弹出,这样主调帧又成了当前帧。

	//被调函数运行完毕后,返回值会留在被调帧的栈顶。我们需要把全部返回值从被调帧栈顶弹出,然后根据期望的返回值数量多退少补,推入当前帧栈顶,这样函数调用才算结束。
	if nResults != 0 {
		results := newStack.popN(newStack.top - nRegs)
		self.stack.check(len(results))
		self.stack.pushN(results, nResults)
	} 
}

func (self *luaState) runLuaClosure() {
	//循环执行被调函数的指令,直到遇到return
	for {
		inst := vm.Instruction(sefl.Fetch())
		inst.Execute(self)
		if inst.Opcode() == vm.OP_RETURN {
			break
		}
	}
}

3.8.3 函数调用指令

CLOSURE:把当前Lua函数的子函数原型实例化为闭包,放入由操作函数A指定的寄存器中。子函数原型来自于当前函数原型的子函数原型表,索引由操作数Bx指定。

func closure(i Instruction, vm LuaVM) {
	a, bx := i.ABx()
	a += 1
	
	vm.LoadProto(bx)
	vm.Replace(a)
}

CALL:调用Lua函数(通过Lua函数名,实际上是调用的是Lua函数被翻译成的指令,也就是指令对应的C函数)。其中被调函数位于寄存器中,索引由操作数A指定。需要传递给被调函数的参数值也在寄存器中,紧挨着被调函数,数量由操作数B指定。函数调用结束后,原先存放函数和参数值的寄存器会被返回值占据,具体有多少个返回值由操作数C指定。

func call(i Instruction, vm LuaVM) {
	a, b, c := i.ABC()
	a += 1

	nArgs := _pushFuncAndArgs(a, b, vm)
	vm.Call(nArgs, c-1)
	_popResults(a, c, vm)
}

RETURN:把存放在连续多个寄存器里的值返回给主调函数。其中第一个寄存器的索引由操作数A指定,寄存器数量由操作数B指定,操作数C没用。

func return(i Instruction, vm LuaVM) {
	a, b, _ := i.ABC()
	a += 1
	
	if b == 1 {
		//no return value
	} else if b > 1 {
		// b-1 return values
		vm.CheckStack(b-1)
		for i := a; i <= a+b-2; i++ {
			vm.PushValue(i)
		}
	} else {
		_fixStack(a, vm)
	}
}

VARARG:把传递给当前函数的变长参数加载到连续多个寄存器中。其中第一个寄存器由操作数A指定,寄存器数量由操作数B指定,操作数C没有用到。

func vararg(i Instruction, vm LuaVM) {
	a, b, _ :=i.ABC()
	a += 1
	if b != 1 {
		vm.LoadVararg(b-1)
		_popResults(a, b, vm)
	}
}

TAILCALL:函数调用一般通过调用栈来实现。用这种方法,每调用一个函数就会产生一个调用帧。如果方法调用层次太深(特别是递归调用函数时),就容易导致调用栈溢出。通过尾递归优化,可以避免栈溢出。利用这种优化,被调函数可以重用主调函数的调用帧,因此可以有效缓解调用栈溢出症状。不过尾调用优化只适用某些特定的情况,不能包治百病。

func tailCall(i Instruction, vm LuaVM) {
	a, b, _ := i.ABC()
	a += 1
	c := 0

	nArgs := _pushFuncAndArgs(a, b, vm)
	vm.Call(nArgs, c-1)
	_popResults(a, c, vm)
}

SELF:主要用来优化方法调用语法糖。比如obj:f(a,b,c),虽然从语义的角度来说完全等价于obj.f(obj,a ,b,c),但是Lua编译器并不是先去掉语法糖再按普通的函数调用处理,而是会生成SELF指令,这样就节约一条指令。
SELF指令把对象和方法拷贝到相邻的两个目标寄存器中。对象在寄存器中,索引由操作数B指定。方法名在常量表中,索引由操作数C指定。目标寄存器索引由操作数A指定。

func self(i Instruction, vm LuaVM) {
	a, b, c := i.ABC()
	A += 1; B += 1

	vm.Copy(b, a+1)
	vm.GetRK(c)
	vm.GetTable(b)
	vm.Replace(a)
}

3.8.4 测试代码

func main() {
	if len(os.Args) > 1 {
		data, err := ioutil.ReadFile(os.Args[1])
		if err !=  nil { panic(err) }
		ls := state.New()
		ls.Load(data, os.Args[1], "b")
		ls.Call(0, 0)
	}
}

首先读取二进制chunk文件,然后创建luaState实例,接着调用Load()方法把主函数加载到栈顶,最后调用Call()方法运行主函数(没有参数,没有返回值,因此是0,0)

3.9 Go函数调用

要想让Lua函数调用Go函数编写函数,就需要一种机制能够给Go函数传递参数,并且接收Go函数返回值。在执行Lua函数时,Lua栈(luastate的luastack)充当虚拟寄存器以供指令操作。在调用Lua函数时,Lua栈充当栈帧以供参数和返回值传递,我们也可以利用Lua栈来给Go函数传递参数和接受返回值。

我们约定,Go函数必须满足这样的签名:接受一个LuaState接口类型的参数,返回一个整数。

type GoFunction func(LuaState) int

在Go函数开始执行之前,Lua栈里是传入的参数值,别无它值。当Go函数结束后,把需要返回的值留在栈顶,然后返回一个整数表示返回值个数。由于Go函数返回了返回值数量,这样它在执行完毕时就不用对栈进行清理了,把返回值留在栈顶即可。

type Closure struct {
	proto  *binchunk.Prototype	//lua closure
	goFunc GoFunction	//go closure
}

我们使用closure结构体统一表示Lua和Go闭包。如果proto字段不是nil,说明这是Lua闭包。否则,goFunc字段一定不是nil。

3.9.1 扩展LuaAPI

type LuaVM interface {
	LuaState
	//  ...
	
	PushGoFunction(f GoFunction) //接受一个Go函数参数,把它转变成Go闭包后推入栈顶
	IsGoFunction(idx int) bool	//判断指定索引处的值是否可以转换成Go函数,该方法以栈索引为参数,返回布尔值,不改变栈的状态
	ToGoFunction(idx int) GoFunction	//把指定索引处的值转变为Go函数并返回,如果值无法转换为Go函数,返回nil即可。该方法以栈索引为参数,不改变栈的状态
}

3.9.2 调用Go函数

修改Call()函数

func (self *luaState) Call(nArgs, nResults int) {
	val := self.stack.get(-(nArgs + 1))
	if c, ok := val(*closure); ok {
		if c.proto != nil {
			self.callLuaClosure(nArgs, nResults, c)
		} else {
			self.callGoClosure(nArgs, nResults, c)
		}
	} else {
		panic("not function")
	}
}
func (self *luaState) callGoClosure(nArgs, nResults int, c *closure) {
	newStack := newLuaStack(nArgs + 20)
	newStack.closure = c

	args := self.stack.popN(nArgs)
	newStack.pushN(args, nArgs)
	self.stack.pop()
	
	self.pushLuaStack(newStack)
	r := c.goFunc(self)
	self.popLuaStack()
	
	if nResults != 0 {
		results := newStack.popN(r)
		self.stack.check(len(results))
		self.stack.pushN(results, nResults)
	} 
}

我们先创建新的调用帧,然后把参数值从主调帧里弹出,推入被调帧。Go闭包直接从主调帧里弹出扔掉即可。

3.9.3 Lua注册表

**3.9.3.1 添加注册表 **

Lua给用户提供了一个注册表,这个注册表实际上就是一个普通的Lua表,所以用户可以在里面存放任何Lua值。Lua全局变量就是借助这个注册表实现的。

type luaState struct {
	register *luaTable	//注册表
	stack *luaStack

	//...
}

由于注册表是全局状态,每个Lua解析器实例都有自己的注册表,所以把它放在luaState结构体里。

3.9.3.2 操作注册表

访问注册表通过伪索引(pseudo-index)实现。负一百万再减去1000就是表示注册表的伪索引,用常量LUA_REGISTRYINDEX表示。

3.9.4 Lua全局环境

Lua全局变量是存在全局环境(5.1在注册表的_G表,5.2在_ENV表)里的,而全局环境也只是存在注册表里的一张普通的表。因为全局环境的使用比注册表还要繁琐一些,所以LuaAPI提供了专门的方法来操作全局环境。

type LuaState interface {
	// ...
	pushGlobalTable()	//把全局环境(注册表下面的_G表)推入栈顶以备后续操作使用
	GetGlobal(name string) LuaType	//由于全局环境主要是用来实现Lua全局变量的,所以里面的键基本上都是字符串。该方法可以把全局变量中的某个字段推入栈顶
	SetGlobal(name string)	//往全局环境(注册表下的_G表)写入一个值,其中字段名由参数决定,值从栈顶弹出
	Register(name string, f GoFunction)	//专门用于给全局环境(注册表下的_G表)注册Go函数值。该方法仅操作全局环境,字段名和Go函数从参数传入,不改变Lua栈的状态。
}

Register()方法,专门用于给全局环境注册Go函数值。该方法仅操作全局环境,字段名和Go函数从参数传入,不改变Lua栈的状态。

func (self *luaState) Register(name string, f GoFunction) {
	self.PushGoFunction(f)
	self.SetGlobal(name)
}

3.9.5 测试代码

下面这个Go语言print()函数即将摇身一变,成为Lua语言里的print()函数。

func print(ls LuaState) int {
	nArgs := ls.GetTop()
	for i := 1; i <= nArgs; i++ {
		if ls.IsBoolean(i) {
			fmt.Printf("%t", ls.ToBoolean(i))
		} else if ls.IsString(i) {
			fmt.Print(ls.ToString(i))
		} else {
			fmt.Print(ls.TypeName(ls.Type(i)))
		}
		if i < nArgs {
			fmt.Print("\t")
		}
	}
	fmt.Println()
	return 0
}

实现好了Go语言print()函数,下一步把它注册到Lua语言里:

func main() {
	if len(os.Args) > 1 {
		data, err := ioutil.ReadFile(os.Args[1])
		if err != nil { panic(err) }
		ls := state.New()
		ls.Register("print", print)	//注册print函数
		ls.Load(data, "chunk", "b")
		ls.Call(0, 0)
	}
}

3.10 闭包和Upvalue

3.10.1 Upvalue介绍

Lua函数本质上全是闭包。就算是编译器为我们生成的主函数也不例外,它从外部捕获了_ENV变量。

实际上Upvalue就是闭包内部捕获的非局部变量。

如果Lua函数捕获的变量不是直接外围函数,而是更外围的函数,那么这种需要借助外围函数来捕获更外围函数局部变量的闭包,叫做扁平闭包(Flat Closure)。

3.10.2 全局变量

Lua全局变量是存在(5.1在注册表的_G表,5.2在_ENV表)里的一个字段。Lua编译器在生成主函数时会在它的外围隐式声明一个局部变量:

local _ENV	--全局环境(是主函数以及主函数所有子函数的Upvalue)
function main(...)
	--其他代码
end

编译器会把全局变量的读写翻译成_ENV字段的读写。也就是说,全局变量实际上也是语法糖

local function f()
	local function g()
		x = y
	end
end

等价于	:
local function f()
	local function g()
		_ENV.x = _ENV.y
	end
end

Lua的变量可以分为三类:局部变量在函数内定义(本质上是函数调用帧里的寄存器),Upvalue是直接或间接外围函数定义的局部变量,全局变量则是全局环境表的字段(通过隐藏的Upvalue,也就是_ENV进行访问)。

对于一个Lua函数来说,有3个地方可以存放非局部的数据,它们是全局变量、函数环境和非局部变量(closure中)。而C API也提供了3种地方来保存这类数据:注册表、环境和upvalue。

注意:

5.1之前, 全局变量存储在_G这个table中, 这样的操作:a = 1 相当于:_G['a'] = 1

在5.2之后, 引入了_ENV叫做环境,与_G全局变量表产生了一些混淆。

在5.2中, 操作a = 1相当于_ENV['a'] = 1

这是一个最基础的认知改变,其次要格外注意_ENV不是全局变量,而是一个upvalue(非局部变量)。

其次,_ENV[‘_G’]指向了_ENV自身,这一目的是为了兼容5.1之前的版本,因为之前你也许会用到:_G['a'] = 2。在5.2中, 这相当于_ENV[‘_G’][‘a’],为了避免5.1之前的老代码在5.2中运行错误,所以5.2设置了_ENV[‘_G’]=_ENV来兼容这个问题。然而你不要忘记_ENV[‘_G’]=_ENV,所以一切都顺理成章了。

在5.1中,我们可以为一段代码块(或者函数)设置环境,使用函数setfuncs,这样会导致那一段代码/函数访问全局变量的时候使用了setfuncs指定的table,而不是全局的_G。

在5.2中,setfuncs遭到了废弃,因为引入了_ENV。 通过在函数定义前覆盖_ENV变量即可为函数定义设置一个全新的环境,比如:

a = 3
function get_echo()
	local _ENV = {print=print, a = 2}
	return function echo()
				print(a)
			end
end

get_echo()()

会打印2,而不是3,因为echo函数的环境被修改为{print=print, a=2},而print(a)相当于访问_ENV[‘a’](先忘掉那为了兼容而存在的_G)。

lua_setglobal/lua_getglobal都是操作lua_State注册表中LUA_RIDX_GLOBALS伪索引指向的全局变量表,与lua中访问_ENV[‘a’]或者a是不同的。

lua_load加载lua代码后会返回一个函数,默认会给这个函数设置一个upvalue就叫_ENV,其值是LUA_RIDX_GLOBALS的全局变量表,你可以lua_setupvalue设置这个函数的upvalue,即下标1的upvalue,因为这个位置是这个函数的_ENV表存放位置(你可以通过lua_setupvalue的返回值印证这一点)

这里巧妙的是,lua_State会在创建时保证LUA_RIDX_GLOBALS的全局变量表中包含一个指向自己的_G元素,这样就保证了在不调用lua_setupvalue的情况下该返回函数的_ENV[‘_G’]是指向自己的,即LUA_RIDX_GLOBALS这个全局表。(其实你的lua解释器就是简单的lua_load后pcall的,对于一个刚启动lua_State来说是没有_ENV的,是lua解释器load你的代码时自动给带上的_ENV,其值是lua_state的LUA_RIDX_GLOBALS全局表。)

最后,提一下,lua_state启动后在注册表里LUA_RIDX_GLOBALS下标存放的全局表一定有一个元素是指向自己的,即_G。

3.10.3 Upvalue底层支持

3.10.3.1 修改closure结构体

闭包要捕获外围函数的局部变量,就必须有地方来存放这些变量。

type closure struct {
	proto  *binchunk.Prototype
	goFunc  GoFunction
	upvals  []*upvalue	//存放Upvalue值(闭包内部捕获的非局部变量)
}

3.10.3.2 Lua闭包支持

Lua函数全部都是闭包,就连编译器为我们生成的主函数也是闭包,捕获了_ENV这个特殊的Upvalue,这个特殊的Upvalue的初始化由API方法Load()负责。具体来说,Load()方法在加载闭包时,会看它是否需要Upvalue,如果需要,那么第一个Upvalue(对于主函数来说就是_ENV)会被初始化成全局变量,其他Upvalue会被初始化为nil。

func (self *luaState) Load(chunk []byte, chunkname, mode string) int {
	proto := binchunk.Undump(chunk)
	c := newLuaClosure(proto)
	self.stack.push(c)
	if len(proto.Upvalues) > 0 { //设置_env
		env := self.registry.get(LUA_RIDX_GLOBALS)
		c.upvalues[0] = &upvalue{&env}
	}
	return 0
}

我们在加载子函数原型时也需要初始化Upvalues,修改LoadProto()方法。

func (self *luaState) LoadProto(idx int) {
	stack := self.stack
	subProto := stack.closure.proto.Protos[idx]
	closure := newLuaClosure(subProto)
	stack.push(closure)
	
	for i, uvInfo := range subProto.Upvalues {
		uvIdx := int(uvInfo.Idx)
		if uvInfo.Instack == 1 { //Upvalue捕获的是当前函数的局部变量,那么我们只需要访问当前函数的局部变量即可
			if stack.openuvs == nil {
				stack.openuvs = map[int]*upvalue{}
			}
			if openuv, found := stack.openuvs[uvIdx]; found {
				closure.upvals[i] = openuv
			} else {
				closure.upvals[i] = &upvalue{&stack.slots[uvIdx]}
				stack.openuvs[uvIdx] = closure.upvals[i]
			}
		} else { //Upvalue捕获的是更外围的函数中的局部变量,该Upvalue已被当前函数捕获,我们只需要把该Upvalue传递给闭包即可
			closure.upvals[i] = stack.closure.upvals[uvIdx]
		}
	} 
}

如果Upvalue捕获的外围函数局部变量还在栈上,直接引用即可,我们称这种Upvalue处于开放状态;反之,必须把变量实际值保存在其他地方,我们称这种Upvalue处于闭合状态。为了能够在合适的时机(比如局部变量退出作用域时)把处于开放状态的Upvalue闭合,需要记录所有暂时还处于开放状态的Upvalue,我们把这些Upvalue记录在被捕获局部变量所在的堆栈里。
给luaStack结构体添加openuvs字段。该字段是map类型,其中键是int类型,存放局部变量的寄存器索引,值是Upvalue指针。

type luaStack struct {
	// ...
	openuvs map[int]*upvalue	//记录所有暂时还处于开放状态的Upvalues。键是索引,值是Upvalue指针
}

3.10.4 Upvalue相关指令

GETUPVAL:把当前闭包的某个Upvalue值拷贝到目标寄存器中。其中目标寄存器的索引由操作数A指定,Upvalue索引由操作数B指定,操作数C没用。

func getUpval(i Instruction, vm LuaVM) {
	a, b, _ := i.ABC()
	a += 1; b += 1

	vm.Copy(LuaUpvalueIndex(b), a)
}

SETUPVAL:使用寄存器中的值给当前闭包的Upvalue赋值。其中寄存器索引由操作数A指定,Upvalue索引由操作数B指定,操作数C没用。
如果我们在函数给Upvalue赋值,Lua编译器就会在这些地方生成SETUPVAL指令。

func setUpval(i Instruction, vm LuaVM) {
	a, b, _ := i.ABC()
	a += 1; b += 1
	
	vm.Copy(a, LuaUpvalueIndex(b))
}

GETTABUP:如果当前闭包的某个Upvalue是表,则GETTABUP指令可以根据键从该表里取值,然后把值放入目标寄存器中。其中目标寄存器索引由操作数A指定,Upvalue索引由操作数B指定,键(可能在寄存器中也可能在常量表中)索引由操作数C指定。

func getTabUp(i Instruction, vm LuaVM) {
	a, b, c := i.ABC()
	a += 1; b += 1
	
	vm.GetRK(c)
	vm.GetTable(LuaUpvalueIndex(b))
	vm.Replace(a)
}	

SETTABUP:如果当前闭包的某个Upvalue是表,则SETTABUP指令可以根据键往该表里写入值。其中Upvalue索引由操作数A指定,键和值可能在寄存器中也可能在常量表中,索引分别由操作数B和C指定。
如果我们在函数里根据键往Upvalue里写入值,Lua编译器就会在这些地方生成SETTABUP指令。

func setTabUp(i Instruction, vm LuaVM) {
	a, b, c := i.ABC()
	a += 1

	vm.GetRK(b)
	vm.GetRK(c)
	vm.SetTable(LuaUpvalueIndex(a))
}

JMP:该指令除了可以进行无条件跳转之外,还兼顾着闭合处于开启状态的Upvalue的责任。如果某个块内部定义的局部变量已经被嵌套函数捕获,那么当这些局部变量退出作用域(也就是块结束)时,编译器会生成一条JMP指令,指示虚拟机闭合相应的Upvalue。

func jmp(i Instruction, vm LuaVM) {
	a, sBx := i.AsBx()
	vm.AddPC(sBx)
	if a != o {
		vm.CloseUpvalues(a) 
	}
}

3.11 元编程

3.11.1 元表

在Lua里,每个值都可以有一个元表,如果值的类型是表或者用户数据,则可以拥有自己“专属”的元表,其他类型的值则是每种类型共享一个元表。

Lua标准库提供了getmetatable()函数,可以获取与值关联的元表。
Lua标准库提供了setmetatable()函数,可以给表设置元表。

3.11.2 支持元表

给luaTable结构体添加metatable字段

type luaTable struct {
	metatable  *luaTable
	arr  []luaValue
	_map  map[luaValue]luaValue
}

3.12 迭代器

Lua语言支持两种形式的for循环语句,数值for循环和通用for循环。数值for循环用于两个数值范围内按照一定的步长进行迭代;通用for循环常用于对表进行迭代。

3.13 异常和错误处理

pcall()会在保护模式下调用被调函数。如果一切正常,pcall()返回true和被调函数返回的全部值。如果调用过程中有异常抛出,pcall()捕获异常,返回fasle和异常。

3.13.1 异常和错误API

type luaState interface {
	// ...
	Error() int	//从栈顶弹出一个Lua值,把该值作为错误抛出
	PCall(nArgs, nResults, msgh int) int	//如果有错误产生,那么PCall()会捕捉错误,把错误对象留在栈顶,并且会返回相应的错误码。
}

3.13.2 error()和pcall()函数

有了API方法Error()和PCall(),实现标准库函数error()和pcall()就是小菜一碟了。

func error(ls LuaState) int {
	return ls.Error()
}
func pCall(ls LuaState) int {
	nArgs := ls.GetTop() - 1
	status := ls.PCall(nArgs, -1, 0)
	ls.PushBoolean(status == LUA_OK)
	ls.Insert(1)
	return ls.GetTop() 
}

调用PCall()方法并插入一个布尔类型的返回值。PCall()方法会把被调函数和参数从栈顶弹出,然后把返回值或者错误对象留在栈顶。我们只需要根据PCall()返回的状态码往栈顶推入一个布尔值,然后把它挪到栈底,让它成为返回给Lua的第一个值即可。

四、Lua标准库

4.1 辅助API和基础库

Lua标准库包括基础库、数学库(math)、字符串库(string)、UTF-8库(utf8)、表操作库(table)、输入输出库(IO)、操作系统库(OS)、包和模块库(package)、协程库(coroutine)、调试库(debug)这十个。

其中标准库是以全局变量(5.1在注册表的_G表,5.2在_ENV表)的形式提供的。Lua标准库里的函数完全是用LuaAPI和辅助API实现的。(都是对Lua栈的操作)

其他库则是以模块的形式提供,使用时需要带上具体模块的前缀,例如math.random()。

4.1.1 辅助API

辅助API完全建立在基础API之上,对一些常用的操作进行了封装。

把原来的LuaState接口重命名为BasicAPI接口,改动如下:

type BasicAPI interface {
	// 原来LuaState接口里的方法
}

然后重新定义LuaState接口,让其扩展BasicAPI和AuxLib接口,代码如下:

type LuaState interface {
	BasicAPI	//基础库
	AuxLib	//辅助库
}

4.1.2 加载方法

LoadString()、LoadFileX()、LoadFile()这三个方法是对基础库中Load()方法的包装。

LoadString()方法加载字符串:

func (self *luaState) LoadString(s string) int {
	return self.Load([]byte(s), s, "bt")
}

LoadFileX()方法加载文件:

func (self *luaState) LoadFileX(filename, mode string) int {
	if data, err := ioutil.ReadFile(filename); err == nil {
		return self.Load(data, "@" + filename, mode)
	}
	return LUA_ERRFILE
} 

LoadFile()方法以默认模式加载文件:

func (self *luaState) LoadFile(filename string) int {
	return self.LoadFileX(filename, "bt")
} 

DoString()加载并使用保护模式执行字符串:

func (self *luaState) DoString(str string) bool {
	return self.LoadString(str) == LUA_OK && 
				self.PCall(0, LUA_MULTRET, 0) == LUA_OK
}

DoFile()加载并使用保护模式执行文件:

func (self *luaState) DoFile(filename string) bool {
	return self.LoadFile(filename) == LUA_OK && 
				self.PCall(0, LUA_MULTRET, 0) == LUA_OK
}

4.1.3 标准库开启方法

Lua标准库完全是可选的。如果想在Lua脚本里使用标准库函数,需要在创建Lua解析器实例之后显示开启各个标准库。辅助API提供了OpenLibs()方法,可以开启全部的标准库:

func (self *luaState) OpenLibs() {
	libs := map[string]GoFunction {
		"_G": stdlib.OpenBaseLib,
	}

	for name, fun := range libs {
		self.RequireF(name, fun, true)
		self.Pop(1)
	}
}

该方法实际上就是循环调用各个标准库的开启函数而已。

OpenBaseLib()方法就是把基础库函数全部注册进全局变量表:

func OpenBaseLib(ls luaState) int {
	// open lib into global table
	ls.PushGlobalTable()
	ls.SetFuncs(baseFuncs, 0)
	// set global _G
	ls.PushValue(-1)
	ls.SetField(-2, "_G")
	// set global _VERSION
	ls.PushString("Lua 5.3")
	ls.SetField(-2, "_VERSION")
	return 1
}

RequireF()方法用于开启单个标准库:

func (self *luaState) RequireF(modname string, openf GoFunction, glb bool) {
	self.GetSubTable(LUA_REGISTRYINDEX, "_LOADED")
	self.GetField(-1, modname)	//LOADED[modname]
	if !self.ToBoolean(-1) {	//package not already loaded?
		self.Pop(1)	//remove field
		self.PushGoFunction(openf)
		self.PushString(modname)	//argument to open function
		self.call(1, 1)	//call 'openf' to open module
		self.PushValue(-1)	//make copy of module (call result)
		self.SetField(-1, modname)	//_LOADED[modname] = module
	}
	self.Remove(-2)	//remove _LOADED table
	if glb {
		self.PushValue(-1)	/copy of module
		self.SetGlobal(modname)	//_G[modname] = module
	}
}

4.1.4 基础库

基础库是通过全局变量(5.1在注册表的_G表,5.2在_ENV表)的形式提供的。基础库一共提供了24个全局变量。其中_VERSION是字符串类型的变量,提供Lua版本号。全局变量实际上是某个表的字段,这个表就是_G。剩下的22个全局变量是函数类型,后面称它们为全局函数。

这22个全局函数大致上又可以分为类型相关、错误处理相关、迭代器相关、元编程相关、加载等六类。

元编程相关函数包括:

getmetatable()
setmetatable()
rawget()
rawset()
rawlen()
rawequal()

迭代器相关函数包括:

next()
pair()
ipair()

错误处理相关函数包括:

error()
pcall()
xpcall()
assert()

类型相关函数包括:

type()
tonumber()
tostring()

加载函数包括:

load()
loadfile()
dofile()

其他函数包括:

print()
select()
require()

4.2 工具库

4.2.1 数学库

数学库通过全局变量math提供了数学相关的常量和函数。其中常量有4个,分别表示整数最大值、整数最小值、浮点数正无穷以及圆周率PI。函数包括三角函数、对数和指数的计算、随机数计算、绝对值计算、最大值和最小值计算、取整、开平方等23个。

4.2.2 表库

其函数通过全局变量table提供,但是这些函数是针对数组进行操作,而非关联表。表库一共提供七个函数,其中move()函数用于在同一个数组中或者两个数组之间移动元素,insert()函数函数用于往数组中插入元素,remove()函数用于从数组中删除元素,sort()函数用于对数组进行排序,concat()函数可以把数组拼接为字符串,pack()函数可以把参数打包成为数组,unpack()函数把数组解包后返回。

4.2.3 字符串库

字符串库通过全局变量string提供了一些常用的字符串操作函数、比如反转、提取子串、格式化等。另外还可以进行模式匹配,通过类似正则表达式的模式对字符串进行搜索和替换等操作。

字符串库还给字符串类型设置了元表和_index元方法,这样我们就可以通过面向对象的方式来使用字符串库。

4.2.4 UTF-8库

UTF-8库通过全局变量utf8对字符串提供了基本的UTF-8编码支持。UTF-8库一共包含5个函数。

utf8.len()	//返回字符串中的UTF-8字符数
utf8.char()	//对代码点进行UTF-8编码,并生成字符串
utf8.offset()	//返回某个UTF-8字符在字符串中的字节偏移
utf8.codepoint()	//返回偏移处的Unicode字符代码点
uft8.codes()	//允许我们对UTF-8字符串中的Unicode代码点进行迭代

4.2.5 OS库

OS库通过全局变量os提供操作系统相关的一些函数,这些函数主要用于获取或者格式化时间和日期、删除或重命名文件、执行外部命令等,共11个。

os.time()	//用于获取当前时刻或者由参数指定某个时刻的时间戳
os.data()	//格式化当前时刻或者指定时刻

4.3 包和模块

标准库提供的常量或者函数都被封装在各自的表里,这些表又被赋值给了全局变量(5.1在注册表的_G表,5.2在_ENV表),所以我们才能以类似math.random()这样的方式来使用这些库。

4.3.1 包和模块介绍

在Lua里,函数是代码重用的最小单位,把一系列函数和相关常量等收集起来,放在单独的命名空间内,就构成了模块,多个模块又可以进一步构成包。

Lua5.1通过package标准库提供了一个统一的包和模块机制。package也是在_G表下的。

for k,v in pairs(package.loaded) do print(k,v) end
for k,v in pairs(_G['package']['loaded']) do print(k,v) end

以上两种写法一样。

require()函数

require()用来加载模块。该函数以模块名(字符串类型)为参数,返回加载后的模块(一般而言是一个表)。require()函数是作为全局变量提供的。

require()函数在真正加载模块之前,会先去package.loaded表里按名字查找模块,如果找到的话(不一定是表)就认为模块已经加载,直接返回模块即可。假如某个模块已经加载,想要重新加载,直接把它从package.loaded表里抹掉即可。

package.loaded[filename] = nil
require(filename)

标准库全部都是以模块的形式提供的。Lua发布版携带的解析器已经默认打开了全部标准库(通过调用辅助API提供的OpenLibs()方法),因此这些库应该已经在package.loaded表里。执行下面的命令可以证明这一点。

lua -e 'for k,v in pairs(package.loaded) do print(k,v) end'

搜索器

require()函数需要先求助搜索器(Searcher)。搜索器是普通函数,接受的唯一参数是模块名,如果搜索器可以找到加载器(也是一个普通函数),则返回加载器和一个额外值,该值会传递给加载器。否则,返回一个字符串,解析为什么搜索失败,或者直接返回nil。

搜索器可以有多个,按顺序存储在package.searchers数组里。Lua官方实现预定义了四个搜索器,按顺序分别是preload搜索器、Lua搜索器、C搜索器、CRoot搜索器。

lua -e 'for k,v in pairs(package.searchers) do print(k,v) end'

其中,preload搜索器只是简单地搜索package.preload表。Lua搜索器用于搜索Lua模块加载器,这个搜索器试图从package.path变量指定的路径找到一个Lua文件,然后加载文件,加载后的函数就是加载器。文件查找逻辑和package.searchpath()一致。

package.path

package.path变量提供的搜索路径决定了Lua搜索器从哪里搜索Lua库文件。

lua -e 'print(package.path)'

搜索路径是由多个子路径构成的,默认按照分号分隔。package.searchpath()函数负责将这个路径分解,并把问号用模块名替换,得到一系列真正的文件路径,然后返回实际存在的第一个文件路径,或者nil和错误信息。

lua -e 'print(package.searchpath('foo.bar', package.path))'

由于模块名可以用点号分隔,最后又会被转换为目录结构等形式,所以模块之间可以形成一种树状层级结构。例如,可以认为a.b.c和a.b.d模块是a.b模块的子模块,a.b模块又是a模块的子模块。一组相互关联的模块按照这种层次结构组织起来就构成了一个包。

文件目录分隔符、搜索路径分隔符、模块名占位符等记录在package.config变量里。

lua -e 'print(package.config)'

4.3.2 实现包库

func OpenPackageLib(ls LuaState) int {
	ls.NewLib(pkgFuncs)	// create 'package' table
	createSearchersTable(ls)
	// set paths
	ls.PushString("./?.lua;./?/init.lua")
	ls.SetField(-2, "path")
	// store config information
	ls.PushString(";" + "\n" + "?" + "\n" + "!" + "\n" + "-" + "\n")
	ls.SetField(-2, "config")
	// set field 'loaded'
	ls.GetSubTable(LUA_REGISTRYINDEX, LUA_LOADED_TABLE)
	ls.SetField(-2, "loaded")
	// set field 'preload'
	ls.GetSubTable(LUA_REGISTRYINDEX, LUA_PRELOAD_TABLE)
	ls.SetField(-2, "preload")

	ls.PushGlobalTable()	// 把注册表下的_G表(全局环境/全局表)推入到栈顶
	ls.PushValue(-2)	//set 'package' as upvalue for next lib
	ls.SetFuncs(“require”, 1)	//open lib into global table
	ls.Pop(1)
	return 1	//return 'package' table
}

把package包以及里面的函数和变量准备好,并把require()函数注册到全局表里(_G表)。package.path、package.config、package.loaded、package.preload这几个个表实际被放进了注册表。

4.4 协程

4.4.1 协程介绍

协程标准是Lua5.0引入的,最初包括create()、resume()、yield()、status()、wrap()这五个函数,Lua5.1添加了一个running()函数,Lua5.3又添加了一个isyieldable()函数,这些函数全部封装在coroutine表里。

协程在Lua里是一等公民,我们可以把协程赋值给变量、存在表里、作为参数传递给函数或者作为返回值从函数里返回等等。

协程有四个状态:运行(running)、挂起(suspended)、正常(normal)和死亡(dead)。任何时刻,只能有一个协程处于运行状态,通过running()函数可以获取这个协程,通过status()函数则可以获取任意协程的状态。即使完全不使用协程库,我们的脚本也是一个正在运行内运行,这个协程被称为主线程。

main = coroutine.running()
print(type(main))	-->thread
print(coroutine.status(main)) -->running

处于运行状态的协程可以通过resume()函数让某个处于挂起状态的协程开始或恢复运行(进入运行状态running),自己则进入正常状态(normal)。被恢复而处于运行状态(running)的协程可以通过yield()函数挂起自己,进入挂起状态(suspended),等待下一次被恢复。如果协程正常执行而结束,则处于死亡状态(dead)。
请添加图片描述

4.4.2 支持协程类型

type luaState struct {
	// ...
	coStatus  int	//协程状态
	coCaller  *luaState	//调用这个协程的协程
	coChan  chan  int	//两个线程之间通过这个字段来互相协助
}

修改New()函数:

func New() LuaState {
	ls := &luaState{}
	
	registry := newLuaTable(8, 0)
	registry.put(LUA_RIDX_MAINTHREAD, ls)
	registry.put(LUA_RIDX_GLOBALS, newLuaTable(0, 20))

	ls.registry = registry
	ls.pushLuaStack(newLuaStack(LUA_MINSTACK, ls))
	return ls
}

通过New()函数创建的线程就是前面提到的主线程,我们把它记录在Lua注册表里,放在索引1处,其他线程通过NewThread()方法创建。

func (self *luaState) NewThread() LuaState {
	t := &luaState{registry: self.registry}
	t.pushLuaStack(newLuaStack(LUA_MINSTACK, t))
	self.stack.push(t)
	return t
}

这个函数创建一个新的线程,把它推入栈顶,同时也作为返回值返回。新创建的线程和创建它的线程共享相同的全局变量,但是有各自的调用栈。

新创建的线程处于挂起状态,Resume()方法让它进入运行状态。

func (self *luaState) Resume(from LuaState, nArgs int) int {
	lsFrom := from.(*luaState)
	if lsFrom.coChan == nil {
		lsFrom.coChan = make(chan int)
	}
	
	if self.coChan == nil {	// start coroutine
		self.coChan = make(chan int)
		self.coCaller = lsFrom
		go func() {
			self.coStatus = self.PCall(nArgs, -1, 0)
			lsFrom.coChan <-1
		}()
	} else {	// resume coroutine
		self.coStatus = LUA_OK
		self.coChan <- 1
	}
	
	<-lsFrom.coChan	// wait coroutine to finish or yield
	return self.coStatus
}

两个线程通过彼此的coChan字段来相互协作,所以需要在首次使用到该字段时进行初始化。对于将要进入运行状态的线程,如果其coChan字段是nil,说明是首次开始运行,需要启动一个Go语言协程(Goroutine)来执行其主函数;否则,线程恢复运行,往它的coChan里随便写入一个值即可。不管线程是首次还是恢复运行,从它的coChan里接受一个值可以等待它执行结束或者挂起。

Yield()方法可以挂起线程:

func (self *luaState) Yield(nResults int) int {
	self.coStatus = LUA_YIELD
	self.coCaller.coChan <- 1
	<-self.coChan
	return self.GetTop()
}

首先把线程状态设置为挂起,然后通知协作方恢复运行,最后等待再一次恢复运行。

Status()方法返回线程的当前状态,这个状态保存在coStatus字段里,它的值可能是LUA_OK、LUA_YIELD、或者某种错误码。

func (self *luaState) Status() bool {
	return self.coStatus
}

GetStack()方法返回线程是否还在执行当中(还有调用栈)

func (self *luaState) GetStack() bool {
	return self.stack.prev != nil
}

五、Lua代码优化

5.1 table使用注意事项

使用table时,如果预先知道大小,最好直接分配,否则Lua解析器会对表进行重新散列的动作(resize)。如下代码:

local a = {}
for i = 1,3 do 
	a[i] = true
end

这段代码,主要做了以下工作:

  1. 最开始,Lua创建了一个空表。
  2. 在第一次迭代中,a[1]为true触发了一次重新散列操作,Lua将数组部分的长度设置为2^0,即1,散列表部分仍为空。
  3. 在第二次迭代中,a[2]为true再次触发了重新散列操作,将数组部分长度设为2^1,即2.
  4. 最后一次迭代又触发了一次重新散列操作,将数组部分长度设为2^2,即4。

只有三个元素的表会三次重新散列操作,然而有100万个元素的表仅仅只会执行20次重新散列操作而已。因为2^20=1048576>1000000。但是,如果创建了非常多的长度很小的表(比如坐标点:point = {x=0, y=0}),这可能会造成巨大的影响。

如果你有很多很小的表需要创建,就可以预先填充以避免重新散列操作。比如:{true, true, true}

5.2 string使用注意事项

Lua会把系统中所有字符串存在一个全局的地方,这个全局变量就是global_state的strt成员。这是一个散列数组,专门用于存放字符串。

使用散列桶来存放数据,当数据量非常大时,分配到每个桶上的数据也会非常多,这样一次查找也会退化为一次线性查找过程。Lua中也考虑了这种情况,所有会有重新散列(rehash)的过程,这就是当字符串数据非常多时,会重新分配桶的数量,降低每个桶上分配到的数据数量,这个过程在函数luaS_resize中。

当一个C函数从Lua收到一个字符串参数时,必须遵守两条规则:
(1)不要访问字符串时从栈中弹出它。(在C层面调用memcpy()复制到C自己定义的字符串,给C层面使用)
(2)不要修改字符串。(因为该字符串由Lua虚拟机管理,Lua虚拟机会对没有被引用的字符串进行GC)

应尽量少地使用字符串连接操作符,因为每一次都会生成一个新的字符串:

a = os.clock()
local s= ''
for i = 1, 300000 do
	s = s .. 'a'
end	
b = os.clock()
print(b-a)	--6.649482

这段代码使用字符串连接操作符生成新的字符串。下面是另一种实现:

a = os.clock()
local s= ''
local t = {}
for i = 1, 300000 do
	t[#t + 1] = 'a'
end	
s = table.concat(t, '')
b = os.clock()
print(b-a)	--0.07178

这种做法使用table来模拟字符串缓冲,避免了大量使用连接操作符,其性能比第一段代码提升了95倍多。

5.3 垃圾回收(GC,Garbage collect)

5.3.1 原理

Lua5.0使用的是双色标记清除算法(Two-Color Mark and Sweep)。该算法的原理是:系统中的每个对象非黑即白,也就是要么被引用,要么没被引用。

伪代码:

每个新创建的对象的颜色为白色

//初始化阶段
遍历root链表中的对象,并将其加入到对象链表中。

//标记阶段
当对象链表中还有未扫描的元素:
	从中取出一个对象并将其标记为黑色
	遍历这个对象关联的其他对象:
		标记为黑色

//回收阶段
遍历所有对象:
	如果为白色:
		这些对象就是没有被引用的对象,逐个回收
	否则:
		这些对象是被引用的对象,重新加入对象链表中等待下一次的GC检查	

在双色标记清除算法中,标记阶段和回收阶段必须合在一起完成,不能被打断,也就意味着每次GC操作的代价极大,在GC过程中,程序必须暂停下来,不能进行其他操作。

Lua5.1之后,采用三色增量标记清除算法(Tri-Color Incremental Mark and Sweep)。

三种颜色分类:

  • 白色:当前对象为待访问状态,表示对象还没有被GC标记过,这也是任何一个对象创建后的初始状态。换言之,如果一个对象再结束GC扫描过程后仍然是白色,说明该对象没有被系统中的任何一个对象所引用,可以回收其空间了。
  • 灰色:当前对象为待扫描状态,表示对象已经被GC访问过,但是该对象引用的其他对象还没有被访问。
  • 黑色:当前对象为已扫描状态,表示对象已经被GC访问过,并且该对象引用的其他对象也被访问了。

好处:它不必要求GC一次性扫描完所有的对象,这个GC过程可以是增量的,可以被中断再恢复并继续进行。

伪代码:

每个新创建的对象颜色为白色

//初始化阶段
遍历root节点中引用的对象,将mianthread、G表、registry表的对象进行标记,从白色置为灰色,并且放入到灰色节点链表中

//标记阶段
当灰色链表中还有未扫描的元素:
	从中取出一个对象并将其标记为黑色
	遍历这个对象关联的其他所有对象:
		如果是白色:
			标记为灰色,加入灰色链表
				
//回收阶段
遍历所有对象:
	如果为白色:
		这些对象都是没有被引用的对象,逐个回收
	否则:
		重新加入对象链表中等待下一轮GC检查	

在保存全局状态的global_State结构体中,有以下几个与GC相关的数据成员。

  • lu_byte currentwhite:存放当前GC的白色。
  • lu_byte gcstate:存放GC状态,分别由以下几种:GCSpause(暂停状态)、GCSpropagate(传播阶段,用于遍历灰色节点检查对象的引用情况)、GCSweepstring(字符串回收阶段)、GCSweep(回收阶段,用于对除了字符串之外的所有其他数据类型进行回收)、GCSfinalize(终止阶段)。
  • int sweepstrgc:字符串回收阶段,每次针对字符串散列桶的一组字符串进行回收,这个值用于记录对应的散列桶索引。
  • GCObject *rootgc:存放待GC对象的链表,所有对象创建之后都会放入该链表中。
  • GCObject **sweepgc:待处理的回收数据都存放在rootgc链表中,由于回收阶段不是一次性全部回收这个链表的所有数据,所以使用这个变量来保存当前回收的位置,下一次从这个位置开始继续回收操作。
  • int sweepstrgc:存放下一个待回收的对象指针。
  • GCObject *gray存放灰色节点的链表
  • GCObject *grayagain存放需要一次性扫描处理的灰色节点链表,也就是说,这个链表上所有数据的处理需要一步到位,不能被打断。
  • GCObjetc *week存放弱表的链表。
  • GCObject *tmudata所有带有GC元方法的udata存放在一个链表中,这个成员指向这个链表的最后一个元素。
  • lu_men GCthreshold:开始进行GC的阈值,当totalbytes大于这个值时开始自动GC
  • lu_men totalbytes:当前分配的内存大小。
  • lu_men estimate:一个估计值,用于保存实际在用的内存大小。
  • lu_men gcdept:用于在单次GC之前保存待回收的数据大小。
  • int gcpause:用于控制下一轮GC开始的时机。
  • int gcstepmul:控制GC的回收速度。

进度控制:

自动回收会在每次调用内存分配相关的操作时检查是否满足触发条件,这个操作在宏luaC_checkGC中进行。触发自动化GC的条件是:totalbytes
(保存当前分配的内存大小)大于等于GCthreshold(一个阈值,这个值可以由一些参数影响和控制,由此改变触发的条件)。

由于自动GC会在使用者不知道的情况下触发,不太可控,因而很多人选择关闭它,具体操作就是通过GCthreshold设置为一个非常大的值来达到一直不满足自动触发条件。

5.3.2 垃圾回收与弱表

存储在全局变量中的对象,即使程序不会再用到它们,但对于Lua来说它们也不是垃圾。

如果要将一些对象放在一个全局数组中,这个对象就无法被回收。如果想让再全局变量中的对象可以被回收,需要使用的机制是弱引用(weak table)。如果一个对象只被一个弱引用table所持有,那么最终Lua是会回收这个对象的。

一共有三种弱引用table:

  1. 具有弱引用key的table
  2. 具有弱引用value的table
  3. 具有弱引用key和弱引用value的table

一个table的弱引用类型是通过其元表中的–mode字段来决定的。这个字段的值应为一个字符串,如果这个字符串中包含字母‘k’,那么这个table的key是弱引用的;如果这个字符串中包含字母‘v’,那么这个table的value是弱引用的。

实例:

a = {}	--存放在_G的表
b = {__mode = "k"}
setmetatable(a, b)	--设置表a的元表是b,b中有__mode字段,包含字母'k',因此表a变成了具有弱引用key的table
key = {}	--创建第一个key
a[key] = 1
key = {}	--创建第二个key
a[key] = 2
collectgarbage()	--强制进行一次垃圾收集
for k, v in pairs(a) do print(v) end

在本例中,第二句赋值key = {}会覆盖第一个key。当收集器运行时,由于没有其他地方在引用第一个key,因此第一个key就被回收了,并且table中的相应条目也被删除了。至于第二个key,变量key仍引用着它,因此它没有被回收。
注意,Lua只回收弱引用table中的对象。而像数字和布尔这样的”值“,是不可回收的。例如,对于一个插入table的数字key,收集器是永远不会删除它的。当然,如果一个数字key所对应的value被回收了,那么整个条目都会从这个弱引用table中删除。

5.3.3 垃圾收集器API

在C语言中,使用lua_gc:

int lua_gc(lua_State *L, int what, int data);

在Lua中,使用collectgarbage:

collectgarbage(what [, data])

what参数:
LUA_GCSTOP("stop"):停止收集器,直到再次以"restart"、"collect"或"step"来调用collectgarbage(或lua_gc)。
LUA_GCRESTART("restart"):重启收集器。
LUA_GCCOLLECT("collect"):执行一轮完整的垃圾收集周期,收集并释放所有不可到达的对象。这是collectgarbage的默认选项。
LUA_GCSTEP("step"):执行一些垃圾收集工作。工作的总量由第二个参数data以一种模糊的方式指定(较大的值表示更多的工作)。
LUA_GCCOUNT("count"):返回Lua当前使用的内存数量,以千字节为单位。这个数字包含了已死亡但尚未回收的对象。
LUA_GCCOUNTB(无对应参数),返回Lua当前使用的内存数量的千字节余数。
LUA_GCSETPAUSE("setpause"):设置收集器的pause参数,其值由data参数指定,表示一个百分比。当data为100时,pause参数设置为1(100%)。
LUA_GCSETSTEPMUL(“setstepmul”):设置收集器的stepmul参数,其值也是由data参数指定,表示一个百分比。

pause参数控制了收集器在完成一轮收集至启动下一轮收集间等待的时间。
stepmul参数控制了收集器的工作速度。

六、项目地址

github:

https://github.com/lironghui233/Luago_VM

gitee:

https://gitee.com/smallppppig/luago_-vm
Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐