自己动手实现Lua:虚拟机、编译器和标准库(txt+pdf+epub+mobi电子书下载)


发布时间:2020-11-24 00:32:59

点击下载

作者:张秀宏

出版社:机械工业出版社

格式: AZW3, DOCX, EPUB, MOBI, PDF, TXT

自己动手实现Lua:虚拟机、编译器和标准库

自己动手实现Lua:虚拟机、编译器和标准库试读:

前言

为什么编写本书

Lua是一门强大、高效、轻量、可嵌入的脚本语言。Lua语言设计十分精巧,在一个很小的内核上可以支持函数式编程、面向对象编程、元编程等多种编程范式。以本书完稿时的最新版本Lua 5.3.4为例,全部代码(包括Lua虚拟机、编译器、标准库等)仅有2万多行,这其中还包括注释和空行。

Lua语言大约于1993年诞生于巴西PUC-Rio大学,之后在游戏领域大放异彩,被很多游戏客户端选为脚本语言,比如知名游戏《魔兽世界》《模拟城市4》《愤怒的小鸟》等。很多流行的游戏引擎也选择Lua作为脚本语言,比如CryENGINE2、Cocos2d-x及Corona SDK等。另外,也有很多游戏服务端采用C/C++搭配Lua的开发模式。除了游戏领域,Lua语言在其他地方也有很多应用,例如被广泛使用的NoSQL数据库Redis就使用Lua作为脚本语言扩展其能力。

相信自己动手设计并实现一门编程语言是每个程序员都会有的一个梦想,目前国内也已经出版或引进了一些指导读者自己实现编程语言的书籍。不过这些书籍要么只介绍了语言实现环节中的一小部分,无法纵观全局;要么只讨论某种大幅裁减后的简化语言,离真正的工业语言还有一定距离。例如,我自己的《自己动手写Java虚拟机》,只讨论了Java虚拟机实现,没有涉及Java编译器和Java标准库。

如上所述,之所以选择Lua语言,就是因为它足够小巧,并且有很好的流行度。麻雀虽小,五脏俱全,这使得我们可以在一本书的篇幅范围内覆盖虚拟机、编译器、标准库这三个方面的内容。希望读者可以通过本书完整体验一门编程语言的实现过程,为将来打造属于自己的语言做好准备。这正是本书的与众不同之处。

本书主要内容

本书主要内容可以分为四个部分:第一部分(第1章)为准备工作;第二部分(第2~13章)主要讨论Lua虚拟机和Lua API;第三部分(第14~17章)主要讨论Lua语法和编译器,第四部分(第18~21章)主要讨论Lua标准库。

全书共21章,各章内容安排如下:

·第一部分(准备)

·第1章:准备工作。

准备编程环境,编写“Hello,World!”程序。

·第二部分(Lua虚拟机和Lua API)

·第2章:二进制chunk。

介绍Lua二进制chuck文件格式,编写代码解析二进制chunk文件。

·第3章:指令集

介绍Lua虚拟机指令集和指令编码格式,编写代码对指令进行解码。

·第4章:Lua API

初步介绍Lua API和Lua State,实现栈相关API方法。

·第5章:Lua运算符

介绍Lua语言运算符,给Lua API添加运算符相关方法。

·第6章:虚拟机雏形

初步实现Lua虚拟机,可以解释执行大约一半的Lua虚拟机指令。

·第7章:表

介绍并实现Lua表、表相关API方法,以及表相关指令。

·第8章:函数调用

介绍并实现Lua函数调用。

·第9章:Go函数调用

介绍如何在Lua中调用Go语言函数。

·第10章:闭包和Upvalue

介绍并实现闭包和Upvalue,以及Upvalue相关指令。

·第11章:元编程

介绍并实现Lua元表、元方法及元编程。

·第12章:迭代器

介绍并实现Lua迭代器。

·第13章:异常和错误处理

介绍Lua异常和错误处理机制。

·第三部分(Lua语法和编译器)

·第14章:词法分析

介绍Lua语言词法规则,实现词法分析器。

·第15章:抽象语法树

初步介绍Lua语言语法规则,定义抽象语法树。

·第16章:语法分析

进一步介绍Lua语言语法规则,编写语法分析器。

·第17章:代码生成

编写代码生成器。

·第四部分(Lua标准库)

·第18章:辅助API和基础库

介绍Lua辅助API和标准库,实现基础库。

·第19章:工具库

介绍并实现数学、表、字符串、UTF-8、OS等标准库。

·第20章:包和模块

介绍Lua包和模块机制,实现package标准库。

·第21章:协程

介绍Lua协程,实现coroutine标准库。

本书面向的读者

本书假定读者已经了解Go语言和Lua语言,所以不会对这两种语言的语法进行专门介绍。本书使用Go语言实现Lua解释器,但并没有用到特别高深的技术,加之Go语言语法比较简单,相信有C系列语言(比如C、C++、C#、Java等)基础的程序员都可以轻松读懂书中的代码。此外,如果读者更加熟悉Java语言,本书也提供了Java版实现代码。简而言之,本书主要面向以下三类读者:

·对脚本语言实现原理感兴趣的读者。

·对编译原理和高级语言虚拟机感兴趣的读者。

·对Lua语言感兴趣,想探究其内部实现的读者。如何阅读本书

本书内容主要围绕代码对Lua虚拟机、编译器和标准库展开讨论。本书代码经过精心安排,除第1章外,每一章都建立在前一章的基础之上,但每一章又都可以单独编译和运行。建议读者从第1章开始,按顺序阅读本书并学习每一章的代码。但也可以直接跳到感兴趣的章节进行阅读,必要时再阅读其他章节。参考资料

相比C/C++、Java、Python等主流语言,Lua算是较为小众的语言,因此能够找到的介绍其内部实现原理和细节的资料并不多,这也是本书写作的动机之一。除了Lua官方实现的源代码,本书在写作过程中主要参考了下面这些资料:

·《Programming in Lua,Fourth Edition》

·《Lua 5.3Reference Manual》

·《The Evolution of Lua》

·《The Implementation of Lua 5.0》

·《A No-Frills Introduction to Lua 5.1VM Instructions》

·《Lua 5.3Bytecode Reference》

除此之外,笔者在本书的写作过程中还查阅了网络上(特别是StackOverflow和Wikipedia)的各种相关资料,这里就不一一罗列了。如果读者需要了解Go语法和标准库,请访问https://golang.google.cn/。

获取本书源代码

本书源代码可以从https://github.com/zxh0/luago-book获取,代码分为Go、Java和Lua三部分,总体目录结构如下:https://github.com/zxh0/luago-book/code/ go/ ch01/src/luago/ ch02/src/luago/ ... ch21/src/luago/ java/ ch02/ ... ch18/ lua/ ch02/ ... ch21/

其中Go语言部分是Lua解释器实现代码,每章为一个子目录,可以单独编译和运行(详见第1章)。Lua语言部分也是每章一个目录,里面包含每一章的Lua示例代码和测试脚本。Java语言部分是Lua解释器的Java版实现代码,仅供读者参考。Java版实现只提供了前18章的代表,剩下的3章留给读者作为练习。

如果读者对Git比较熟悉,希望每次将注意力集中在某一章的代码上,也可以使用git checkout命令单独检出某一章的代码。本书为每一章都创建了对应的分支,例如,第1章的代码在ch01分支里,以此类推。

勘误和支持

受笔者技术水平和表达能力所限,本书并非尽善尽美,如有不合理之处,恳请读者批评指正。由于时间仓促,书中也难免会存在一些疏漏之处,还请读者谅解。

本书的勘误将通过https://github.com/zxh0/luago-book/blob/master/errata.md发布和更新。如果读者发现书中的错误、有改进意见,或者有任何问题需要和作者讨论,都可以在本书的Github项目上创建Issue。另外,也可以加入QQ群(311942068)和本书作者以及其他读者进行交流。

致谢

首先感谢家人。为了尽快完成这本书,在过去一年多的写作过程中,我不得不在咖啡厅里度过几十个周末。这些时间本应该用来陪伴家人,或者陪孩子玩耍。没有家人的理解和支持,这本书就不可能这么快问世,谢谢你们!

其次要感谢朋友和同事。感谢乐元素CTO凌聪的大力帮助,同时也感谢R大、窦建伟、田生彩、张飞、王世华、杨兵、蔡晓均等阅读本书初稿并提出宝贵意见。特别感谢武岳为本书每一章绘制可爱的鼹鼠图。此外还有许多人也为我写作本书提供了帮助和鼓励,这里无法一一列出,但同样感谢各位。

最后感谢华章公司的各位编辑,你们的认真负责是本书质量的保证。

第一部分 准备

·第1章 准备工作第1章准备工作

子曰:工欲善其事,必先利其器。本书的目标是带领读者从零开始自己动手学会Lua语言,所以我们要做的第一件事就是把开发环境准备好。本章主要分为两部分内容:第一部分介绍读者跟随本书编写代码所需的环境和工具;第二部分介绍本书源代码目录结构。1.1 准备开发环境

读者跟随本书编写代码所需的开发环境非常简单,只需要一台安装着现代操作系统(比如Windows、Linux、macOS等)的电脑,以及文本编辑器、Lua语言编译器和Go语言编译器。1.1.1 操作系统

由于操作系统一般都自带了简单的文本编辑器(比如Windows下的记事本),且Lua语言和Go语言也都是跨平台的,所以读者可以选择自己喜欢的操作系统。不过由于笔者是使用MacBook笔记本编写本书代码和文字的,所以书中出现的命令和路径等都是Unix形式。下面是一个例子。$ ls /dev/*random/dev/random /dev/urandom

命令行以“$”开头,后跟输出结果;路径分隔符是“/”。如果读者使用Windows操作系统进行编写,需要对命令和路径做出相应的调整。另外,虽然任何文本编辑器都可以满足我们的需要,但是最好选择可以对Lua语言和Go语言进行语法着色的编辑器,这里推荐使用Sublime Text。1.1.2 安装Lua

Lua虽然是解释型语言,但实际上Lua解释器会先把Lua脚本编译成字节码,然后在虚拟机中解释执行字节码,这一点和Java语言很像。本书的第一部分(第2~13章)主要围绕Lua字节码的解释执行展开讨论,并初步实现我们自己的Lua虚拟机。在这一部分,我们需要通过官方Lua编译器来将Lua脚本编译成字节码,因此需要安装Lua。在本书的第二部分(第14~17章),我们将实现自己的Lua编译器。

Lua的安装比较简单,读者可以从http://www.lua.org/download.html下载最新版(本书编写时,Lua的最新版本是5.3.4)源代码自行编译,或者直接下载已经编译好的发行版。安装完毕后,在命令行里执行luac-v命令,如果看到类似下面的输出,就表示安装成功了。$ luac -vLua 5.3.4 Copyright (C) 1994-2017 Lua.org, PUC-Rio1.1.3 安装Go

本书将带领读者使用Go语言编写Lua虚拟机、Lua编译器以及Lua标准库,因此需要安装Go。Go的安装也比较简单,读者可以从https://golang.google.cn/dl/下载最新版(本书编写时,Go的最新版本是1.10.2)安装包进行安装。安装完毕后,在命令行里执行go version命令,如果看到类似下面的输出,就表示安装成功了。$ go versiongo version go1.10.2 darwin/amd641.2 准备目录结构

如果读者跟着本书一起编写代码,那么在每一章的最后,都会提供一份完整的源代码,可以独立编译为可执行程序。除第1章之外,每一章的代码都以前一章代码为基础,逐渐添加功能,最终实现一个完整的Lua解释器。建议读者跟随本书的每一章,自己输入代码,循序渐进完成Lua解释器的开发。当然直接从GitHub上下载源代码,只编写自己感兴趣的部分也是完全可以的。

作为开始,我们需要创建一个根目录,然后在里面创建go和lua子目录。其中go目录里存放每一章的Go语言源代码,lua目录里存放每一章的Lua语言示例和测试代码。读者可以在任何位置创建根目录,在本书后面的内容里,我们将使用“$LUAGO”来表示这个目录,出现的路径也都是相对于该目录的相对路径。$LUAGO的目录结构如下所示。$LUAGO/ go/ ch01/src/ ch02/src/ ... lua/ ch01/ ch02/ ...

万事开头难。作为一本介绍编程语言实现的书,按照惯例,当然也要从“Hello,World!”程序开始。请读者打开命令行窗口,执行下面的命令。$ cd $LUAGO/go/$ mkdir -p ch01/src/luago$ export GOPATH=$PWD/ch01

上面的命令创建了本章的目录结构,并且设置好了GOPATH环境变量(关于GOPATH的介绍,请参考https://golang.google.cn/doc/code.html)。请读者在go/ch01/src/luago目录下面创建main.go文件,现在完整的目录结构如下所示。$LUAGO/ go/ch01/src/luago/main.go lua/

打开main.go文件,在里面输入如下代码。package mainfunc main() { println("Hello, World!")}

在命令行里执行下面的命令编译“Hello,World!”程序。$ go install luago

命令执行完毕,如果没有看到任何输出,那么就表示编译成功了。go/ch01/bin目录下会出现luago可执行文件,直接运行就可以看到“Hello,World!”输出。$ ./ch01/bin/luagoHello, World!1.3 本章小结

千里之行,始于足下。本章我们准备好了开发环境,包括操作系统、文本编辑器以及Lua语言和Go语言编译器。我们还创建了代码的目录结构,并且编写和运行了“Hello,World!”程序。从第2章开始,我们将正式进入Lua语言的学习之旅。第二部分Lua虚拟机和Lua API·第2章 二进制chunk·第3章 指令集·第4章 Lua API·第5章 Lua运算符·第6章 虚拟机雏形·第7章 表·第8章 函数调用·第9章 Go函数调用·第10章 闭包和Upvalue·第11章 元编程·第12章 迭代器·第13章 异常和错误处理第2章二进制chunk

Lua是一门以高效著称的脚本语言,为了达到较高的执行效率,Lua从1.0版(1993年发布)开始就内置了虚拟机。也就是说,Lua脚本并不是直接被Lua解释器解释执行,而是类似Java语言那样,先由Lua编译器编译为字节码,然后再交给Lua虚拟机去执行。相比较而言,诞生时间比Lua稍晚一些的脚本语言Ruby在出现以来的很长一段时间里一直是直接解释执行Ruby脚本,直到1.9版(2007年底发布)才引入了YARV虚拟机。

Lua字节码需要一个载体,这个载体就是二进制chunk,对Java虚拟机比较熟悉的读者可以把二进制chunk看作Lua版的class文件。本章会首先对二进制chunk进行一个简单的介绍,然后详细讨论Lua编译器的用法和二进制chunk格式,最后编写代码实现二进制chunk解析,为后续章节做准备。在继续阅读本章内容之前,请读者执行如下命令,把本章所需的目录结构和编译环境准备好。$ cd $LUAGO/go/$ cp -r ch01/ ch02$ mkdir ch02/src/luago/binchunk$ export GOPATH=$PWD/ch02$ mkdir $LUAGO/lua/ch022.1 什么是二进制chunk

在Lua的行话里,一段可以被Lua解释器解释执行的代码就叫作chunk。chunk可以很小,小到只有一两条语句;也可以很大,大到包含成千上万条语句和复杂的函数定义。前面也提到过,为了获得较高的执行效率,Lua并不是直接解释执行chunk,而是先由编译器编译成内部结构(其中包含字节码等信息),然后再由虚拟机执行字节码。这种内部结构在Lua里就叫作预编译(Precompiled)chunk,由于采用了二进制格式,所以也叫二进制(Binary)chunk。

我们仍然以Java虚拟机作为对照,存放chunk的文件(一般以.lua为后缀)对应.java源文件,二进制chunk则对应编译好的class文件。Java的class文件里除了字节码外,还有常量池、行号表等信息,类似地,二进制chunk里也有这些信息。然而和Java不同的是,Lua程序员一般不需要关心二进制chunk,因为Lua解释器会在内部进行编译,如图2-1所示。图2-1 隐式调用Lua编译器

Java提供了命令行工具javac,用来把Java源文件编译成class文件,类似地,Lua也提供了命令行工具luac,可以把Lua源代码编译成二进制chunk,并且保存成文件(默认文件名为luac.out)。Lua解释器可以直接加载并执行二进制chunk文件,如图2-2所示。图2-2 显式调用Lua编译器

如前所述,Lua解释器会在内部编译Lua脚本,所以预编译并不会加快脚本执行的速度,但是预编译可以加快脚本加载的速度,并可以在一定程度上保护源代码。另外,luac还提供了反编译功能,方便我们查看二进制chunk内容和Lua虚拟机指令。下面详细介绍luac的用法。2.2 luac命令介绍

luac命令主要有两个用途:第一,作为编译器,把Lua源文件编译成二进制chunk文件:第二,作为反编译器,分析二进制chunk,将信息输出到控制台。这里仍然以Java为对照,JDK提供了单独的命令行工具javap,用来反编译class文件,而Lua则是将编译命令和反编译命令整合在了一起。在命令行里直接执行luac命令(不带任何参数)可以看到luac命令的完整用法。$ luacluac: no input files givenusage: luac [options] [filenames]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 -- stop handling options - stop handling options and process stdin

本节主要以“Hello,World!”程序为例讨论luac命令的两种用法。请读者在$LUAGO/lua/ch02/目录下创建hello_world.lua文件,并且在里面输入如下代码。print("Hello, World!")

为了便于讨论,我们暂时将当前路径切换到$LUAGO/lua/ch02/目录。$ cd $LUAGO/lua/ch022.2.1 编译Lua源文件

将一个或者多个文件名作为参数调用luac命令就可以编译指定的Lua源文件,如果编译成功,在当前目录下会出现luac.out文件,里面的内容就是对应的二进制chunk。如果不想使用默认的输出文件,可以使用“-o”选项对输出文件进行明确指定。编译生成的二进制chunk默认包含调试信息(行号、变量名等),可以使用“-s”选项告诉luac去掉调试信息。另外,如果仅仅想检查语法是否正确,不想产生输出文件,可以使用“-p”选项进行编译。下面是luac的一些用法示例。$ luac hello_world.lua # 生成luac.out$ luac -o hw.luac hello_world.lua # 生成hw.luac$ luac -s hello_world.lua # 不包含调试信息$ luac -p hello_world.lua # 只进行语法检查

为了方便后面的讨论,本节还会简单介绍一下Lua编译器的内部工作原理,本书第二部分(第14~17章)会详细介绍Lua编译器的实现细节。

Lua编译器以函数为单位进行编译,每一个函数都会被Lua编译器编译为一个内部结构,这个结构叫作“原型”(Prototype)。原型主要包含6部分内容,分别是:函数基本信息(包括参数数量、局部变量数量等)、字节码、常量表、Upvalue表、调式信息、子函数原型列表。由此可知,函数原型是一种递归结构,并且Lua源码中函数的嵌套关系会直接反映在编译后的原型里。

细心的读者一定会想到这样一个问题:前面我们写的“Hello,World!”程序里面只有一条打印语句,并没有定义函数,那么Lua编译器是怎么编译这个文件的呢?由于Lua是脚本语言,如果我们每执行一段脚本都必须要定义一个函数(就像Java那样),岂不是很麻烦?所以这个吃力不讨好的工作就由Lua编译器代劳了。

Lua编译器会自动为我们的脚本添加一个main函数(后文称其为主函数),并且把整个程序都放进这个函数里,然后再以它为起点进行编译,那么自然就把整个程序都编译出来了。这个主函数不仅是编译的起点,也是未来Lua虚拟机解释执行程序时的入口。我们写的“Hello,World!”程序被Lua编译器加工之后,就变成了下面这个样子。function main(...) print("Hello, World!") returnend

把主函数编译成函数原型后,Lua编译器会给它再添加一个头部(Header,详见2.3.3节),然后一起dump成luac.out文件,这样,一份热乎的二进制chunk文件就新鲜出炉了。综上所述,函数原型和二进制chunk的内部结构如图2-3所示。图2-3 二进制chunk内部结构2.2.2 查看二进制chunk

二进制chunk之所以使用二进制格式,是为了方便虚拟机加载,然而对人类却不够友好,因为其很难直接阅读。如前所述,luac命令兼具编译和反编译功能,使用“-l”选项可以将luac切换到反编译模式。正如javap命令是查看class文件的利器,luac命令搭配“-l”选项则是查看二进制chunk的利器。本节的目标是学会阅读luac的反编译输出。在2.3节,我们将深入到二进制chunk的内部来研究其格式。

以前面编译出来的hello_world.luac文件为例,其反编译输出如下。$ luac -l hello_world.luacmain (4 instructions at 0x7fb4dbc030f0)0+ params, 2 slots, 1 upvalue, 0 locals, 2 constants, 0 functions 1 [1]GETTABUP 0 0 -1 ; _ENV "print" 2 [1]LOADK 1 -2 ; "Hello, World!" 3 [1]CALL 0 2 1 4 [1]RETURN 0 1

上面的例子以二进制chunk文件为参数,实际上也可以直接以Lua源文件为参数,luac会先编译源文件,生成二进制chunk文件,然后再进行反编译,产生输出。由于“Hello,World!”程序只有一条打印语句,所以编译出来的二进制chunk里也只有一个主函数原型(没有子函数),因此反编译输出里也只有主函数信息。如果我们的Lua程序里有函数定义,那么luac反编译器会按顺序依次输出这些函数原型的信息,例如如下的Lua程序(请读者将其保存在$LUAGO/lua/ch02/foo_bar.lua文件中)。function foo() function bar() endend

反编译输出中会依次包含main、foo和bar函数的信息,如下所示。$ luac -l foo_bar.lua main (3 instructions at 0x7fc43fc02b20)0+ params, 2 slots, 1 upvalue, 0 locals, 1 constant, 1 function 1 [4] CLOSURE 0 0 ; 0x7fc43fc02cc0 2 [1] SETTABUP 0 -1 0 ; _ENV "foo" 3 [4] RETURN 0 1function (3 instructions at 0x7fc43fc02cc0)0 params, 2 slots, 1 upvalue, 0 locals, 1 constant, 1 function 1 [3] CLOSURE 0 0 ; 0x7fc43fc02e40 2 [2] SETTABUP 0 -1 0 ; _ENV "bar" 3 [4] RETURN 0 1function (1 instruction at 0x7fc43fc02e40)0 params, 2 slots, 0 upvalues, 0 locals, 0 constants, 0 functions 1 [3] RETURN 0 1

反编译打印出的函数信息包含两个部分:前面两行是函数基本信息,后面是指令列表。

第一行如果以main开头,说明这是编译器为我们生成的主函数;以function开头,说明这是一个普通函数。接着是定义函数的源文件名和函数在文件里的起止行号(对于主函数,起止行号都是0),然后是指令数量和函数地址。

第二行依次给出函数的固定参数数量(如果有+号,表示这是一个vararg函数)、运行函数所必要的寄存器数量、upvalue数量、局部变量数量、常量数量、子函数数量。如果读者看不懂这些信息也没有关系,我们在后面的章节中会陆续介绍这些信息。

指令列表里的每一条指令都包含指令序号、对应行号、操作码和操作数。分号后面是luac根据指令操作数生成的注释,以便于我们理解指令。第3章会详细介绍Lua虚拟机指令。

以上看到的是luac反编译器精简模式的输出内容,如果使用两个“-l”选项,则可以进入详细模式,这样,luac会把常量表、局部变量表和upvalue表的信息也打印出来。$ luac -l -l hello_world.luamain (4 instructions at 0x7fbcb5401c00)0+ params, 2 slots, 1 upvalue, 0 locals, 2 constants, 0 functions 1 [1] GETTABUP 0 0 -1 ; _ENV "print" 2 [1] LOADK 1 -2 ; "Hello, World!" 3 [1] CALL 0 2 1 4 [1] RETURN 0 1constants (2) for 0x7fbcb5401c00: 1 "print" 2 "Hello, World!"locals (0) for 0x7fbcb5401c00:upvalues (1) for 0x7fbcb5401c00: 0 _ENV 1 0

到这里luac命令反编译模式的基本用法和阅读方法就介绍完毕了,如果读者觉得一头雾水也不要担心,暂时只要对二进制chunk有一个粗略的认识就可以了,在2.3节我们会详细地讨论二进制chunk格式。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 binchunktype 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.luac00000000: 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...........(w00000020: 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..H00000060: 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.LUAC_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...........(w00000020: 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值

试读结束[说明:试读内容隐藏了图片]

下载完整电子书


相关推荐

最新文章


© 2020 txtepub下载