歡迎大家前往騰訊雲+社區,獲取更多騰訊海量技術實踐干貨哦~
作者:鄭小輝 | 騰訊 游戲客戶端開發高級工程師
寫在前面:本文所有的文字都是我手工一個一個敲的,以及本文后面分享的Demo代碼都是我一行一行碼的,在我之前已經有非常多的前輩研究過Lua虛擬機了,所以本文很多思想必然是踏在這些巨人的肩膀上的。
本文標題是”深入淺出Lua虛擬機”,其實重點在淺出這兩字上。畢竟作者的技術水平有限。但是聽說名字要起的屌一點文章才有人看,故而得名。
謹以此文奉獻給那些對Lua虛擬機有興趣的人。希望本文能達到一個拋磚引玉的效果。
Lua的執行流程:
Lua代碼的整個流程:
如下圖所示:程序員編碼lua文件->語法詞法分析生成Lua的字節碼文件(對應Lua工具鏈的Luac.exe)->Lua虛擬機解析字節碼,並執行其中的指令集->輸出結果。
藍色和綠色的部分是本文所試圖去講的內容。
詞法語法分析:
我不准備講Lua的所有詞法分析過程,畢竟如果浪費太多時間來寫這個的話一會策划同學要提刀來問我需求的開發進度如何了,所以長話短說,我就根據自己對Lua的理解,以某一個具體的例子來做分析:
Lua代碼塊:
If a < b then a = c end
這句話咱們程序員能看懂,可是計算機就跟某些男程序員家里負責貌美如花的老婆一樣,只知道這是一串用英文字符拼出來的一行沒有任何意義的字符串而已。
為了讓計算機能夠讀懂這句話,那么我們要做的第一件事情就是分詞:既然你看不懂。我就先把一句話拆成一個一個單詞,而且我告訴你每個單詞的含義是什么。
分詞的結果大概長下面這樣:
分詞結果 類型(意義)
if Type_If (if 關鍵字)
a Type_Var (這是一個變量)
< Type_OpLess(這是一個小於號)
b Type_Var(這是一個變量)
then Type_Then(Then關鍵字)
a Type_Var (這是一個變量)
= Type_OpEqual(這是一個等號)
c Type_Var(這是一個變量)
end Type_End(End關鍵字)
好了。現在計算機終於明白了。原來你寫的這行代碼里面有9個字,而且每個字的意思我都懂了。所以現在問題是,計算機理解了這句話了嗎?
計算機依然不理解。就好像“吃飯”這句話,計算機理解了 “吃”是動詞,張開嘴巴的意思。“飯”是名詞,指的米飯的意思。但是你把吃飯放在一起,計算機並不知道這是“張開嘴巴,把飯放進嘴里,並且咽到胃里”的意思。因為計算機只知道“張開嘴巴”和“米飯”兩件事,這兩件事有什么聯系,計算機並不能理解。有人會說了:簡單:吃+其他字 這種結構就讓計算機籠統的理解為把后一個詞代表的東西放進嘴巴里的意思就好了啊?這種情況適合”吃飯”這個詞,但是如果這樣你讓計算機怎么理解“吃驚”這個詞呢?所以這里引出下一個話題:語義解析。
關於語義解析這塊,如果大家想要了解的更深入,可以去了解一下AST(抽象語法樹)。然而對於我們這個例子,我們用簡單的方式模擬着去理解就好了。
對於Lua而言,每一個關鍵字都有自己特別的結構。所以Lua的關鍵字將成為語義解析的重點。我們現在涉及到的if這個例子:我們可以簡單的用偽代碼表述這個解析過程:
對於if語句我們可以抽象成這種結構:
If condition(條件表達式) then dosth(語句塊) end
所以對if語句塊進行解析的偽代碼如下:
ReadTokenWord();
If(tokenWord.type == Type_If) then
ReadCondition() //讀取條件表達式
ReadThen() //讀取關鍵字then
ReadCodeBlock() //讀取邏輯代碼塊
ReadEnd() //讀取關鍵字End
End
所以為了讓計算機理解,我們還是得把這個東西變成數據結構。
因為我只是做一個Demo而已,所以我用了先驗知識。也就是我假定我們的If語句塊邏輯結構是這樣的:
If 小於條件表達式 then 賦值表達式 End
所以在我的Demo里轉成C++數據結構就是IfStateMent大概是這樣:
OK,所以現在,我們整個詞法語法分析都做完了。但是真正的Lua虛擬機並不能執行我們的ifStateMent這種東西。Lua源碼里的實現也是類似這種TokenType 和 結構化的 if Statement whileStatement等等,並且Lua沒有生成完整的語法樹。Lua源碼的實現里面,它是解析一些語句,生成臨時的語法樹,然后翻譯成指令集的。並不會等所有的語句都解析完了再翻譯的。語義解析和翻譯成指令集是並行的一個過程。貼一個源碼里面關於語義解析的部分實現:
OK,現在咱們已經把我們程序員輸入的Lua代碼變成了一個數據結構(計算機能讀懂)。下一步我們要把這個數據結構再變成Lua虛擬機能認識的東西,這個東西就是 Lua 指令集!
至於轉換的過程,對於我們這個例子,大概是這樣的:
If a < b then a = c end
先理解條件 a<b:一種基於寄存器的指令設計大概是這樣的:
a,b均為變量。假定我們的可用的寄存器索引值從10(0-9號寄存器都已經被占用了)開始:又假定我們有一個常量索引表:0號常量:字符’a’,1號常量:字符串’b’。那么a<b可以被翻譯為這樣:
-
LoadK 10,0 :將_G[ConstVar[0]]載入10號寄存器: R[10] = _G[“a”]
-
LoadK 11,1 :將_G[ConstVar[1]]載入11號寄存器: R[11] = _G[“b”]
-
LT 10,11 : 比較R[10]<R[11]是否成立,如果成立,則跳過下一條指令(++PC),否則執行下一條指令。LT后面跟着的一條指令必然是JMP指令。就是如果R[10]<R[11]成立,則不執行JMP,直接執行JMP后面的一條指令(a=c的語句塊對應的指令集),否則直接跳過下面的一個語句塊(跳過a=c的賦值過程)。
同理,繼續進行a=c的翻譯等等。
所以If a < b then a = c end在我寫的demo里面最后被翻譯成了:
OK,我們現在大概明白了從Lua代碼怎么變成指令集的這件事了。
現在我們來具體看一下Lua5.1的指令集:
Lua的指令集是定長的,每一條指令都是32位,其中大概長這樣:
每一條指令的低六位 都是指令的指令碼,比如 0代表MOVE,12代表Add。Lua總共有37條指令,分別是
MOVE,LOADK,LOADBOOL,LOADNIL,GETUPVAL,GETGLOBAL,GETTABLE,SETGLOBAL,SETUPVAL,SETTABLE,NEWTABLE,SELF,ADD,SUB,MUL,DIV,MOD,POW,UNM,NOT,LEN,CONCAT,JMP,EQ,LT,LE,TEST,TESTSET,CALL,TAILCALL,RETURN,FORLOOP,TFORLOOP,SETLIST,CLOSE,CLOSURE,VARARG.
我們發現圖上還有iABC,iABx,iAsBx。這個意思是有的指令格式是 OPCODE,A,B,C的格式,有的指令是OPCODE A,BX格式,有的是OPCODE A,sBX格式。sBx和bx的區別是bx是一個無符號整數,而sbx表示的是一個有符號的數,也就是sbx可以是負數。
我不打算詳細的講每一條指令,我還是舉個例子:
指令編碼 0x 00004041 這條指令怎么解析:
0x4041 = 0000 0000 0000 0000 0100 0000 0100 0001
低六位(0~5)是opcode:000001 = 1 = LoadK指令(0~37分別對應了我上面列的38條指令,按順序來的,0是Move,1是loadk,2是loadbool.....37是vararg)。LoadK指令格式是iABC(C沒用上,僅ab有用)格式。所以我們再繼續讀ab。
a = 低6~13位 為 00000001 = 1所以a=1
b = 低14~22位 為000000001 = 1所以b=1
所以0x4041 = LOADK 1, 1
指令碼如何解析我也在demo里面寫了,代碼大概是這樣:
那么Lua文件經過Luac的編譯后生成的Lua字節碼,Lua字節碼文件里面除了包含指令集之外又有哪些東西呢?當然不會像我上面的那個詞法語法解析那個demo那么弱智拉。所以下面我們就講一下Lua字節碼文件的結構:
Lua字節碼文件(*.lua.bytes)包含了:文件頭+頂層函數:
文件頭結構:
頂層函數和其他普通函數都擁有同樣的結構:
所以我們是可以輕松自己寫代碼去解析的。后文提供的Demo源碼里面我也已經實現了字節碼文件的解析。
Demo中的例子是涉及到的Lua源代碼以及最終解析字節碼得到的信息分別是:
OK,本文現在就剩最后一點點東西了:Lua虛擬機是怎么執行這些指令的呢?
大概是這樣的:
While(指令不為空)
執行指令
取下一條要執行的指令
End
每一條指令應該怎么執行呢???如果大家還有印象的話,咱們前文語義解析完之后轉指令集是這樣的:
a < b
-
LoadK 10,0 :將_G[ConstVar[0]]載入10號寄存器: R[10] = _G[“a”]
-
LoadK 11,1 :將_G[ConstVar[1]]載入11號寄存器: R[11] = _G[“b”]
-
LT 10,11 : 比較R[10]<R[11]是否成立,如果成立,則跳過下一條指令(++PC),否則執行下一條指令。LT后面跟着的一條指令必然是JMP指令。就是如果R[10]<R[11]成立,則不執行JMP,直接執行JMP后面的一條指令(a=c的語句塊),否則直接跳過下面的一個語句塊(跳過a=c的賦值過程)。
那當然是指令后面的文字就已經詳細的描述了指令的執行邏輯拉,嘿嘿。
為了真正的執行起來,所以我們在數據結構上設計需要 1,寄存器:2,常量表:3,全局變量表:
為了能執行我們demo里面的例子:
我實現了這段代碼涉及到的所有指令
insExecute[(int)OP_LOADK] = &LuaVM::LoadK;
insExecute[(int)OP_SETGLOBAL] = &LuaVM::SetGlobal;
insExecute[(int)OP_GETGLOBAL] = &LuaVM::GetGlobal;
insExecute[(int)OP_ADD] = &LuaVM::_Add;
insExecute[(int)OP_SUB] = &LuaVM::_Sub;
insExecute[(int)OP_MUL] = &LuaVM::_Mul;
insExecute[(int)OP_DIV] = &LuaVM::_Div;
insExecute[(int)OP_CALL] = &LuaVM::_Call;
insExecute[(int)OP_MOD] = &LuaVM::_Mod;
insExecute[(int)OP_LT] = &LuaVM::_LT;
insExecute[(int)OP_JMP] = &LuaVM::_JMP;
insExecute[(int)OP_RETURN] = &LuaVM::_Return;
以Add為例:
bool LuaVM::_Add(LuaInstrunction ins)
{
//R(A):=RK(B)+RK(C) :::
//Todo:必要的參數合法性檢查:如果有問題則拋異常
// 將ins.bValue代表的數據和ins.cValue代表的數據相加的結果賦值給索引值為ins.aValue的寄存器
luaRegisters[ins.aValue].SetValue(0, GetBK(ins.bValue) + GetBK(ins.cValue));
return true;
}
下面是程序的運行效果截圖:
看完整個過程,其實可以思考這個問題:為什么Lua執行效率會遠遠低於C程序?
個人愚見:
-
真假寄存器:Lua指令集涉及到的寄存器是模擬的寄存器,其實質還是內存上的一個數據。訪問速度取決於CPU對內存的訪問速度。而C程序最后可以用win32指令集or Arm指令集來執行。這里面涉及到的寄存器EBX,ESP等都是CPU上面的與非門,其訪問速度=CPU的頻率(和cpu訪問內存的速度對比簡直一個天上一個地上)。
-
指令集運行的平台:Lua指令集運行的平台是Lua虛擬機。而C程序指令集運行的直接是硬件支持的。
-
C里面的數據直接對應的就是內存地址。而Lua里面的數據對應的是一個描述這個數據的數據結構。所以隔了這么一層,效率也大打折扣了。
-
比如Lua的Gc操作等等這些東西都是C程序不需要去做的。。。。
OK,最后獻上我寫的這個demo的源代碼:這份源代碼是我在清明節在家的時候瞎寫的。也就是說代碼並沒有經過耐心的整理,而且清明節有人找我出去喝酒,導致我有很長一段時間都處於“我艹快點碼完我要出去喝了”這種心不在焉的狀態,所以有些編碼格式和結構設計都處處能看到隨性的例子~畢竟只是一個demo嘛。人生在世,要有佛性,隨緣就好!如果各位真的想進一步理解關於Lua虛擬機的東西,那么我推薦諸位有空耐着性子去讀一讀Lua虛擬機的源代碼~
最后,誠摯感謝所有看到了最后這句話的同學。謝謝你們耐着性子看完了一個技術菜雞的長篇廢話。
此文已由作者授權騰訊雲+社區發布,需要源碼的同學請點擊:https://cloud.tencent.com/developer/article/1092005?fromSource=waitui下載源碼
問答
相關閱讀