使用 C 語言實現一個虛擬機器

oschina發表於2015-05-13

介紹

GitHub 展示了我們將會構建的東西, 你也可以在發生錯誤的時候拿你的程式碼同這個資源庫進行對比. GitHub 資源庫

我考慮過會寫一篇有關使用C語言構建專屬虛擬機器的文章. 我喜歡研究“底層”的應用程式,比方說編譯器、直譯器以及虛擬機器。我也愛談論到它們。我也有另外一個系列的有關使用Go來編寫一個直譯器的文章(目前正在準備中)。我也在開發自己的程式語言 Alloy.

必要的準備工作及注意事項:

在開始之前需要做以下工作:

  • 一個C編譯器——我使用了 clang 3.4,也可以用其它支援 c99/c11 的編譯器;
  • 文字編輯器——我建議使用基於IDE的文字編輯器,我使用 Emacs;
  • 基礎程式設計知識——最基本的變數,流程控制,函式,資料結構等;
  • Make 指令碼——能使程式更快一點。

為什麼要寫個虛擬機器?

有以下原因:

  • 想深入瞭解計算機工作原理。本文將幫助你瞭解計算機底層如何工作,虛擬機器提供簡潔的抽象層,這不就是一個最好的學習它們原理的方法嗎?
  • 更深入瞭解一些程式語言是如何工作。例如,當下多種經常使用那些語言的虛擬機器。包括JVM,Lua VM,FaceBook 的 Hip—Hop VM(PHP/Hack) 等。
  • 只是因為有興趣學習虛擬機器。

指令集

我們將要實現一種非常簡單的自定義的指令集。我不會講一些高階的如位移暫存器等,希望在讀過這篇文章後掌握這些。

我們的虛擬機器具有一組暫存器,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編譯器(我使用 clang 3.4)。還需要一個資料夾來放置我們的專案,我喜歡將我的專案放置於~/Dev:

$cd ~/Dev/
mkdir mac
cd mac
mkdir src

如上,我們先 cd 進入~/Dev 目錄,或者任何你想放置的位置,然後新建一個目錄(我稱這個虛擬機器為”mac”)。然後再 cd 進這個目錄並新建我們 src 目錄,這個目錄用於放置程式碼。

Makefile

makefile 相對直接,我們不需要將什麼東西分成多個檔案,也不用包含任何東西,所以我們只需要用一些標誌來編譯檔案:

SRC_FILES = main.c
CC_FLAGS = -Wall -Wextra -g -std=c11
CC = clang

all:
    ${CC} ${SRC_FILES} ${CC_FLAGS} -o mac

這對目前來說已經足夠了,你以後還可以改進它,但是隻要它能完成這個工作,我們應該滿足了。

指令程式設計(程式碼)

現在開始寫虛擬機器的程式碼了。第一,我們需要定義程式的指令。為此,我們可以使用一個列舉型別enum,因為我們的指令基本上是從0到X的數字。事實上,可以說你是在組裝一個彙編檔案,它會使用像 mov 這樣的詞,然後翻譯成宣告的指令。
我們可以只寫一個指令檔案,例如 PSH, 5 是0, 5,但是這樣並不易讀,所以我們使用列舉器!

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

現在我們可以將一個測試程式儲存為一個陣列。我們寫一個簡單的程式用於測試:將5和6相加,然後將他們列印出來(用POP指令)。如果你願意,你可以定義一個指令將棧頂的值列印出來。

指令應該儲存成一個陣列,我將在文件的頂部定義它;但你或許會將它放在一個標頭檔案中,下面是我們的測試程式:

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

上面的程式將會把5和6壓入棧,呼叫 ADD 指令,這將會把棧頂的兩個值彈出,相加後將結果壓回棧中,接下來我們彈出結果,因為 POP 指令將會列印這個值,但是你不必自己再做了,我已經做好並測試過了。最後,HLT 指令結束程式。

很好,這樣我們有了自己的程式。現在我們實現了虛擬機器的讀取,解碼,求值的模式。但是要記住,我們沒有解碼任何東西,因為我們給出的是原始指令。也就是說我們只需要關注讀取和求值!我們可以將它們簡化成兩個函式 fetch 和 evaluate。

取得當前指令

因為我們已經將我們的程式存成了一個陣列,所以很簡單的就可以取得當前指令。一個虛擬機器有一個計數器,一般來說叫做程式計數器,指令指標等等,這些名字是一個意思取決於你的個人喜好。在虛擬機器的程式碼庫裡,IP 或 PC 這樣的簡寫形式也隨處可見。

如果你之前有記得,我說過我們要把程式計數器以暫存器的形式儲存…我們將那麼做——在以後。現在,我們只是在我們程式碼的最頂端建立一個叫 ip 的變數,並且設定為 0。

int ip = 0;

ip 變數代表指令指標。因為我們已經將程式存成了一個陣列,所以使用 ip 變數去指明程式陣列中當前索引。例如,如果建立了一個被賦值了程式 ip 索引的變數 x,它將儲存我們程式的第一條指令。

[假設ip為0]

int ip = 0;

int main() {
    int instr = program[ip];
    return 0;

如果我們列印變數 instr,本來應是 PSH 的它將顯示為0,因為在他是我們列舉裡的第一個值。我們也可以寫一個取回函式像這樣:

int fetch() {
    return program[ip];
}

這個函式將會返回當前被呼叫指令。太棒了,那麼如果我們想要下一條指令呢?很容易,我們只要增加指令指標就好了:

int main() {
    int x = fetch(); // PSH
    ip++; // increment instruction pointer
    int y = fetch(); // 5
}

那麼怎樣讓它自己動起來呢?我們知道一個程式直到它執行 HLT 指令才會停止。因此我們使用一個無限的迴圈持續直到當前指令為HLT。

// INCLUDE <stdbool.h>!
bool running = true;

int main() {
   while (running) {
       int x = fetch();
       if (x == HLT) running = false;
       ip++;
   }
}

這工作的很好,但是有點凌亂。我們正在迴圈每一條指令,檢查是否 HLT,如果是就停止迴圈,否則“吃掉”指令接著迴圈。

判斷一條指令

因此這就是我們虛擬機器的主體,然而我們想要確實的評判每一條指令,並且使它更簡潔一些。好的,這個簡單的虛擬機器,你可以寫一個“巨大”的 switch 宣告。讓 switch 中的每一個 case 對應一條我們定義在列舉中的指令。這個 eval 函式將使用一個簡單的指令的引數來判斷。我們在函式中不會使用任何指令指標遞增除非我們想運算元浪費運算元。

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

因此如果我們在回到主函式,就可以像這樣使用我們的 eval 函式工作:

bool running = true;
int ip = 0;

// instruction enum here

// eval function here

// fetch function here

int main() {
    while (running) {
        eval(fetch());
        ip++; // increment the ip every iteration
    }
}

棧!

漂亮!那應該會表現的很完美。現在在我們新增其他的指令之前,我們需要一個棧,很容易就做到了,我們僅僅使用一個陣列,這個陣列有固定的長度,這個陣列裡包含了256個值。我們也需要一些棧指標,通常簡寫成sp。這就指向了我們棧陣列中的索引。

因此為了幫你能看見棧,下面就是陣列化的棧:

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

那麼接下來我們先把5放到棧中

[5]

接著我們放入6

[5, 6]

然後新增指令基本上將彈出這些值而且放到一起,最後把結果放到棧中。

[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通常被設定成-1,這也就意味著它是空的。記住陣列是從0開始的,因此如果sp是0,在沒有初始化為零的情況下它將被設定成C編譯器給出的隨機數。

如果我們壓入(push)3個值,那麼sp將程式設計2。所以這是一個有個3個值的陣列:

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

現在我們從棧上彈出(pop)一個值,我們僅需要減小棧頂指標。比如我們接下來彈出9,那麼棧頂將變為5:

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

所以,當我們想知道棧頂內容的時候,我們只需要檢視sp的當前值。OK,你可能想知道棧是如何工作的,現在我們用C語言實現它,那時相當easy。和ip一樣,我們也應該定義一個sp變數。記得把它賦為-1!再定義一個名為stack的陣列,程式碼如下:

int ip = 0;
int sp = -1;
int stack[256]; // 用陣列或適合此處的其它結構

// 其它C程式碼

現在如果我們想入棧一個值,我們先增加棧頂指標,接著設定當前sp處的值(我們剛剛增加的)。注意:這兩步的順序很重要!

// 壓棧5

// sp = -1
sp++; // sp = 0
stack[sp] = 5; // 棧頂現在變為5

所以,在我們的執行函式eval()裡,可以像這樣實現push指令:

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

現在你留意到,它和我們之前實現的eval()函式的一些不同。首先,我們把每個case語句塊放到大括號裡。你可能不太瞭解這種用法,它可以讓你在每條case的作用域裡定義變數。我們現在不需要定義變數,但將來會用到。它可以很簡單得讓所有的case語句塊保持風格一致。

其次是神奇的表示式program[++ip]。它做了什麼?呃,我們的程式儲存在一個陣列裡,PSH指令需要獲得一個運算元。運算元本質是一個引數,就像當你呼叫一個函式時,你可以給它傳遞一個引數。這種情況我們稱作壓棧數值5。我們可以通過增加指令指標(譯者注:一般也叫做程式計數器)ip來獲取運算元。當ip為0時,這意味著執行到了PSH指令,接下來我們希望取得下一條指令——即壓棧的數值。這可以通過ip自增的方法實現(注意:增加ip的位置十分重要,我們希望在取得指令前自增,否則我們只是拿到了PSH指令),接下來需要跳到下一條指令否則會引發奇怪的錯誤。當然我們也可以把sp++簡化到stack[++sp]裡。

對於POP指令,實現非常簡單。只需要減小棧頂指標,但是我一般希望能夠在出棧的時候列印出棧值。

我省略了實現其它指令的程式碼和swtich語句,僅列出POP指令的實現:

// 記得#include <stdio.h>!

case POP: {
    int val_popped = stack[sp--];
    printf("popped %d/n", val_popped);
    break;
}

現在,POP指令能夠工作了!我們剛剛做的只是把棧頂放到變數val_popped裡,接著棧頂指標減一。如果我們首先棧頂減一,那麼將得到一些無效值,因為sp可能取值為0,那麼我們可能把stack[-1]賦給val_popped,通常這不是一個好主意。

最後是ADD指令。這條指令可能要花費你一些腦細胞,同時這也是我們需要用大括號{}實現case語句內作用域的原因。

case ADD: {
    // 首先我們出棧,把數值存入變數a
    int a = stack[sp--];

    // 接著我們出棧,把數值存入變數b

    // 接著兩個變數相加,再把結果入棧
    int result = a + b;
    sp++; // 棧頂加1 **放在賦值之前**
    stack[sp] = result; // 設定棧頂值

    // 完成!
    break;
}

暫存器

暫存器是虛擬機器中的選配件。它很容易實現,我之前提到我們可能需要六個暫存器:A,B,C,D,E和F。和實現指令集一樣,我們也用一個列舉來實現它們。

typedef enum {
   A, B, C, D, E, F,
   NUM_OF_REGISTERS
} Registers;

小技巧:在列舉的最後,我們放了一個數 NUM_OF_REGISTERS。通過這個數我們可以獲取暫存器的個數,即便你又新增了額外的暫存器。現在我們需要一個陣列為我們的暫存器存放數值:

int registers[NUM_OF_REGISTERS];

接下來你可以像這樣取得暫存器內的值:

printf("%d/n", registers[A]); // 列印暫存器A的值

修訂

我沒有在暫存器花太多心思,但你應該能夠寫出一些操作暫存器的指令。比如,如果你想實現任何分支跳轉,你可以通過把指令指標(譯者注:或叫程式計數器)和/或棧頂指標存到暫存器裡,或者你可以實現分支指令。

前者實現起來比較快捷、簡單。我們可以這樣做,增加代表IP和SP的暫存器:

typedef enum {
    A, B, C, D, E, F, PC, SP,
    NUM_OF_REGISTERS
} Registers;

現在我們需要實現程式碼來使用指令指標和棧頂指標。一個簡單的辦法是刪掉上面定義的sp和ip變數,用巨集定義實現它們:

#define sp (registers[SP])
#define ip (registers[IP])   譯者注:此處應同Registers列舉中保持一致,IP應改為PC

這個修改恰到好處,你不需要重寫很多程式碼,同時它執行的很好。

額外的習題

但是,如何實現分支指令?

我把問題留給你!記住指令指標(程式計數器)指向當前指令,並且其數值儲存在一個暫存器裡。所以你需要寫一條指令設定暫存器的值,例如:SET REG value。接下來可以通過設定IP暫存器為某條指令的位置,進而跳轉到這條指令。如果你想看一個更復雜的例子,請訪問我的github程式碼庫,那裡有一個遞減某個值直到其為0的例子

這裡有一些練習題目,實現MOV指令:MOV REG_A, REG_B。換句話說,這條指令把數值從REG_A移到REG_B。同樣SET REG_A VALUE,會設定REG_A內容為VALUE。

你可以從github此處check out原始碼。如果你想看實現了MOV和SET指令的、更“高階”的虛擬機器,請check out bettervm.c檔案。你可以拿自己的實現和它作比較。如果你指向大體瀏覽一下程式碼,請先check out main.c。

好了!現在你拿到程式碼了。在根目錄下執行make,它會自動編譯,接下來執行./mac。

多謝閱讀本文!

相關文章