1. 程式人生 > 其它 >如何用 Lua 實現一個微型虛擬機器?有點意思

如何用 Lua 實現一個微型虛擬機器?有點意思

技術標籤:luaskynetactorreactor

目錄

  1. 介紹
  2. 機器指令模擬
  3. 最終核心程式碼
  4. 虛擬機器內部狀態視覺化
  5. 完整專案程式碼
  6. 後續計劃
  7. 參考

介紹

在網上看到一篇文章使用 C 語言實現一個虛擬機器, 這裡是他的程式碼Github示例程式碼, 覺得挺有意思, 作者用很少的一些程式碼實現了一個可執行的虛擬機器, 所以打算嘗試用Lua實現同樣指令集的虛擬機器, 同時也仿照此文寫一篇文章, 本文中大量參考引用了這位作者的文章和程式碼, 在此表示感謝.

準備工作:

  • 一個Lua環境
  • 文字編輯器
  • 基礎程式設計知識

為什麼要寫這個虛擬機器?

原因是: 很有趣, 想象一下, 做一個非常小, 但是卻具備基本功能的虛擬機器是多麼有趣啊!

指令集

談到虛擬機器就不可避免要提到指令集, 為簡單起見, 我們這裡使用跟上述那篇文章一樣的指令集, 硬體假設也一樣:

  • 暫存器: 本虛擬機器有那麼幾個暫存器:A,B,C,D,E,F, 這些也一樣設定為通用暫存器, 可以用來儲存任何東西.
  • 程式: 本虛擬機器使用的程式將會是一個只讀指令序列.
  • 堆疊: 本虛擬機器是一個基於堆疊的虛擬機器, 我們可以對這個堆疊進行壓入/彈出值的操作.

這樣基於堆疊的虛擬機器的實現要比基於暫存器的虛擬機器的實現簡單得多.

示例指令集如下:

PSH 5       ; pushes 5 to the stack
PSH 10      ; pushes 10 to the stack
ADD         ; pops two values on top of the stack, adds them pushes to stack
POP         ; pops the value on the stack, will also print it for debugging
SET A 0     ; sets register A to 0
HLT         ; stop the program

注意,POP指令將會彈出堆疊最頂層的內容, 然後把堆疊指標, 這裡為了方便觀察, 我們會設定一條列印命令,這樣我們就能夠看到ADD指令工作了。我還加入了一個SET指令,主要是讓你理解暫存器是可以訪問和寫入的。你也可以自己實現像MOV A B(將A的值移動到B)這樣的指令。HTL指令是為了告訴我們程式已經執行結束。

說明: 原文的C語言版在對堆疊的處理上不太準確, 沒有把stack的棧頂元素 "彈出", 在POPADD後,stack中依然保留著應該彈出的資料,,

虛擬機器工作原理

這裡也是本文的核心內容, 實際上虛擬機器很簡單, 遵循這樣的模式:

  • 讀取: 首先,我們從指令集合或程式碼中讀取下一條指令
  • 解碼: 然後將指令解碼
  • 執行: 執行解碼後的指令

為聚焦於真正的核心, 我們現在簡化一下這個處理步驟, 暫時忽略虛擬機器的編碼部分, 因為比較典型的虛擬機器會把一條指令(包括操作碼和運算元)打包成一個數字, 然後再解碼這個數字, 因此, 典型的虛擬機器是可以讀入真實的機器碼並執行的.

專案檔案結構

正式開始程式設計之前, 我們需要先設定好我們的專案. 我是在OSX上寫這個虛擬機器的, 因為Lua的跨平臺特性, 所以你也可以在WindowsLinux上無障礙地執行這個虛擬機器.

首先, 我們需要一個Lua執行環境(我使用Lua5.3.2), 可以從官網下載對應於你的作業系統的版本. 其次我們要新建一個專案資料夾, 因為我打算最終把這個專案分享到github上, 所以用這個目錄~/GitHub/miniVM, 如下:

Air:GitHub admin$ cd ~/GitHub/miniVM/
Air:miniVM admin$

如上,我們先cd進入~/GitHub/miniVM,或者任何你想放置的位置,然後新建一個lua檔案miniVM.lua。 因為現在專案很簡單, 所以暫時只有這一個程式碼檔案。

執行也很簡單, 我們的虛擬機器程式是miniVM.lua, 只需要執行:

lua miniVM.lua

機器指令集

現在開始為虛擬機器準備要執行的程式碼了. 首先, 我們需要定義虛擬機器使用的機器指令集.

指令集資料結構設計

我們需要用一種資料結構來模擬虛擬機器中的指令集.

C語言版

C語言版中, 作者用列舉型別來定義機器指令集, 因為機器指令基本上都是一些從0n的數字, 我們就像在編輯一個彙編檔案, 使用類似PSH之類的助記符, 再翻譯成對應的機器指令.

假設助記符PSH對應的機器指令是0, 也就是把PSH, 5翻譯為0, 5, 但是這樣我們讀起來會比較費勁, 因為在C中, 以列舉形式寫的程式碼更具可讀性, 所以C語言版作者選擇了使用列舉來設計機器指令集, 如下:

typedef enum {
   PSH,
   ADD,
   POP,
   SET,
   HLT
} InstructionSet;

Lua版的其他方案

看看我們的Lua版本如何選擇資料結構, 眾所周知Lua只有一種基本資料結構:table, 因此我們如果想使用列舉這種資料結構. 就需要寫出Lua版的列舉來, 在網路上搜到這兩篇文件:

第一篇是直接用Lua使用C定義的列舉, 程式碼比較多, 就不在這裡列了, 不符合我們這個專案對於簡單性的要求.

第二篇是用Luatable模擬實現了一個列舉, 程式碼比較短, 列在下面.

function CreateEnumTable(tbl, index)   
    local enumtbl = {}   
    local enumindex = index or 0   
    for i, v in ipairs(tbl) do   
        enumtbl[v] = enumindex + i   
    end   
    return enumtbl   
end  

local BonusStatusType = CreateEnumTable({"NOT_COMPLETE", "COMPLETE", "HAS_TAKE"},-1)  

不過這種實現對我們來說也不太適合, 一方面寫起來比較繁瑣, 另一方面程式碼也不太易讀, 所以需要設計自己的列舉型別.

最終使用的Lua版

現在的方案是直接選擇用一個table來表示, 如下:

InstructionSet = {"PSH","ADD","POP","SET","HLT"}

這樣的實現目前看來最簡單, 可讀性也很不錯, 不過缺乏擴充套件性, 我們暫時就用這種方案.

測試程式資料結構設計

現在需要一段用來測試的程式程式碼了, 假設是這樣一段程式: 把56相加, 把結果打印出來.

C語言版中, 作者使用了一個整型陣列來表示該段測試程式, , 如下:

const int program[] = {
    PSH, 5,
    PSH, 6,
    ADD,
    POP,
    HLT
};

注意:PSH是前面C語言版定義的列舉值, 是一個整數0, 其他類似.

我們的Lua版暫時使用最簡單的結構:表, 如下:

program = {
	"PSH", "5",
	"PSH", "6",
	"ADD",
	"POP",
	"HLT"
}

這段程式碼具體來說, 就是把56分別先後壓入堆疊, 呼叫ADD指令, 它會將棧頂的兩個值彈出, 相加後再把結果壓回棧頂, 然後我們用POP指令把這個結果彈出, 最後HLT終止程式.

很好, 我們有了一個完整的測試程式. 現在, 我們描述了虛擬機器的讀取, 解碼, 求值的詳細過程. 但是實際上我們並沒有做任何解碼操作, 因為我們這裡提供的就是原始的機器指令. 也就是說, 我們後續只需要關注讀取求值兩個操作. 我們將其簡化為fetcheval兩個函式.

從測試程式中取得當前指令

因為我們的Lua版把測試程式存為一個字串表program的形式, 因此可以很簡單地取得任意一條指令.

虛擬機器有一個用來定位當前指令的地址計數器, 一般被稱為指令指標程式計數器, 它指向即將執行的指令, 通常被命名為IPPC. 在我們的Lua版中, 因為表的索引以1開始, 所以這樣定義:

-- 指令指標初值設為第一條
IP = 1

那麼結合我們的program表, 很容易理解program[IP]的含義: 它以IP作為表的索引值, 去取program表中的第1條記錄, 完整程式碼如下:

IP = 1
instr = program[IP];

如果我們列印instr的值, 會返回字串PSH, 這裡我們可以寫一個取指函式fetch, 如下:

function fetch()
	return program[IP]
end

該函式會返回當前被呼叫的指令, 那麼我們想要取得下一條指令該如何呢? 很簡單, 只要把指令指標IP1即可:

x = fetch()	-- 取得指令 PSH
IP = IP + 1	-- 指令指標加 1
y = fetch()	-- 取得運算元 5 

我們知道, 虛擬機器是會自動執行的, 比如指令指標會在每執行一條指令時自動加1指向下一條指令, 那麼我們如何讓這個虛擬機器自動執行起來呢? 因為一個程式直到它執行到HLT指令時才會停止, 所以我們可以用一個無限迴圈來模擬虛擬機器, 這個無限迴圈以遇到HLT指令作為終止條件, 程式碼如下:

running = true
-- 設定指令指標指向第一條指令
IP = 1
while running do
	local x = fetch()
	if x == "HLT" then running = false end
	IP = IP + 1	
end

說明: 程式碼中的local表示x是一個區域性變數, 其他不帶local的都是全域性變數

一個虛擬機器最基本的核心就是上面這段程式碼了, 它揭示了最本質的東西, 我們可以把上面這段程式碼看做一個虛擬機器的原型程式碼, 更復雜的虛擬機器都可以在這個原型上擴充套件.

不過上面這段程式碼什麼具體工作也沒做, 它只是順序取得程式中的每條指令, 檢查它們是不是停機指令HLT, 如果是就跳出迴圈, 如果不是就繼續檢查下一條, 相當於只執行了HLT.

執行每一條指令

但是我們希望虛擬機器還能夠執行其他指令, 那麼就需要我們對每一條指令分別進行處理了, 這裡最適合的語法結構就是C語言switch-case了, 讓switch中的每一個case都對應一條我們定義在指令集InstructionSet中的機器指令, 在C語言版中是這樣的:

void eval(int instr) {
    switch (instr) {
        case HLT:
            running = false;
            break;
    }
}

不過Lua沒有switch-case這種語法, 我們就用if-then-elseif的結構來寫一個指令執行函式, 也就是一個求值函式eval, 處理HLT指令的程式碼如下:

function eval(instr)
	if instr == "HLT" then 
		running = false
	end
end

我們可以這樣呼叫eval函式:

running = true
IP = 1
while running do
	eval(fetch())
	IP = IP + 1
end

增加對其他指令處理的eval:

function eval(instr)
	if instr == "HLT" then 
		running = false
	elseif instr == "PSH" then
		-- 這裡處理 PSH 指令, 具體處理後面新增
	elseif instr == "POP" then
		-- 這裡處理 POP 指令, 具體處理後面新增
	elseif instr == "ADD" then
		-- 這裡處理 ADD 指令, 具體處理後面新增
	end
end

棧的資料結構設計

因為我們的這款虛擬機器是基於棧的, 一切的資料都要從儲存器搬運到棧中來操作, 所以我們在為其他指令增加具體的處理程式碼之前, 需要先準備一個棧.

注意: 我們這裡要使用一種最簡單的棧結構:陣列

C語言版中使用了一個固定長度為256的陣列, 同時需要一個棧指標SP, 它其實就是陣列的索引, 用來指向棧中的元素, 如下:

int sp = -1;
int stack[256]; 

我們的Lua版也準備用一個最簡單的表來表示棧, 如下:

SP = 0
stack = {}

注意: 我們知道C的陣列是從0開始的, 而Lua的陣列是從1開始的, 所以我們的程式碼中以1作為陣列的開始, 那麼SP的初值就要設定為0.

12月30號我們會做一個skynet的訓練營直播,感興趣的朋友可以進群973961276瞭解詳情跟大家一起交流學習哦!並且群裡還整理超多的視訊資料和麵經分享

各種指令執行時棧狀態變化的分析

下面是一個形象化的棧, 最左邊是棧底, 最右邊是棧頂:

[] // empty
PSH 5 // put 5 on **top** of the stack
[5]
PSH 6
[5, 6]
POP
[5]
POP
[] // empty
PSH 6
[6]
PSH 5
[6, 5]

先手動分析一下我們的測試程式程式碼執行時棧的變化情況, 先列出測試程式:

PSH, 5,
PSH, 6,
ADD,
POP,
HLT

先執行PSH, 5,也就是把5壓入棧中, 棧的情況如下:

[5]

再執行PSH, 6,也就是把6壓入棧中, 棧的情況如下:

[5,6]

再執行ADD, 因為它需要2個引數, 所以它會主動從棧中彈出最上面的2個值, 把它們相加後再壓入棧中, 相當於執行2POP, 再執行一個PSH, 棧的情況如下:

[5, 6]
// pop the top value, store it in a variable called a
a = pop; // a contains 6
[5] // stack contents
// pop the top value, store it in a variable called b
b = pop; // b contains 5
[] // stack contents
// now we add b and a. Note we do it backwards, in addition
// this doesn't matter, but in other potential instructions
// for instance divide 5 / 6 is not the same as 6 / 5
result = b + a;
push result // push the result to the stack
[11] // stack contents

上面這段描述很重要, 理解了這個你才清楚如何用程式碼來模擬棧的操作.

上述沒有提到棧指標SP的變化, 實際上它預設指向棧頂元素, 也就是上述棧中最右邊那個元素的索引, 我們看到, 最右邊的元素的索引是一直變化的.

空的棧指標在C語言版的虛擬機器中被設定為-1.

如果我們在棧中壓入3個值, 那麼棧的情況如下:

SP指向這裡(SP = 3)
       |
       V
[1, 5, 9]
 1  2  3  <- 陣列下標

現在我們先從棧上彈出POP出一個值, 我們如果只修改棧指標SP, 讓其減1, 如下:

SP指向這裡(SP = 2)
    |
    V
[1, 5, 9]
 1  2 <- 陣列下標

注意: 我們不能指定彈出棧中的某個元素, 只能彈出位於棧頂的元素

因為我們是最簡版的山寨棧, 所以執行彈出指令時只修改棧指標的話, 棧中的那個應該被彈出的9實際上還在數組裡, 所以我們在模擬POP指令時需要手動把彈出的棧頂元素從棧中刪除, 這樣做的好處在後面視覺化時就清楚了.

各指令的處理邏輯

經過上面的詳細分析, 我們應該對執行PSHPOP指令時棧的變化(特別是棧指標和棧陣列)比較清楚了, 那麼先寫一下壓棧指令PSH 5的處理邏輯, 當我們打算把一個值壓入棧中時, 先調整棧頂指標的值, 讓其加1, 再設定當前SP處棧的值stack[SP], 注意這裡的執行順序:

SP = -1;
stack = {};

SP = SP + 1
stack[SP] = 5

C語言版中寫成這樣的:

void eval(int instr) {
    switch (instr) {
        case HLT: {
            running = false;
            break;
        }
        case PSH: {
            sp++;
            stack[sp] = program[++ip];
            break;
        }
    }
}

C語言版作者用了不少sp++,stack[sp] = program[++ip]之類的寫法, 但是我覺得這裡這麼用會降低易讀性, 因為讀者不太容易看出執行順序, 不如拆開來寫成sp = sp + 1ip = ip + 1, 這樣看起來更清楚.

所以在我們Lua版的eval函式中, 可以這樣寫PSH指令的處理邏輯:

function eval(instr)
	if instr == "HLT" then 
		running = false
	elseif instr == "PSH" then
		-- 這裡處理 PSH 指令, 具體處理如下
		SP = SP + 1
		-- 指令指標跳到下一個, 取得 PSH 的運算元
		IP = IP + 1
		stack[SP] = program[IP]
	elseif instr == "POP" then
		-- 這裡處理 POP 指令, 具體處理後面新增   
	elseif instr == "ADD" then  
		-- 這裡處理 ADD 指令, 具體處理後面新增
	end
end

分析一下我們的程式碼, 其實很簡單, 就是發現當指令是PSH後, 首先棧頂指標SP1, 接著指令指標加1, 取得PSH指令後面緊跟著的運算元, 然後把棧陣列的第一個元素stack[SP]賦值為測試程式陣列中的運算元program[IP].

接著是POP指令的處理邏輯, 它要把棧頂指標減1, 同時最好從棧陣列中刪除掉彈出棧的元素:

elseif instr == "POP" then
	-- 這裡處理 POP 指令, 具體處理如下
	local val_popped = stack[SP]
	SP = SP - 1
elseif ...

ADD指令的處理邏輯

最後是稍微複雜一些的ADD指令的處理邏輯, 因為它既有壓棧操作, 又有出棧操作, 如下:

elseif instr == "ADD" then  
	-- 這裡處理 ADD 指令, 具體處理如下
	-- 先從棧中彈出一個值
	local a = stack[SP]
	stack[SP] = 0
	SP = SP - 1
            
	-- 再從棧中彈出一個值
	local b = stack[SP]
	stack[SP] = 0
	SP = SP - 1
	
	-- 把兩個值相加
	local result = a + b
         
   	-- 把相加結果壓入棧中   
	SP = SP + 1
	stack[SP] = result
end

最終程式碼

很好, 現在我們Lua版的虛擬機器完成了, 完整程式碼如下:

-- 專案名稱: miniVM
-- 專案描述: 用 Lua 實現的一個基於棧的微型虛擬機器
--	專案地址: https://github.com/FreeBlues/miniVM
--	專案作者: FreeBlues

-- 指令集
InstructionSet = {"PSH","ADD","POP","SET","HLT"}
Register = {A, B, C, D, E, F,NUM_OF_REGISTERS}

--	測試程式程式碼
program = {"PSH", "5", "PSH", "6", "ADD", "POP", "HLT"}

-- 指令指標, 棧頂指標, 棧陣列
IP = 1
SP = 0
stack = {}

-- 取指令函式
function fetch()
	return program[IP]
end

-- 求值函式
function eval(instr)
	if instr == "HLT" then 
		running = false
	elseif instr == "PSH" then
		-- 這裡處理 PSH 指令, 具體處理如下
		SP = SP + 1
		-- 指令指標跳到下一個, 取得 PSH 的運算元
		IP = IP + 1
		stack[SP] = program[IP]
	elseif instr == "POP" then
		-- 這裡處理 POP 指令, 具體處理如下 
		local val_popped = stack[SP]
		SP = SP - 1  
	elseif instr == "ADD" then  
		-- 這裡處理 ADD 指令, 具體處理如下
		-- 先從棧中彈出一個值
		local a = stack[SP]
		stack[SP] = 0
		SP = SP - 1
            
		-- 再從棧中彈出一個值
		local b = stack[SP]
		stack[SP] = 0
		SP = SP - 1
	
		-- 把兩個值相加
		local result = a + b
         
   		-- 把相加結果壓入棧中   
		SP = SP + 1
		stack[SP] = result
		
		-- 為方便檢視測試程式執行結果, 這裡增加一條列印語句
		print(stack[SP])
	end
end

-- 虛擬機器主函式
function main()
	running = true
	while running do
		eval(fetch())
		IP = IP + 1
	end
end

-- 啟動虛擬機器
main()

執行結果如下:

Air:miniVM admin$ lua miniVM.lua 
11.0
Air:miniVM admin$

本專案程式碼可以到群973961276裡下載.

虛擬機器內部狀態視覺化

應該說目前為止我們的虛擬機器已經完美地實現了, 不過美中不足的是它的一切動作都被隱藏起來, 我們只能看到最終執行結果, 當然了我們也可以增加列印命令來顯示各條指令執行時的情況, 但是這裡我們打算把虛擬機器執行時內部狀態的變化用圖形的方式繪製出來, 而不僅僅是簡單的print文字字元.

框架選擇:Love2D

這裡我們選擇使用Love2D來繪圖, 原因有這麼幾個:

  • 簡單好用:結構很簡單, 框架很好用
  • 跨平臺:同時支援Windows, Mac OS X, Linux, Android 和 iOS
  • 免費開源:直接下載了就能用

Love2D的簡單介紹

Love2D寫程式非常簡單方便, 首先新建一個目錄love(目錄名可以隨便起), 接著在該目錄下新建一個檔案main.lua(該檔案必須使用這個名字), 然後在main.lua中編寫遊戲邏輯即可, 可以試試這段程式碼:

function love.draw()
    love.graphics.print("Hello World", 400, 300)
end

執行命令是用love呼叫目錄, 它會自動載入目錄內的main.lua檔案, 命令如下:

love ./love

它會新建一個視窗, 然後列印Hello World.

把專案修改為 Love2D 的形式

其實很簡單, 就是在專案檔案目錄下新建個目錄miniVM, 然後拷貝miniVM.lua程式碼檔案到這個新目錄中, 並將新目錄中的程式碼檔名修改為main.lua.

Air:miniVM admin$ cp ./miniVM.lua ./miniVM/main.lua
Air:miniVM admin$ tree
.
├── README.md
├── miniVM
│ └── main.lua
└── miniVM.lua

1 directory, 3 files
Air:miniVM admin$ 

按照Love2D的程式碼框架要求修改整合程式碼, 在main.lua中增加一個載入函式love.load, 把所有隻執行一次的程式碼放進去, 再增加一個重新整理函式love.update, 把所有需要重複執行的程式碼放進去, 最後增加一個love.draw函式, 把所有用於繪圖的程式碼放進去, 修改後的main.lua如下:

function love.load()
    -- 指令集
	InstructionSet = {"PSH","ADD","POP","SET","HLT"}
	Register = {A, B, C, D, E, F,NUM_OF_REGISTERS}

	--	測試程式程式碼
	program = {"PSH", "5", "PSH", "6", "ADD", "POP", "HLT"}

	-- 指令指標, 棧頂指標, 棧陣列
	IP = 1
	SP = 0
	stack = {}
	
	running = true
end

function love.update(dt)
    -- 虛擬機器主體
	if running then
		eval(fetch())
		IP = IP + 1
	end
end

function love.draw()
    love.graphics.print("Welcome to our miniVM!", 400, 300)
end


-- 取指令函式
function fetch()
	return program[IP]
end

-- 求值函式
function eval(instr)
	if instr == "HLT" then 
		running = false
	elseif instr == "PSH" then
		-- 這裡處理 PSH 指令, 具體處理如下
		SP = SP + 1
		-- 指令指標跳到下一個, 取得 PSH 的運算元
		IP = IP + 1
		stack[SP] = program[IP]
	elseif instr == "POP" then
		-- 這裡處理 POP 指令, 具體處理如下 
		local val_popped = stack[SP]
		SP = SP - 1  
	elseif instr == "ADD" then  
		-- 這裡處理 ADD 指令, 具體處理如下
		-- 先從棧中彈出一個值
		local a = stack[SP]
		stack[SP] = 0
		SP = SP - 1
            
		-- 再從棧中彈出一個值
		local b = stack[SP]
		stack[SP] = 0
		SP = SP - 1
	
		-- 把兩個值相加
		local result = a + b
         
   		-- 把相加結果壓入棧中   
		SP = SP + 1
		stack[SP] = result
		
		-- 為方便檢視測試程式執行結果, 這裡增加一條列印語句
		print(stack[SP])
	end
end

程式碼整合完畢, 檢查無誤後用Love2D載入, 如下:

Air:miniVM admin$ pwd
/Users/admin/GitHub/miniVM
Air:miniVM admin$ love ./miniVM
11
Air:miniVM admin$

我們會看到彈出一個視窗用於繪製圖形, 同時命令列也會返回執行結果.

編寫繪製函式

目前我們的虛擬機器有一個用來模擬儲存器儲存測試程式指令的program表, 還有一個用來模擬棧的stack表, 另外有兩個指標, 一個是指示當前指令位置的指令指標IP, 另一個是指示當前棧頂位置的棧頂指標SP, 所以, 我們只需要繪製出這4個元素在虛擬機器執行時的狀態變化即可.

繪製 program 表和指令指標 IP

首先繪製作為儲存器使用的program表, 我們準備遵循約定俗成的習慣, 用兩個連在一起的矩形方框來表示它的基本儲存單元, 左邊的矩形表示地址, 右邊的矩形表示在改地址存放的值, 這裡我們會用到Love2D中這三個基本繪圖函式:

  • love.graphics.setColor(0, 100, 100)
  • love.graphics.rectangle("fill", x, y, w, h)
  • love.graphics.print("Welcome to our miniVM!", 400, 300)

我們一步步來, 先繪製右側矩形和指令, 程式碼如下:

-- 繪製儲存器中指令程式碼的變化
function drawMemory()
	local x,y = 500, 300
	local w,h = 60, 20
	for k,v in ipairs(program) do
		-- 繪製矩形
		love.graphics.setColor(0, 255, 50)
		love.graphics.rectangle("fill", x, y-(k-1)*h, w, h)
		
		--	繪製要執行的指令程式碼       
		love.graphics.setColor(200, 100, 100)
		love.graphics.print(v, x+15,y-(k-1)*h+5)
               
   end    
end

function love.draw()
	-- love.graphics.print("Welcome to our miniVM!", 400, 300)
	-- 繪製儲存器中指令程式碼的變化
	drawMemory()
end

顯示效果如下:

接著我們把左側的地址矩形和地址值, 還有指令指標也繪製出來, 程式碼如下:

-- 繪製儲存器中指令程式碼的變化
function drawMemory()
	local x,y = 500, 300
	local w,h = 60, 20
	for k,v in ipairs(program) do
		-- 繪製儲存器右側矩形
		love.graphics.setColor(0, 255, 50)
		love.graphics.rectangle("line", x, y+(k-1)*h, w, h)
		
		--	繪製儲存器中要執行的指令程式碼       
		love.graphics.setColor(200, 100, 100)
		love.graphics.print(v, x+15,y+(k-1)*h+5)
		
		-- 繪製儲存器左側矩形
		love.graphics.setColor(0, 255, 50)
		love.graphics.rectangle("line", x-w/3-10,y+(k-1)*h,w/3+10, h)
		
		--	繪製表示儲存器地址的數字序號      
		love.graphics.setColor(200, 100, 100)
		love.graphics.print(k,x-w/2-10+10,y+(k-1)*h+5)
		
		-- 繪製指令指標 IP
		love.graphics.setColor(255, 10, 10)
		love.graphics.print("IP".."["..IP.."] ->",x-w-10+10-120,y+(IP-1)*h)          
   end    
end

顯示效果如下:


繪製 stack 表和棧頂指標 SP

接下來就是繪製用來模擬棧的stack表和棧頂指標SP了, 跟上面類似, 程式碼如下:

-- 繪製棧的變化
function drawStack()
	local x,y = 200, 300
	local w,h = 60, 20
	for k,v in ipairs(stack) do
		-- 顯示棧右側矩形
		love.graphics.setColor(0, 255, 50)
		love.graphics.rectangle("line", x, y+(k-1)*h, w, h)
        
       --	繪製被壓入棧內的值       
		love.graphics.setColor(200, 100, 100)
		love.graphics.print(v, x+10,y+(k-1)*h)
		
       
       -- 繪製棧左側矩形
		love.graphics.setColor(0, 255, 50)
		love.graphics.rectangle("line", x-w-20,y+(k-1)*h,w+20, h) 
		
		--	繪製表示棧地址的數字序號      
		love.graphics.setColor(200, 100, 100)
		love.graphics.print(k,x-w-20+10,y+(k-1)*h)
        
       
		-- 繪製棧頂指標 SP
		love.graphics.setColor(255, 10, 10)
		love.graphics.print("SP".."["..SP.."] ->",x-w-10+10-100,y+(SP-1)*h)
    end    
end

function love.draw()
	-- love.graphics.print("Welcome to our miniVM!", 400, 300)
	-- 繪製儲存器中指令程式碼的變化
	drawMemory()
	drawStack()
end

顯示效果如下:

很不錯的結果, 終於能看到虛擬機器這個黑盒子裡面的內容了, 不過一下子就執行過去了, 還是有些遺憾, 那麼就給它增加一項單步除錯的功能好了!

說明: 因為Love2D的座標軸方向是左手系,也就是說Y軸的正向向下, 所以我們調整了一下programstack的地址順序, 小序號在上, 大序號在下.

增加單步除錯功能

其實很簡單, 我們只需要在虛擬機器的主體執行流程中增加一個判斷邏輯, 每執行一條指令後都等待使用者的輸入, 這裡我們設計簡單一些, 就是每執行完一條指令, 虛擬機器就自動暫停, 如果使用者用鍵盤輸入s鍵, 則繼續執行下一條指令.

需要用到這個鍵盤函式:

  • love.keyreleased(key)

程式碼如下:

function love.load()
	...
	step = false
end

function love.keyreleased(key)
   if key == "s" then
      step = true
   end
end

function love.update(dt)
    -- 虛擬機器主體
	if running then 
		if step then 
			step = false
			eval(fetch())
			IP = IP + 1
		end
	end
end

執行中可以通過按下s鍵來單步執行每一條指令, 可以看看效果:





到現在為止, 我們的視覺化部分完成了, 而且也可以通過使用者的鍵盤輸入來單步執行指令, 可以說用Lua實現微型虛擬機器的基本篇順利完成. 接下來的擴充套件篇我們打算在這個簡單虛擬機器的基礎上增加一些指令, 實現一個稍微複雜一些的虛擬機器, 同時我們可能會修改一些資料結構, 比如我們的指令集的表示方式, 為後面更有挑戰性的目標提供一些方便.

完整專案程式碼

完整專案程式碼儲存在群973961276裡, 歡迎自由下載.

專案檔案清單如下:

Air:miniVM admin$ tree
.
├── README.md
├── miniVM
│ └── main.lua
├── miniVM.lua
└── pic
    ├── p01.png
    ├── p02.png
    ├── p03.png
    ├── p04.png
    ├── p05.png
    ├── p06.png
    ├── p07.png
    ├── p08.png
    └── p09.png

2 directories, 12 files
Air:miniVM admin$ 

後續計劃

因為這種方式很好玩, 所以我們打算後續在這個基礎上實現一個Intel 8086的虛擬機器, 包括完整的指令集, 最終目標是可以在我們的虛擬機器上執行DOS時代的x86彙編程式程式碼.

參考