官术网_书友最值得收藏!

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結構體和它的方法。

主站蜘蛛池模板: 英吉沙县| 永济市| 文成县| 正镶白旗| 米脂县| 霍林郭勒市| 抚州市| 凭祥市| 大渡口区| 会昌县| 离岛区| 金门县| 沧州市| 集安市| 武威市| 辽中县| 日喀则市| 陆良县| 神池县| 红安县| 汝州市| 香港 | 邵武市| 秭归县| 潞西市| 宣汉县| 怀仁县| 双鸭山市| 太保市| 密云县| 乐安县| 乐清市| 黎平县| 南陵县| 正蓝旗| 彭阳县| 罗定市| 开原市| 馆陶县| 诏安县| 丰原市|