- 自己動手實現Lua:虛擬機、編譯器和標準庫
- 張秀宏
- 6557字
- 2019-01-03 15:00:08
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結構體和它的方法。
- Data Visualization with D3 4.x Cookbook(Second Edition)
- 極簡算法史:從數學到機器的故事
- Apache Oozie Essentials
- JavaScript:Functional Programming for JavaScript Developers
- 單片機C語言程序設計實訓100例:基于STC8051+Proteus仿真與實戰
- C#編程入門指南(上下冊)
- Web交互界面設計與制作(微課版)
- Symfony2 Essentials
- Unity 2D Game Development Cookbook
- C++從入門到精通(第5版)
- Mastering C++ Multithreading
- SQL Server 2008 R2數據庫技術及應用(第3版)
- Troubleshooting Citrix XenApp?
- QPanda量子計算編程
- SignalR:Real-time Application Development(Second Edition)