歡迎大家前往騰訊雲+社群,獲取更多騰訊海量技術實踐乾貨哦~
作者:鄭小輝 | 騰訊 遊戲客戶端開發高階工程師
寫在前面:本文所有的文字都是我手工一個一個敲的,以及本文後面分享的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虛擬機器的原始碼~
最後,誠摯感謝所有看到了最後這句話的同學。謝謝你們耐著性子看完了一個技術菜雞的長篇廢話。
此文已由作者授權騰訊雲+社群釋出,需要原始碼的同學請點選:cloud.tencent.com/developer/a…下載原始碼