本篇文章是Lua設計與實現專欄的第三篇,主要結合了《Lua設計與實現》書中的第五章(虛擬機),以及lua5.3源碼進行一些總結,由於原書中主要是基於lua5.1進行書寫的,所以可能會有跟書中列舉代碼不一致的地方,不過大體上是保持一致的。

同時,本文虛擬機的概念和類型劃分的內容主要參考了這篇blog的,裡面講的挺詳細的。

虛擬機基本概念

虛擬機指藉助軟體系統對物理機器指令執行進行的一種模擬。首先,對於物理機器的執行,主要是機器從內存中fetch指令,通過匯流排傳輸到CPU,然後進行解碼、執行、結果存儲等步驟。既然虛擬機是對其進行的一種模擬,那麼也逃不過以下幾個特點:

  • 將源碼編譯成VM所能執行的位元組碼。
  • 位元組碼格式(指令格式),例如三元式、四元式、波蘭式等。
  • 函數調用的相關棧結構,函數的出入口和傳參方式。
  • 指令指針,類似於物理機的指令寄存器(EIP)。
  • 虛擬CPU。 instruction dispatcher。
    • 取指:通過IP fetch下一條指令
    • 解碼:對指令進行翻譯,得到指令類型,並且解析其操作數。
    • 執行:跳到對應邏輯塊進行執行。

棧式虛擬機和寄存器式虛擬機

雖然虛擬機的實現都逃不過以上幾步,但是以具體實現來看,又分為兩大類:棧式和寄存器式。

棧式虛擬機

採用棧式虛擬機的語言有JVM、CPython以及.Net CLR等。 它的概念很簡單,就是所有的指令執行,都是基於一個操作數棧的。你想要執行任何指令時,對不起,得先入棧,然後算完了再給我出棧。流程如下圖:

總的來說,就是抽象出了一個高度可移植的操作數棧,所有代碼都會被編譯成位元組碼,然後位元組碼就是在玩這個棧。 好處是實現簡單,移植性強。壞處是指令條數比較多,數據轉移次數比較多,因為每一次入棧出棧都牽涉數據的轉移。

寄存器式虛擬機

採用寄存器式的虛擬機有lua和Dalvik等。 這種實現沒有操作數棧這一概念,但是會有許多的虛擬寄存器。這類虛擬寄存器有別於CPU的寄存器,因為CPU寄存器往往是定址的(比如DX本身就是能存東西),而寄存器式的虛擬機中的寄存器通常有兩層含義:(1)寄存器別名(比如lua裏的RA、RB、RC、RBx等),它們往往只是起到一個地址映射的功能,它會根據指令中跟操作數相關的欄位計算出操作數實際的內存地址,從而取出操作數進行計算;(2)實際寄存器,有點類似操作數棧,也是一個全局的運行時棧,只不過這個棧是跟函數走的,一個函數對應一個棧幀,棧幀裏每個slot就是一個寄存器,第1步中通過別名映射後的地址就是每個slot的地址。具體的棧幀可以參考後文講CallInfo時的棧幀圖。 好處是指令條數少,數據轉移次數少。壞處是單挑指令長度較長。

具體來看,lua裏的實際寄存器數組是用TValue結構的棧來模擬的,這個棧也是lua和C進行交互的虛擬棧。 lua裏的位元組碼叫做opcode,本文正文將對"源碼->位元組碼生成->位元組碼執行"這整個流程進行介紹,並對其中的關鍵函數和數據結構進行源碼級別的剖析。

關鍵函數和結構分析

luaL_dofile:包含了luaL_loadfile和lua_pcall兩個步驟,分別對應了函數的解析和執行階段。

luaL_loadfile:會調用具體的parser,對lua文件進行進行詞法和語法分析,把source轉化成opcode,並創建Proto結構保存該opcode和該函數的元信息。 Proto結構如下:

該結構基本涵蓋了parse階段該函數的所有分析信息。主要包括以下幾部分:

  • 常量表。比如在函數裏寫了a = 1 + 2,那這裡的1和2就會放在常量表裡。
  • 局部變數信息。包含了局部變數的名字和它在函數中的生存週期區間(用pc來衡量)。
  • Upvalue信息。包含了該upvalue的名字和它是否歸屬於本函數棧還是外層函數棧的標記。
  • opcode列表。包含了該函數實際調用的所有指令。其實就是一個int32類型的列表,因為lua虛擬機裏每個指令對應一個int32.

lua_pcall:這個函數最終會調到luaD_call,也就是lua虛擬機裏函數執行的主要函數。

從代碼裏可以看出,luaD_call的調用分為兩步:

  • luaD_precall:
    • 如果是C函數或者C閉包,會直接創建單個函數調用的運行時結構CallInfo,來完成函數的進棧和出棧。
    • 如果是lua閉包,在precall中只會做函數調用前的準備工作,實際執行會在後一步luaV_execute中進行。這裡的準備工作主要包括:(1)處理lua的不定長參數、參數數量不夠時的nil填充等。(2)分配CallInfo結構,並填充該函數運行時所需的base、top、opcode等信息,注意CallInfo結構裏還有個很關鍵的func欄位,它指向棧裏對應的LClosure結構,這個結構為虛擬機後續執行提供upvalue表和常量表的查詢,畢竟後續對常量和upvalue的read操作,都是需要把它們從這兩個表中載入到寄存器裏的。

  • luaV_execute:這一步就是我們前面提到的lua虛擬機的CPU了,因為所有指令的實際執行都是在這個函數裏完成的。它做的主要工作,就是在一個大循環裏,不斷的fetch和dispatch指令。每次的fetch就是把pc加1,而dispatch就是一個大的swtich-case,每個不同類型的opcode對應不同的執行邏輯。舉一個創建table的例子:

在該指令中,會首先對32位指令進行位操作,得到該table的初始數組和hash表部分的大小b和c,然後調用luaH_new來創建table,最後根據b和c的值,對table進行resize操作。

另外,前面提到的CallInfo結構,包含了單個函數調用,lua虛擬機所需要的輔助數據結構,它的結構如下:

下圖是lua虛擬機在執行第二個函數時的一個棧示意圖:

我們來看下lua_State裏與之相關的幾個欄位:

  • stack。TValue*類型,記錄了"內存"起始地址。
  • base。TValue*類型,記錄當前函數的第一個參數位置。
  • top。TValue*類型,記錄當前函數的棧頂。
  • base_ci。當前棧裏所有的函數調用CallInfo數組。
  • ci。當前函數的CallInfo。

可以發現,通過這樣的組織結構,luavm可以方便的獲取到任意函數的位置以及其中的所有參數位置。而每個CallInfo裏又記錄了函數的執行pc,因此vm對函數的執行可以說是瞭如指掌了。

指令格式

前文已經提到,lua虛擬機的單條指令長度為32位。其位分佈如下圖所示:

這裡的OpCode,就是指令的類型,由於其只有6位,所有lua最多支持63種指令類型。而對於A、B、C、Bx、sBx等,都是該指令的參數,參數的值通常指的是一個相對偏移,例如相對於當前函數base的偏移,相對於常量表頭的偏移等。另外,根據指令的不同,參數個數和類型也可能不同。來看幾個常用的例子:

  • 從變數賦值:

其實就是簡單的把寄存器RB(i)的值賦值到寄存器RA(i)中去,這裡的寄存器指的就是我們棧裡頭的某個坑位。 所以這裡的RA和RB宏,都是一個棧地址獲取操作,全部的定義如下:

這些宏的內部實現主要分為2步:

    • 通過GETARG_XXX(i)從當前指令中獲取參數XXX的值
    • 用函數base或者常量表base去加這個參數值得到最終的棧(寄存器)地址。

  • 從常量賦值:

與變數賦值唯一的不同,就是RB是基於常量表的偏移。

  • 設置table欄位:

這裡不上代碼了,這裡RK是一個條件宏,因為我有可能是t[a] = b, 也可能是t1 = b,key如果是變數a,說明a肯定是在函數棧裡頭的變數,對應的定址就用RB,而如果key是1,說明它不存在函數棧裡頭,而是在函數常量表裡頭,定址就用KB。

簡單例子

我們結合一個簡單的lua chunk,decompile一下它生成的byte code(這裡decode使用的也是書中介紹的ChunkSpy工具,目前已支持了5.3),從而加深理解:

我們先來看下該chunk對應的函數,在生成的位元組碼裏,稱之為level 1 function:

一個函數最終的位元組碼,基本就包含三塊:

  • 常量表
  • upvalue表
  • code。所有的位元組指令,都是在玩常量表、upvalue表和寄存器棧。可以結合具體的指令來理解。

我們再來看下定義在chunk裏的testFunc函數,它被稱為level 2函數,如果我在testFunc裏還嵌套了子函數,稱為level 3函數,以此類推。該函數的位元組碼與level 1的格式基本一致,這裡就直接上圖,不逐行解釋了:

總結

虛擬機執行流程圖

在梳理完整個lua虛擬機的源碼分析,opcode的生成和執行邏輯以後,我們可以上書中的一個總流程圖來回顧一下:

這個圖中最核心的兩塊,一個是Proto結構,它是分析階段和執行階段的橋樑;另一個是OpCode的執行,這一塊可以結合前面虛擬機概念,以及Stack-based和Register-based VM的區別一起理解,包括但不限於:從CallInfo裏fetch指令,指令執行時的switch case跳轉和操作數的定址,運行時的棧幀佈局,lua_State中的關鍵欄位等等。 本文的總結就到這裡,後面有時間可能會啃一啃書中第六章「指令的解析與執行」,因為這兩章其實聯繫比較緊密,到時候如果有新的收穫也會同步到這邊來。


推薦閱讀:
相關文章