用 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
的棧頂元素 "彈出", 在 POP
和 ADD
后, stack
中依然保留着應該彈出的數據,,
虛擬機工作原理
這里也是本文的核心內容, 實際上虛擬機很簡單, 遵循這樣的模式:
- 讀取: 首先,我們從指令集合或代碼中讀取下一條指令
- 解碼: 然后將指令解碼
- 執行: 執行解碼后的指令
為聚焦於真正的核心, 我們現在簡化一下這個處理步驟, 暫時忽略虛擬機的編碼部分, 因為比較典型的虛擬機會把一條指令(包括操作碼和操作數)打包成一個數字, 然后再解碼這個數字, 因此, 典型的虛擬機是可以讀入真實的機器碼並執行的.
項目文件結構
正式開始編程之前, 我們需要先設置好我們的項目. 我是在 OSX
上寫這個虛擬機的, 因為 Lua
的跨平台特性, 所以你也可以在 Windows
或 Linux
上無障礙地運行這個虛擬機.
首先, 我們需要一個 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語言版
中, 作者用枚舉類型來定義機器指令集, 因為機器指令基本上都是一些從 0
到 n
的數字, 我們就像在編輯一個匯編文件, 使用類似 PSH
之類的助記符, 再翻譯成對應的機器指令.
假設助記符 PSH
對應的機器指令是 0
, 也就是把 PSH, 5
翻譯為 0, 5
, 但是這樣我們讀起來會比較費勁, 因為在 C
中, 以枚舉形式寫的代碼更具可讀性, 所以 C語言版
作者選擇了使用枚舉來設計機器指令集, 如下:
typedef enum {
PSH,
ADD,
POP,
SET,
HLT
} InstructionSet;
Lua版的其他方案
看看我們的 Lua
版本如何選擇數據結構, 眾所周知 Lua
只有一種基本數據結構: table
, 因此我們如果想使用枚舉這種數據結構. 就需要寫出 Lua
版的枚舉來, 在網絡上搜到這兩篇文檔:
第一篇是直接用 Lua
使用 C
定義的枚舉, 代碼比較多, 就不在這里列了, 不符合我們這個項目對於簡單性的要求.
第二篇是用Lua
的table
模擬實現了一個枚舉, 代碼比較短, 列在下面.
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"}
這樣的實現目前看來最簡單, 可讀性也很不錯, 不過缺乏擴展性, 我們暫時就用這種方案.
測試程序數據結構設計
現在需要一段用來測試的程序代碼了, 假設是這樣一段程序: 把 5
和 6
相加, 把結果打印出來.
在 C語言版
中, 作者使用了一個整型數組來表示該段測試程序, , 如下:
const int program[] = {
PSH, 5,
PSH, 6,
ADD,
POP,
HLT
};
注意:
PSH
是前面C語言版
定義的枚舉值, 是一個整數0
, 其他類似.
我們的 Lua
版暫時使用最簡單的結構:表, 如下:
program = {
"PSH", "5",
"PSH", "6",
"ADD",
"POP",
"HLT"
}
這段代碼具體來說, 就是把 5
和 6
分別先后壓入堆棧, 調用 ADD
指令, 它會將棧頂的兩個值彈出, 相加后再把結果壓回棧頂, 然后我們用 POP
指令把這個結果彈出, 最后 HLT
終止程序.
很好, 我們有了一個完整的測試程序. 現在, 我們描述了虛擬機的讀取, 解碼, 求值
的詳細過程. 但是實際上我們並沒有做任何解碼操作, 因為我們這里提供的就是原始的機器指令. 也就是說, 我們后續只需要關注 讀取
和 求值
兩個操作. 我們將其簡化為 fetch
和 eval
兩個函數.
從測試程序中取得當前指令
因為我們的 Lua
版把測試程序存為一個字符串表 program
的形式, 因此可以很簡單地取得任意一條指令.
虛擬機有一個用來定位當前指令的地址計數器, 一般被稱為 指令指針
或 程序計數器
, 它指向即將執行的指令, 通常被命名為 IP
或 PC
. 在我們的 Lua
版中, 因為表的索引以 1
開始, 所以這樣定義:
-- 指令指針初值設為第一條
IP = 1
那么結合我們的 program
表, 很容易理解 program[IP]
的含義: 它以 IP
作為表的索引值, 去取 program
表中的第 1
條記錄, 完整代碼如下:
IP = 1
instr = program[IP];
如果我們打印 instr
的值, 會返回字符串 PSH
, 這里我們可以寫一個取指函數 fetch
, 如下:
function fetch()
return program[IP]
end
該函數會返回當前被調用的指令, 那么我們想要取得下一條指令該如何呢? 很簡單, 只要把指令指針 IP
加 1
即可:
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
個值, 把它們相加后再壓入棧中, 相當於執行 2
個 POP
, 再執行一個 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
指令時需要手動把彈出的棧頂元素從棧中刪除, 這樣做的好處在后面可視化時就清楚了.
各指令的處理邏輯
經過上面的詳細分析, 我們應該對執行 PSH
和 POP
指令時棧的變化(特別是棧指針和棧數組)比較清楚了, 那么先寫一下壓棧指令 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 + 1
跟 ip = 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
后, 首先棧頂指針 SP
加 1
, 接着指令指針加 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
軸的正向向下, 所以我們調整了一下program
和stack
的地址順序, 小序號在上, 大序號在下.
增加單步調試功能
其實很簡單, 我們只需要在虛擬機的主體執行流程中增加一個判斷邏輯, 每執行一條指令后都等待用戶的輸入, 這里我們設計簡單一些, 就是每執行完一條指令, 虛擬機就自動暫停, 如果用戶用鍵盤輸入 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
匯編程序代碼.