如何用 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
.
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
個值, 把它們相加後再壓入棧中, 相當於執行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$
本專案程式碼可以到群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
軸的正向向下, 所以我們調整了一下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
實現微型虛擬機器的基本篇順利完成. 接下來的擴充套件篇我們打算在這個簡單虛擬機器的基礎上增加一些指令, 實現一個稍微複雜一些的虛擬機器, 同時我們可能會修改一些資料結構, 比如我們的指令集的表示方式, 為後面更有挑戰性的目標提供一些方便.
完整專案程式碼
完整專案程式碼儲存在群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
彙編程式程式碼.