用 Lua 實現一個微型虛擬機-基本篇


用 Lua 實現一個微型虛擬機-基本篇

目錄

介紹

在網上看到一篇文章 使用 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.

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

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

[] // 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$

本項目代碼可以到 Github-miniVM 下載.

虛擬機內部狀態可視化

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

更詳細的可以參考我寫的這篇文檔Mac 下安裝使用 Love2D

把項目修改為 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 實現微型虛擬機的基本篇順利完成. 接下來的擴展篇我們打算在這個簡單虛擬機的基礎上增加一些指令, 實現一個稍微復雜一些的虛擬機, 同時我們可能會修改一些數據結構, 比如我們的指令集的表示方式, 為后面更有挑戰性的目標提供一些方便.

完整項目代碼

完整項目代碼保存在 Github-miniVM 里, 歡迎自由下載.

項目文件清單如下:

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 匯編程序代碼.

參考


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM