自己动手实现Lua:虚拟机、编译器和标准库
上QQ阅读APP看书,第一时间看更新

2.3 二进制chunk格式

和Java的class文件类似,Lua的二进制chunk本质上也是一个字节流。不过class文件的格式设计相当紧凑,并且在Java虚拟机规范里给出了严格的规定,二进制chunk则不然。

1)二进制chunk格式(包括Lua虚拟机指令)属于Lua虚拟机内部实现细节,并没有标准化,也没有任何官方文档对其进行说明,一切以Lua官方实现的源代码为准。在写作本书的过程中,笔者参考了一些关于二进制chunk格式和Lua虚拟机指令的非官方说明文档,具体见本书参考资料。

2)二进制chunk格式的设计没有考虑跨平台的需求。对于需要使用超过一个字节表示的数据,必须要考虑大小端(Endianness)问题。Lua官方实现的做法比较简单:编译Lua脚本时,直接按照本机的大小端方式生成二进制chunk文件,当加载二进制chunk文件时,会探测被加载文件的大小端方式,如果和本机不匹配,就拒绝加载。

3)二进制chunk格式的设计也没有考虑不同Lua版本之间的兼容问题。和大小端问题一样,Lua官方实现的做法也比较简单:编译Lua脚本时,直接按照当时的Lua版本生成二进制chunk文件,当加载二进制chunk文件时,会检测被加载文件的版本号,如果和当前Lua版本不匹配,则拒绝加载。

4)二进制chunk格式并没有被刻意设计得很紧凑。在某些情况下,一段Lua脚本被编译成二进制chunk之后,甚至会比文本形式的源文件还要大。不过如前所述,由于把Lua脚本预编译成二进制chunk的主要目的是为了获得更快的加载速度,所以这也不是什么大问题。

本节主要讨论二进制chunk格式与如何将其编码成Go语言结构体。在2.4节,我们会进一步编写二进制chunk解析代码。

2.3.1 数据类型

前文提到过,二进制chunk本质上来说是一个字节流。大家都知道,一个字节能够表示的信息是非常有限的,比如说一个ASCII码或者一个很小的整数可以放进一个字节内,但是更复杂的信息就必须通过某种编码方式编码成多个字节。在讨论二进制chunk格式时,我们称这种被编码为一个或多个字节的信息单位为数据类型。请读者注意,由于Lua官方实现是用C语言编写的,所以C语言的一些数据类型(比如size_t)会直接反映在二进制chunk的格式里,千万不要将这两个概念混淆。

二进制chunk内部使用的数据类型大致可以分为数字、字符串和列表三种。

1.数字

数字类型主要包括字节、C语言整型(后文简称cint)、C语言size_t类型(简称size_t)、Lua整数、Lua浮点数五种。其中,字节类型用来存放一些比较小的整数值,比如Lua版本号、函数的参数个数等;cint类型主要用来表示列表长度;size_t则主要用来表示长字符串长度;Lua整数和Lua浮点数则主要在常量表里出现,记录Lua脚本中出现的整数和浮点数字面量。

数字类型在二进制chunk里都按照固定长度存储。除字节类型外,其余四种数字类型都会占用多个字节,具体占用几个字节则会记录在头部里,详见2.3.3节。表2-1列出了二进制chunk整数类型在Lua官方实现(64位平台)里对应的C语言类型、在本书中使用的Go语言类型,以及占用的字节数。

表2-1 二进制chunk整数类型

2.字符串

字符串在二进制chunk里,其实就是一个字节数组。因为字符串长度是不固定的,所以需要把字节数组的长度也记录到二进制chunk里。作为优化,字符串类型又可以进一步分为短字符串和长字符串两种,具体有三种情况:

1)对于NULL字符串,只用0x00表示就可以了。

2)对于长度小于等于253(0xFD)的字符串,先使用一个字节记录长度+1,然后是字节数组。

3)对于长度大于等于254(0xFE)的字符串,第一个字节是0xFF,后面跟一个size_t记录长度+1,最后是字节数组。

上述三种情况如图2-4所示。

图2-4 字符串存储格式

3.列表

在二进制chunk内部,指令表、常量表、子函数原型表等信息都是按照列表的方式存储的。具体来说也很简单,先用一个cint类型记录列表长度,然后紧接着存储n个列表元素,至于列表元素如何存储那就要具体情况具体分析了,我们在2.3.4节会详细讨论。

2.3.2 总体结构

总体而言,二进制chunk分为头部和主函数原型两部分。请读者在$LUAGO/go/ch02/src/luago/binchunk目录下创建binary_chunk.go文件,在里面定义binaryChunk结构体,代码如下所示。

        package binchunk

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

可以看到,头部和主函数原型之间,还有一个单字节字段“sizeUpvalues”。到这里,读者只要知道二进制chunk里有这么一个用于记录主函数upvalue数量的字段就可以了,在第10章我们会详细讨论闭包和upvalue。

2.3.3 头部

头部总共占用约30个字节(因平台而异),其中包含签名、版本号、格式号、各种整数类型占用的字节数,以及大小端和浮点数格式识别信息等。请读者在binary_chunk.go文件里定义header结构体,代码如下所示。

        type header struct {
            signature        [4]byte
            version           byte
            format            byte
            luacData          [6]byte
            cintSize          byte
            sizetSize        byte
            instructionSize byte
            luaIntegerSize  byte
            luaNumberSize    byte
            luacInt           int64
            luacNum           float64
        }

下面详细介绍每一个字段的含义。

1.签名

很多二进制格式都会以固定的魔数(Magic Number)开始,比如Java的class文件,魔数是四字节0xCAFEBABE。Lua二进制chunk的魔数(又叫作签名,Signature)也是四个字节,分别是ESC、L、u、a的ASCII码。用十六进制表示是0x1B4C7561,写成Go语言字符串字面量是"\x1bLua"。

魔数主要起快速识别文件格式的作用。如果Lua虚拟机试图加载一个“号称”二进制chunk的文件,并发现其并非是以0x1B4C7561开头,就会拒绝加载该文件。用xxd命令观察一下hello_world.luac文件,可以看到,开头四个字节确实是0x1B4C7561,如下所示。

        $ xxd -u -g 1 hello_world.luac
        00000000: 1B 4C 75 61 53 00 19 93 0D 0A 1A 0A 04 08 04 08  .LuaS...........
        00000010: 08 78 56 00 00 00 00 00 00 00 00 00 00 00 28 77  .xV...........(w
        00000020: 40 01 11 40 68 65 6C 6C 6F 5F 77 6F 72 6C 64 2E  @..@hello_world.
        00000030: 6C 75 61 00 00 00 00 00 00 00 00 00 01 02 04 00  lua.............
        00000040: 00 00 06 00 40 00 41 40 00 00 24 40 00 01 26 00  ....@.A@..$@..&.
        00000050: 80 00 02 00 00 00 04 06 70 72 69 6E 74 04 0E 48  ........print..H
        00000060: 65 6C 6C 6F 2C 20 57 6F 72 6C 64 21 01 00 00 00  ello, World! ....
        00000070: 01 00 00 00 00 00 04 00 00 00 01 00 00 00 01 00  ................
        00000080: 00 00 01 00 00 00 01 00 00 00 00 00 00 00 01 00  ................
        00000090: 00 00 05 5F 45 4E 56                                   ..._ENV
2.版本号

签名之后的一个字节,记录二进制chunk文件所对应的Lua版本号。Lua语言的版本号由三个部分构成:大版本号(Major Version)、小版本号(Minor Version)、发布号(Release Version)。比如Lua的当前版本是5.3.4,其中大版本号是5,小版本号是3,发布号是4。

二进制chunk里存放的版本号是根据Lua大小版本号算出来的,其值等于大版本号乘以16加小版本号,之所以没有考虑发布号是因为发布号的增加仅仅意味着bug修复,并不会对二进制chunk格式进行任何调整。Lua虚拟机在加载二进制chunk时,会检查其版本号,如果和虚拟机本身的版本号不匹配,就拒绝加载该文件。

笔者在前面是用5.3.4版luac编译hello_world.lua文件的,因此二进制chunk里的版本号应该是5×16 + 3 = 83,用十六进制表示正好是0x53,如下所示。

        00000000: 1B 4C 75 61 53 00 19 93 0D 0A 1A 0A 04 08 04 08  .LuaS...........
3.格式号

版本号之后的一个字节记录二进制chunk格式号。Lua虚拟机在加载二进制chunk时,会检查其格式号,如果和虚拟机本身的格式号不匹配,就拒绝加载该文件。Lua官方实现使用的格式号是0,如下所示。

        00000000: 1B 4C 75 61 53 00 19 93 0D 0A 1A 0A 04 08 04 08  .LuaS...........
4. LUAC_DATA

格式号之后的6个字节在Lua官方实现里叫作LUAC_DATA。其中前两个字节是0x1993,这是Lua 1.0发布的年份;后四个字节依次是回车符(0x0D)、换行符(0x0A)、替换符(0x1A)和另一个换行符,写成Go语言字面量的话,结果如下所示。

        "\x19\x93\r\n\x1a\n":
        00000000: 1B 4C 75 61 53 00 19 93 0D 0A 1A 0A 04 08 04 08  .LuaS...........

这6个字节主要起进一步校验的作用。如果Lua虚拟机在加载二进制chunk时发现这6个字节和预期的不一样,就会认为文件已经损坏,拒绝加载。

5.整数和Lua虚拟机指令宽度

接下来的5个字节分别记录cint、size_t、Lua虚拟机指令、Lua整数和Lua浮点数这5种数据类型在二进制chunk里占用的字节数。在笔者的机器上,cint和Lua虚拟机指令各占用4个字节,size_t、Lua整数和Lua浮点数则各占用8个字节,如下所示。

        00000000: 1B 4C 75 61 53 00 19 93 0D 0A 1A 0A 04 08 04 08  .LuaS...........
        00000010: 08 78 56 00 00 00 00 00 00 00 00 00 00 00 28 77  .xV...........(w

Lua虚拟机在加载二进制chunk时,会检查上述5种数据类型所占用的字节数,如果和期望数值不匹配则拒绝加载。

6. LUAC_INT

接下来的n个字节存放Lua整数值0x5678。如前文所述,在笔者的机器上Lua整数占8个字节,所以这里n等于8。

        00000000: 1B 4C 75 61 53 00 19 93 0D 0A 1A 0A 04 08 04 08  .LuaS...........
        00000010: 08 78 56 00 00 00 00 00 00 00 00 00 00 00 28 77  .xV...........(w

存储这个Lua整数的目的是为了检测二进制chunk的大小端方式。Lua虚拟机在加载二进制chunk时,会利用这个数据检查其大小端方式和本机是否匹配,如果不匹配,则拒绝加载。可以看出,在笔者的机器上(内部是Intel CPU),二进制chunk是小端方式。

7. L UAC_NUM

头部的最后n个字节存放Lua浮点数370.5。如前文所述,在笔者的机器上Lua浮点数占8个字节,所以这里n等于8。

        00000000: 1B 4C 75 61 53 00 19 93 0D 0A 1A 0A 04 08 04 08  .LuaS...........
        00000010: 08 78 56 00 00 00 00 00 00 00 00 00 00 00 28 77  .xV...........(w
        00000020: 40 01 11 40 68 65 6C 6C 6F 5F 77 6F 72 6C 64 2E  @..@hello_world.

存储这个Lua浮点数的目的是为了检测二进制chunk所使用的浮点数格式。Lua虚拟机在加载二进制chunk时,会利用这个数据检查其浮点数格式和本机是否匹配,如果不匹配,则拒绝加载。目前主流的平台和语言一般都采用IEEE 754浮点数格式。

到此为止,二进制chunk头部就介绍完毕了,二进制chunk的整体格式如图2-5所示。

图2-5 二进制chunk存储格式

请读者打开binary_chunk.go文件,在里面定义相关常量,代码如下所示。

        const (
            LUA_SIGNATURE     = "\x1bLua"
            LUAC_VERSION      = 0x53
            LUAC_FORMAT       = 0
            LUAC_DATA          = "\x19\x93\r\n\x1a\n"
            CINT_SIZE          = 4
            CSZIET_SIZE       = 8
            INSTRUCTION_SIZE = 4
            LUA_INTEGER_SIZE = 8
            LUA_NUMBER_SIZE  = 8
            LUAC_INT           = 0x5678
            LUAC_NUM           = 370.5
        )

2.3.4 函数原型

由2.1节可知,函数原型主要包含函数基本信息、指令表、常量表、upvalue表、子函数原型表以及调试信息;基本信息又包括源文件名、起止行号、固定参数个数、是否是vararg函数以及运行函数所必要的寄存器数量;调试信息又包括行号表、局部变量表以及upvalue名列表。

请读者在binary_chunk.go文件里定义Prototype结构体,代码如下所示。

        type Prototype struct {
            Source            string
            LineDefined       uint32
            LastLineDefined  uint32
            NumParams        byte
            IsVararg          byte
            MaxStackSize     byte
            Code              []uint32
            Constants        []interface{}
            Upvalues          []Upvalue
            Protos            []*Prototype
            LineInfo          []uint32
            LocVars           []LocVar
            UpvalueNames     []string
        }

函数原型的整体格式如图2-6所示,接下来将详细介绍每一个字段的含义。

图2-6 函数原型存储格式

1.源文件名

函数原型的第一个字段存放源文件名,记录二进制chunk是由哪个源文件编译出来的。为了避免重复,只有在主函数原型里,该字段才真正有值,在其他嵌套的函数原型里,该字段存放空字符串。和调试信息一样,源文件名也不是执行函数所必需的信息。如果使用“-s”选项编译,源文件名会连同其他调试信息一起被Lua编译器从二进制chunk里去掉。我们继续观察hello_world.luac文件。

        00000000: 1B 4C 75 61 53 00 19 93 0D 0A 1A 0A 04 08 04 08  .LuaS...........
        00000020: 40 01 11 40 68 65 6C 6C 6F 5F 77 6F 72 6C 64 2E  @..@hello_world.
        00000030: 6C 75 61 00 00 00 00 00 00 00 00 00 01 02 04 00  lua.............

可以看到,由于文件名比较短,所以是以短字符串形式存储的。其长度+1占用一个字节,内容是十六进制0x11,转换成十进制再减去一,结果就是16。长度之后存放的是@hello_world.lua,刚好占用16个字节。细心的读者会有疑问,文件名里的“@”符号是从哪里来的呢?

实际上,我们前面的描述并不准确。函数原型里存放的源文件名,准确来说应该是指函数的来源,如果来源以“@”开头,说明这个二进制chunk的确是从Lua源文件编译而来的;去掉“@”符号之后,得到的才是真正的文件名。如果来源以“=”开头则有特殊含义,比如“=stdin”说明这个二进制chunk是从标准输入编译而来的;若没有“=”,则说明该二进制chunk是从程序提供的字符串编译而来的,来源存放的就是该字符串。为了便于描述,在不引起混淆的前提下,我们后面仍将各种类型的来源统称为源文件。

2.起止行号

跟在源文件名后面的是两个cint型整数,用于记录原型对应的函数在源文件中的起止行号。如果是普通的函数,起止行号都应该大于0;如果是主函数,则起止行号都是0,如下所示。

        00000020: 40 01 11 40 68 65 6C 6C 6F 5F 77 6F 72 6C 64 2E  @..@hello_world.
        00000030: 6C 75 61 00 00 00 00 00 00 00 00 00 01 02 04 00  lua.............
3.固定参数个数

起止行号之后的一个字节记录了函数固定参数个数。这里的固定参数,是相对于变长参数(Vararg)而言的,我们在第8章会详细讨论Lua函数调用和变长参数。Lua编译器为我们生成的主函数没有固定参数,因此这个值是0,如下所示。

        00000020: 40 01 11 40 68 65 6C 6C 6F 5F 77 6F 72 6C 64 2E  @..@hello_world.
        00000030: 6C 75 61 00 00 00 00 00 00 00 00 00 01 02 04 00  lua.............
4.是否是Vararg函数

接下来的一个字节用来记录函数是否为Vararg函数,即是否有变长参数(详见第8章)。0代表否,1代表是。主函数是Vararg函数,有变长参数,因此这个值为1,如下所示。

        00000020: 40 01 11 40 68 65 6C 6C 6F 5F 77 6F 72 6C 64 2E  @..@hello_world.
        00000030: 6C 75 61 00 00 00 00 00 00 00 00 00 01 02 04 00  lua.............
5.寄存器数量

在记录过函数是否是Vararg函数之后的一个字节记录的是寄存器数量。Lua编译器会为每一个Lua函数生成一个指令表,也就是我们常说的字节码。由于Lua虚拟机是基于寄存器的虚拟机(详见第3章),大部分指令也都会涉及虚拟寄存器操作,那么一个函数在执行期间至少需要用到多少个虚拟寄存器呢?Lua编译器会在编译函数时将这个数量计算好,并以字节类型保存在函数原型里。运行“Hello, World! ”程序需要2个虚拟寄存器,如下所示。

        00000020: 40 01 11 40 68 65 6C 6C 6F 5F 77 6F 72 6C 64 2E  @..@hello_world.
        00000030: 6C 75 61 00 00 00 00 00 00 00 00 00 01 02 04 00  lua.............

这个字段也被叫作MaxStackSize,为什么这样叫呢?这是因为Lua虚拟机在执行函数时,真正使用的其实是一种栈结构,这种栈结构除了可以进行常规地推入和弹出操作以外,还可以按索引访问,所以可以用来模拟寄存器。我们在第4章会详细讨论这种栈结构。

6.指令表

函数基本信息之后是指令表。本章我们只要知道每条指令占4个字节就可以了,第3章会详细介绍Lua虚拟机指令格式。“Hello, World! ”程序主函数有4条指令,如下所示。

        00000020: 40 01 11 40 68 65 6C 6C 6F 5F 77 6F 72 6C 64 2E  @..@hello_world.
        00000030: 6C 75 61 00 00 00 00 00 00 00 00 00 01 02 04 00  lua.............
        00000040: 00 00 06 00 40 00 41 40 00 00 24 40 00 01 26 00  ....@.A@..$@..&.
        00000050: 80 00 02 00 00 00 04 06 70 72 69 6E 74 04 0E 48  ........print..H
7.常量表

指令表之后是常量表。常量表用于存放Lua代码里出现的字面量,包括nil、布尔值、整数、浮点数和字符串五种。每个常量都以1字节tag开头,用来标识后续存储的是哪种类型的常量值。常量tag值、Lua字面量类型以及常量值存储类型之间的对应关系见表2-2。

表2-2 二进制chunk常量tag值

“Hello, World! ”程序主函数常量表里有2个字符串常量,如下所示。

        00000040: 00 00 06 00 40 00 41 40 00 00 24 40 00 01 26 00  ....@.A@..$@..&.
        00000050: 80 00 02 00 00 00 04 06 70 72 69 6E 74 04 0E 48  ........print..H
        00000060: 65 6C 6C 6F 2C 20 57 6F 72 6C 64 21 01 00 00 00  ello, World! ....

请读者在binary_chunk.go文件里定义tag值常量,代码如下所示。

        const (
            TAG_NIL        = 0x00
            TAG_BOOLEAN    = 0x01
            TAG_NUMBER     = 0x03
            TAG_INTEGER    = 0x13
            TAG_SHORT_STR = 0x04
            TAG_LONG_STR  = 0x14
        )

在C语言里,可以使用联合体(Union)把不同的数据类型统一起来。Go语言不支持联合体,但是使用空接口可以达到同样的目的,这一技巧会在本书中多次使用。当某个变量(或者结构体字段、数组元素等)需要容纳不同类型的值时,我们就把它定义为空接口类型。

8. Upvalue表

常量表之后是Upvalue表。在本章我们只要了解该表的每个元素占用2个字节就可以了,第10章会详细介绍闭包和Upvalue。请读者在binary_chunk.go文件里定义Upvalue结构体,代码如下所示。

        type Upvalue struct {
            Instack byte
            Idx      byte
        }

“Hello, World! ”程序主函数有一个Upvalue,如下所示。

        00000050: 80 00 02 00 00 00 04 06 70 72 69 6E 74 04 0E 48  ........print..H
        00000060: 65 6C 6C 6F 2C 20 57 6F 72 6C 64 21 01 00 00 00  ello, World! ....
        00000070: 01 00 00 00 00 00 04 00 00 00 01 00 00 00 01 00  ................
9.子函数原型表

Upvalue表之后是子函数原型表。“Hello, World! ”程序只有一条打印语句,没有定义函数,所以主函数原型的子函数原型表长度为0,如下所示。

        00000060: 65 6C 6C 6F 2C 20 57 6F 72 6C 64 21 01 00 00 00  ello, World! ....
        00000070: 01 00 00 00 00 00 04 00 00 00 01 00 00 00 01 00  ................
10.行号表

子函数原型表之后是行号表,其中行号按cint类型存储。行号表中的行号和指令表中的指令一一对应,分别记录每条指令在源代码中对应的行号。由前文可知,“Hello, World! ”程序主函数一共有4条指令,这4条指令对应的行号都是1,如下所示。

        00000060: 65 6C 6C 6F 2C 20 57 6F 72 6C 64 21 01 00 00 00  ello, World! ....
        00000070: 01 00 00 00 00 00 04 00 00 00 01 00 00 00 01 00  ................
        00000080: 00 00 01 00 00 00 01 00 00 00 00 00 00 00 01 00  ................
11.局部变量表

行号表之后是局部变量表,用于记录局部变量名,表中每个元素都包含变量名(按字符串类型存储)和起止指令索引(按cint类型存储)。请读者在binary_chunk.go文件里定义LocVar结构体,代码如下所示。

        type LocVar struct {
            VarName string
            StartPC uint32
            EndPC    uint32
        }

“Hello, World! ”程序没有使用局部变量,所以主函数原型局部变量表长度为0,如下所示。

        00000070: 01 00 00 00 00 00 04 00 00 00 01 00 00 00 01 00  ................
        00000080: 00 00 01 00 00 00 01 00 00 00 00 00 00 00 01 00  ................
12. Upvalue名列表

函数原型的最后一部分内容是Upvalue名列表。该列表中的元素(按字符串类型存储)和前面Upvalue表中的元素一一对应,分别记录每个Upvalue在源代码中的名字。“Hello, World! ”程序主函数使用了一个Upvalue,名为“_ENV”,如下所示。

        00000080: 00 00 01 00 00 00 01 00 00 00 00 00 00 00 01 00  ................
        00000090: 00 00 05 5F 45 4E 56                                   ..._ENV

这个名为“_ENV”的神秘Upvalue到底是什么来头呢?请读者耐心阅读本书,到了第10章一切就会真相大白。行号表、局部变量表和Upvalue名列表,这三个表里存储的都是调试信息,对于程序的执行并不必要。如果在编译Lua脚本时指定了“-s”选项,Lua编译器就会在二进制chunk中把这三个表清空。

13. Undump()函数

到此为止,整个二进制chunk格式就都已经介绍完毕了,我们也定义好了Prototype等结构体以及头部和常量表相关的常量。在本节的最后,请读者在binary_chunk.go文件末尾加一个Undump()函数,用于解析二进制chunk,代码如下所示。

        func Undump(data []byte) *Prototype {
            reader := &reader{data}
            reader.checkHeader()          // 校验头部
            reader.readByte()              // 跳过Upvalue数量
            return reader.readProto("")   // 读取函数原型
        }

可以看出,Undump()函数把具体的解析工作交给了reader结构体。由于头部在后续的函数执行中并没有太大用处,所以我们只是利用它对二进制chunk格式进行校验。主函数Upvalue数量从主函数原型里也是可以拿到的,所以暂时先跳过这个字段。接下来我们讨论reader结构体和它的方法。