Lua是 一门以高效著称的脚本语言,为了达到较高的执行效率,Lua从1.0(1993年发布)开始就内置了虚拟机lvm
。也就是说,Lua脚本并不是直接被Lua解释器解释执行的,而是类似于Java那样,先由Lua编译器编译为字节码ByteCode
,然后交由Lua虚拟机去执行。Lua字节码ByteCode
需要一个载体,这个载体就是二进制chunk
,可以将Lua的二进制chunk
看做Java的class
文件。
chunk
在Lua中一段可以被Lua解释器解释执行的代码叫做chunk
,chunk
可以很小,小到一两条语句。也可以很大,大到包含成千上万语句和复杂的函数定义。为了获得较高的执行效率,Lua并不直接解释执行chunk
,而是先由编译器编译成内部结构,其中包含字节码等信息,然后再由虚拟机执行字节码。这种内部结构在Lua里叫做预编译(Precompiled)chunk
,由于采用了二进制格式,所以也叫做二进制(Binary)chunk
。
Lua程序员一般无需关心二进制chunk
,因为Lua解释器会在内部进行编译。Lua提供了命令行工具luac
,可以把Lua源代码编译成二进制chunk
,并保存成文件,默认文件名为luac.out
。Lua解释器可直接加载并执行二进制chunk
文件。
Lua解释器会在内部编译Lua脚本,所以预编译并不会加快脚本执行的速度,但是预编译可以加快脚本加载的速度,并可以在一定程序上保护源代码。另外luac
还提供了反编译功能,方便查看二进制chunk
内容和Lua虚拟机指令。
luac
luac
命令主要由2个用途:
- 作为编译器,把Lua文件编译成二进制
chunk
文件 - 作为反编译器,分析二进制
chunk
,将信息输出到控制台。
Lua将编译命令和反编译命令整合在一起,在命令行直接执行luac
命令可查看其完整用法。
λ luac
luac: no input files given
usage: luac [options] [filenames].
Available options are:
- process stdin
-l list 查看二进制chunk
-o name output to file 'name' (default is "luac.out") 对输出文件进行明确指定
-p parse only 仅执行解释,即只是检查语法是否正确,不产生输出文件。
-s strip debug information 去掉编译生成的二进制chunk默认包含的调试信息(行号、变量名等)
-v show version information 显示版本信息
-- stop handling options
$ luac test.lua # 生成luac.out
$ luac test.lua -o test.out # 生成test.out
$ luac test.lua -s # 不包含调试信息
$ luac test.lua -p # 只进行语法检查
编译Lua源代码
将一个或多个文件名作为参数调用luac
命令就可以编译指定的Lua源文件,若编译成功则在当前目录下生成luac.out
文件,其中的内容就是对应的二进制chunk
。
Lua编译器工作原理
Lua编译器以函数为单位进行编译,每个函数都会被Lua编译器编译称为一个内部结构,这个结构叫做“原型”(prototype),原型主要包含6部分内容,分别是:
- 函数基本信息:包含参数数量、局部变量数量等
- 字节码
- 常量表
- Upvalue表
- 调试信息
- 子函数原型列表
由此可知,函数原型是一种递归结构,Lua源码中函数的嵌套关系会直接反映在编译后的原型中。
print("hello, world!")
上面仅有一条打印语句,并没有定义函数,那么Lua编译器是怎么编译这个文件的呢?由于Lua是脚本语言,如果每执行一段脚本都必须要定义一个函数,不是很麻烦吗?所以这样吃力不讨好的工作就由Lua编译器代劳了。
Lua编译器会自动为脚本添加一个main
主函数,并将整个程序都放在这个函数里,然后再以它为起点进行编译,那么自然就把整个程序都编译出来了。主函数不仅仅是编译的起点,也是为了Lua虚拟机解释执行程序时的入口。
程序被Lua编译器加工后,会变成如下:
function main(...)
print("hello world!")
return
end
将主函数编译成函数原型后,Lua编译器会给它再添加一个头部Header
,然后一起dump
成为luac.out
文件,这样二进制chunk
文件就产生了。
查看二进制chunk
二进制chunk
之所以使用二进制格式,是为了方便虚拟机加载,然后对人类却不够友好,因为其很难直接阅读。luac
命令兼具编译和反编译功能,使用luac -l
选项可查看二进制chunk
,即luac
反编译器精简模式的输出类型。
$ vim test.lua
-- test.lua
print("hello world")
$ luac test.lua
$ luac -l luac.out
main <test.lua:0,0> (4 instructions, 16 bytes at 006BECA0)
0+ params, 2 slots, 0 upvalues, 0 locals, 2 constants, 0 functions
1 [2] GETGLOBAL 0 -1 ; print
2 [2] LOADK 1 -2 ; "hello world"
3 [2] CALL 0 2 1
4 [2] RETURN 0 1
$ vim test.lua
function foo()
function bar()
end
end
$ luac test.lua
$ luac -l test.out
main <test.lua:0,0> (3 instructions, 12 bytes at 0206ECA0)
0+ params, 2 slots, 0 upvalues, 0 locals, 1 constant, 1 function
1 [4] CLOSURE 0 0 ; 0206EE10
2 [1] SETGLOBAL 0 -1 ; foo
3 [4] RETURN 0 1
function <test.lua:1,4> (3 instructions, 12 bytes at 0206EE10)
0 params, 2 slots, 0 upvalues, 0 locals, 1 constant, 1 function
1 [3] CLOSURE 0 0 ; 0206EE78
2 [2] SETGLOBAL 0 -1 ; bar
3 [4] RETURN 0 1
function <test.lua:2,3> (1 instruction, 4 bytes at 0206EE78)
0 params, 2 slots, 0 upvalues, 0 locals, 0 constants, 0 functions
1 [3] RETURN 0 1
使用luac -l
反编译打印的函数信息包含两个部分:前两行是函数基本信息,后面是指令列表
main <test.lua:0,0> (4 instructions, 16 bytes at 006BECA0)
function <test.lua:1,4> (3 instructions, 12 bytes at 0206EE10)
函数 <源文件名:起始行号,终止行号>(指令数量, 函数地址)
第1行:若以main
开头说明编译器自动生成主函数,若以function
开头则说明是一个普通函数。接着是定义函数的源文件名和函数在文件里的起止行号(对于主函数,起止行号都是0),然后是指令数量和函数地址。
第2行:依次给出函数的固定参数数量(若有+
号表示是一个vararg
函数)、运行函数所必要的寄存器数量、upvalue
数量、局部变量数量、常量数量、子函数数量。
0+ params, 2 slots, 0 upvalues, 0 locals, 1 constant, 1 function
参数数量, 寄存器数量, upvalue数量, 局部变量数量, 常量数量, 子函数数量
指令列表中每条指令都包含指令序号、对应行号、操作码、操作数。分号后面是luac
根据指令操作数生成的注释,以便于理解指令。
指令序号, 对应行号, 操作码, 操作数, 注释
1 [4] CLOSURE 0 0 ; 0206EE10
2 [1] SETGLOBAL 0 -1 ; foo
3 [4] RETURN 0 1
luac
反编译器详细模式输出内容,luac
会将常量表、局部变量表、upvalue
表信息也打印出来。
$ luac -l -l luac.out
main <test.lua:0,0> (3 instructions, 12 bytes at 01FBECA0)
0+ params, 2 slots, 0 upvalues, 0 locals, 1 constant, 1 function
1 [4] CLOSURE 0 0 ; 01FBEE28
2 [1] SETGLOBAL 0 -1 ; foo
3 [4] RETURN 0 1
constants (1) for 01FBECA0:
1 "foo"
locals (0) for 01FBECA0:
upvalues (0) for 01FBECA0:
function <test.lua:1,4> (3 instructions, 12 bytes at 01FBEE28)
0 params, 2 slots, 0 upvalues, 0 locals, 1 constant, 1 function
1 [3] CLOSURE 0 0 ; 01FBEE90
2 [2] SETGLOBAL 0 -1 ; bar
3 [4] RETURN 0 1
constants (1) for 01FBEE28:
1 "bar"
locals (0) for 01FBEE28:
upvalues (0) for 01FBEE28:
function <test.lua:2,3> (1 instruction, 4 bytes at 01FBEE90)
0 params, 2 slots, 0 upvalues, 0 locals, 0 constants, 0 functions
1 [3] RETURN 0 1
constants (0) for 01FBEE90:
locals (0) for 01FBEE90:
upvalues (0) for 01FBEE90:
chunk格式
Lua的二进制chunk
本质上也是一个字节流
- 二进制
chunk
格式属于Lua虚拟机内部实现细节,并未标准化,也没有官方文档说明,一切以Lua官方实现的源码为准。 - 二进制
chunk
格式的设计没有考虑跨平台的需求,对于需要使用一个字节表示的数据,必须要考虑大小端(Endianness)问题。Lua官方实现的做法是编译Lua脚本时,直接按照本机的大小端方式生成二进制chunk
文件,当加载二进制chunk
文件时,会探测被加载文件的大小端方式,如果和本机不匹配就拒绝加载。 - 二进制
chunk
格式的设计没有考虑Lua版本兼容性,Lua官方做法是编译Lua脚本时,直接按照当时的Lua版本生成chunk
文件,当加载二进制chunk
文件时,会检测被加载文件的版本号,如果和当前Lua版本不一致则拒绝加载。 - 二进制
chunk
格式的设计没有刻意设计的很紧凑,在某些情况下一段Lua脚本被编译成二进制chunk
后甚至会比文本形式的源文件还要大。由于把Lua脚本编译成二进制chunk
的主要目的是为了获得更快的加载速度,所以这也不是什么大问题。
数据类型
二进制chunk
本质上来说是一个字节流,一个字节能够表示的信息是非常有限的,如一个ASCII码或一个很小的整数可以放进一个字节内,但是更复杂的信息就必须通过某种编码方式编码成多个字节。在讨论二进制chunk
格式时,称这种被编码为一个或多个字节的信息单位为数据类型。
由于Lua官方实现是使用C语言编写的,所以C语言的一些数据类型会直接反映在二进制chunk
的格式里。二进制chunk
内部使用的数据类型大致分为数字、字符串、列表三种。
- 数字
数字类型主要包括5种:
- 字节:用来存放一些比较小的整数值,比如Lua版本号、函数的参数个数等。
- C语言
cint
整型:主要用来表示列表长度 - C语言
size_t
类型:主要用来表示长字符串长度 - Lua整数:Lua整数和Lua浮点数则主要在常量表里出现,记录Lua脚本中出现的整数和浮点数字面量。
- Lua浮点数
数字类型在二进制chunk
里都按照固定长度存储,除字节类型外,其余4种数字类型都会占用多个字节,具体占用字节数则会及记录在头部中。
- 字符串
字符串在二进制chunk
中其实是一个字节数组,因为字符串长度不固定,所以需要将字节数组的长度也记录到二进制chunk
中。作为优化,字符串类型又可以进一步分为短字符串和长字符串,具体有3种情况:
- 对于
NULL
字符串只用0x00
表示即可 - 对于长度小于等于253
0xFD
的字符串,先使用一个字节记录长度+1,然后是字节数组。 - 对于长度大于等于254
oxFE
的字符串,第一个字节是oxFF
,其后是size_t
记录长度+1,最后是字节数组。
- 列表
在二进制chunk
内部,指令表、常量表、子函数原型表等信息都按照列表的方式存储。即先用一个cint
类型记录列表长度,然后紧接着存储n
个列表元素,至于列表元素如何存储需具体情况具体分析。