本章是“手把手教你構建 C 語言編譯器”系列的第三篇,本章我們要構建一臺虛擬的電腦,設計我們自己的指令集,執行我們的指令集,說得通俗一點就是自己實現一套匯編語言。它們將作為我們的編譯器最終輸出的目的碼。
本系列:
計算機的內部工作原理
我們關心計算機的三個基本部件:CPU、暫存器及記憶體。程式碼(彙編指令)以二進位制的形式儲存在記憶體中,CPU 從中一條條地載入指令執行。程式執行的狀態儲存在暫存器中。
記憶體
我們從記憶體開始說起。現代的作業系統都不直接使用記憶體,而是使用虛擬記憶體。虛擬記憶體可以理解為一種對映,在我們的程式眼中,我們可以使用全部的記憶體地址,而作業系統需要將它對映到實際的記憶體上。當然,這些並不重要,重要的是一般而言,程式的記憶體會被分成幾個段:
- 程式碼段(text)用於存放程式碼(指令)。
- 資料段(data)用於存放初始化了的資料,如
int i = 10;
,就需要存放到資料段中。 - 未初始化資料段(bss)用於存放未初始化的資料,如
int i[1000];
,因為不關心其中的真正數值,所以單獨存放可以節省空間,減少程式的體積。 - 棧(stack)用於處理函式呼叫相關的資料,如呼叫幀(calling frame)或是函式的區域性變數等。
- 堆(heap)用於為程式動態分配記憶體。
它們在記憶體中的位置類似於下圖:
|
但我們的虛擬機器並不模擬完整的計算機,我們只關心三個內容:程式碼段、資料段以及棧。其中的資料段我們只存放字串,因為我們的編譯器並不支援初始化變數,因此我們也不需要未初始化資料段。理論上我們的虛擬器需要維護自己的堆用於記憶體分配,但實際實現上較為複雜且與編譯無關,故我們引入一個指令MSET
,使我們能直接使用編譯器(直譯器)中的記憶體。
綜上,我們需要首先在全域性新增如下程式碼:
1 2 3 4 |
int *text, // text segment *old_text, // for dump text segment *stack; // stack char *data; // data segment |
注意這裡的型別,雖然是int
型,但理解起來應該作為無符號的整型,因為我們會在程式碼段(text)中存放如指標/記憶體地址的資料,它們就是無符號的。其中資料段(data)由於只存放字串,所以是 char *
型的
接著,在main
函式中加入初始化程式碼,真正為其分配記憶體:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
int main() { close(fd); ... // allocate memory for virtual machine if (!(text = old_text = malloc(poolsize))) { printf("could not malloc(%d) for text area\n", poolsize); return -1; } if (!(data = malloc(poolsize))) { printf("could not malloc(%d) for data area\n", poolsize); return -1; } if (!(stack = malloc(poolsize))) { printf("could not malloc(%d) for stack area\n", poolsize); return -1; } memset(text, 0, poolsize); memset(data, 0, poolsize); memset(stack, 0, poolsize); ... program(); |
暫存器
計算機中的暫存器用於存放計算機的執行狀態,真正的計算機中有許多不同種類的暫存器,但我們的虛擬機器中只使用 4 個暫存器,分別如下:
PC
程式計數器,它存放的是一個記憶體地址,該地址中存放著 下一條 要執行的計算機指令。SP
指標暫存器,永遠指向當前的棧頂。注意的是由於棧是位於高地址並向低地址增長的,所以入棧時SP
的值減小。BP
基址指標。也是用於指向棧的某些位置,在呼叫函式時會使用到它。AX
通用暫存器,我們的虛擬機器中,它用於存放一條指令執行後的結果。
要理解這些暫存器的作用,需要去理解程式執行中會有哪些狀態。而這些暫存器只是用於儲存這些狀態的。
在全域性中加入如下定義:
1 |
int *pc, *bp, *sp, ax, cycle; // virtual machine registers |
在 main
函式中加入初始化程式碼,注意的是PC
在初始應指向目的碼中的main
函式,但我們還沒有寫任何編譯相關的程式碼,因此先不處理。程式碼如下:
1 2 3 4 5 6 7 8 |
memset(stack, 0, poolsize); ... bp = sp = (int *)((int)stack + poolsize); ax = 0; ... program(); |
與 CPU 相關的是指令集,我們將專門作為一個小節。
指令集
指令集是 CPU 能識別的命令的集合,也可以說是 CPU 能理解的語言。這裡我們要為我們的虛擬機器構建自己的指令集。它們基於 x86 的指令集,但要更為簡單。
首先在全域性變數中加入一個列舉型別,這是我們要支援的全部指令:
1 2 3 4 |
// instructions enum { LEA ,IMM ,JMP ,CALL,JZ ,JNZ ,ENT ,ADJ ,LEV ,LI ,LC ,SI ,SC ,PUSH, OR ,XOR ,AND ,EQ ,NE ,LT ,GT ,LE ,GE ,SHL ,SHR ,ADD ,SUB ,MUL ,DIV ,MOD , OPEN,READ,CLOS,PRTF,MALC,MSET,MCMP,EXIT }; |
這些指令的順序安排是有意的,稍後你會看到,帶有引數的指令在前,沒有引數的指令在後。這種順序的唯一作用就是在列印除錯資訊時更加方便。但我們講解的順序並不依據它。
MOV
MOV
是所有指令中最基礎的一個,它用於將資料放進暫存器或記憶體地址,有點類似於 C 語言中的賦值語句。x86 的 MOV
指令有兩個引數,分別是源地址和目標地址:MOV dest, source
(Intel 風格),表示將 source
的內容放在 dest
中,它們可以是一個數、暫存器或是一個記憶體地址。
一方面,我們的虛擬機器只有一個暫存器,另一方面,識別這些引數的型別(是數還是地址)是比較困難的,因此我們將 MOV
指令拆分成 5 個指令,這些指令只接受一個引數,如下:
IMM <num>
將<num>
放入暫存器ax
中。LC
將對應地址中的字元載入ax
中,要求ax
中存放地址。LI
將對應地址中的整數載入ax
中,要求ax
中存放地址。SC
將ax
中的資料作為字元存放入地址中,要求棧頂存放地址。SI
將ax
中的資料作為整數存放入地址中,要求棧頂存放地址。
你可能會覺得將一個指令變成了許多指令,整個系統就變得複雜了,但實際情況並非如此。首先是 MOV
指令其實有許多變種,根據型別的不同有 MOVB
, MOVW
等指令,我們這裡的LC/SC
和 LI/SI
就是對應字元型和整型的存取操作。
但最為重要的是,通過將 MOV
指令拆分成這些指令,只有 IMM
需要有引數,且不需要判斷型別,所以大大簡化了實現的難度。
在 eval()
函式中加入下列程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
void eval() { int op, *tmp; while (1) { if (op == IMM) {ax = *pc++;} // load immediate value to ax else if (op == LC) {ax = *(char *)ax;} // load character to ax, address in ax else if (op == LI) {ax = *(int *)ax;} // load integer to ax, address in ax else if (op == SC) {ax = *(char *)*sp++ = ax;} // save character to address, value in ax, address on stack else if (op == SI) {*(int *)*sp++ = ax;} // save integer to address, value in ax, address on stack } ... return 0; } |
其中的 *sp++
的作用是退棧,相當於 POP
操作。
這裡要解釋的一點是,為什麼 SI/SC
指令中,地址存放在棧中,而 LI/LC
中,地址存放在ax
中?原因是預設計算的結果是存放在 ax
中的,而地址通常是需要通過計算獲得,所以執行 LI/LC
時直接從 ax
取值會更高效。另一點是我們的 PUSH
指令只能將 ax
的值放到棧上,而不能以值作為引數,詳細見下文。
PUSH
在 x86 中,PUSH
的作用是將值或暫存器,而在我們的虛擬機器中,它的作用是將 ax
的值放入棧中。這樣做的主要原因是為了簡化虛擬機器的實現,並且我們也只有一個暫存器 ax
。程式碼如下:
1 |
else if (op == PUSH) {*--sp = ax;} // push the value of ax onto the stack |
JMP
JMP <addr>
是跳轉指令,無條件地將當前的 PC
暫存器設定為指定的 <addr>
,實現如下:
1 |
else if (op == JMP) {pc = (int *)*pc;} // jump to the address |
要記得,pc
暫存器指向的是 下一條 指令。所以此時它存放的是 JMP
指令的引數,即<addr>
的值。
JZ/JNZ
為了實現 if
語句,我們需要條件判斷相關的指令。這裡我們只實現兩個最簡單的條件判斷,即結果(ax
)為零或不為零情況下的跳轉。
實現如下:
1 |
else if (op == JZ) {pc = ax ? pc + 1 : (int *)*pc;} // jump if ax is zero |
1 |
else if (op == JNZ) {pc = ax ? (int *)*pc : pc + 1;} // jump if ax is zero |
子函式呼叫
這是彙編中最難理解的部分,所以合在一起說,要引入的命令有 CALL
, ENT
, ADJ
及LEV
。
首先我們介紹 CALL <addr>
與 RET
指令,CALL
的作用是跳轉到地址為 <addr>
的子函式,RET
則用於從子函式中返回。
為什麼不能直接使用 JMP
指令呢?原因是當我們從子函式中返回時,程式需要回到跳轉之前的地方繼續執行,這就需要事先將這個位置資訊儲存起來。反過來,子函式要返回時,就需要獲取並恢復這個資訊。因此實際中我們將 PC
儲存在棧中。如下:
1 2 |
else if (op == CALL) {*--sp = (int)(pc+1); pc = (int *)*pc;} // call subroutine //else if (op == RET) {pc = (int *)*sp++;} // return from subroutine; |
這裡我們把 RET
相關的內容註釋了,是因為之後我們將用 LEV
指令來代替它。
在實際呼叫函式時,不僅要考慮函式的地址,還要考慮如何傳遞引數和如何返回結果。這裡我們約定,如果子函式有返回結果,那麼就在返回時儲存在 ax
中,它可以是一個值,也可以是一個地址。那麼引數的傳遞呢?
各種程式語言關於如何呼叫子函式有不同的約定,例如 C 語言的呼叫標準是:
- 由呼叫者將引數入棧。
- 呼叫結束時,由呼叫者將引數出棧。
- 引數逆序入棧。
事先宣告一下,我們的編譯器引數是順序入棧的,下面的例子(C 語言呼叫標準)取自 維基百科:
1 2 3 4 5 6 7 8 9 10 |
int callee(int, int, int); int caller(void) { int i, ret; ret = callee(1, 2, 3); ret += 5; return ret; } |
會生成如下的 x86 彙編程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
caller: ; make new call frame push ebp mov ebp, esp sub 1, esp ; save stack for variable: i ; push call arguments push 3 push 2 push 1 ; call subroutine 'callee' call callee ; remove arguments from frame add esp, 12 ; use subroutine result add eax, 5 ; restore old call frame mov esp, ebp pop ebp ; return ret |
上面這段程式碼在我們自己的虛擬機器裡會有幾個問題:
push ebp
,但我們的PUSH
指令並無法指定暫存器。mov ebp, esp
,我們的MOV
指令同樣功能不足。add esp, 12
,也是一樣的問題(儘管我們還沒定義)。
也就是說由於我們的指令過於簡單(如只能操作ax
暫存器),所以用上面提到的指令,我們連函式呼叫都無法實現。而我們又不希望擴充現有指令的功能,因為這樣實現起來就會變得複雜,因此我們採用的方法是增加指令集。畢竟我們不是真正的計算機,增加指令會消耗許多資源(錢)。
ENT
ENT <size>
指的是 enter
,用於實現 ‘make new call frame’ 的功能,即儲存當前的棧指標,同時在棧上保留一定的空間,用以存放區域性變數。對應的彙編程式碼為:
1 2 3 4 |
; make new call frame push ebp mov ebp, esp sub 1, esp ; save stack for variable: i |
實現如下:
1 |
else if (op == ENT) {*--sp = (int)bp; bp = sp; sp = sp - *pc++;} // make new stack frame |
ADJ
ADJ <size>
用於實現 ‘remove arguments from frame’。在將呼叫子函式時壓入棧中的資料清除,本質上是因為我們的 ADD
指令功能有限。對應的彙編程式碼為:
1 2 |
; remove arguments from frame add esp, 12 |
實現如下:
1 |
else if (op == ADJ) {sp = sp + *pc++;} // add esp, <size> |
LEV
本質上這個指令並不是必需的,只是我們的指令集中並沒有 POP
指令。並且三條指令寫來比較麻煩且浪費空間,所以用一個指令代替。對應的彙編指令為:
1 2 3 4 5 |
; restore old call frame mov esp, ebp pop ebp ; return ret |
具體的實現如下:
1 |
else if (op == LEV) {sp = bp; bp = (int *)*sp++; pc = (int *)*sp++;} // restore call frame and PC |
注意的是,LEV
已經把 RET
的功能包含了,所以我們不再需要 RET
指令。
LEA
上面的一些指令解決了呼叫幀的問題,但還有一個問題是如何在子函式中獲得傳入的引數。這裡我們首先要了解的是當引數呼叫時,棧中的呼叫幀是什麼樣的。我們依舊用上面的例子(只是現在用“順序”呼叫引數):
|
所以為了獲取第一個引數,我們需要得到 new_bp + 4
,但就如上面的說,我們的 ADD
指令無法操作除 ax
外的暫存器,所以我們提供了一個新的指令:LEA <offset>
實現如下:
1 |
else if (op == LEA) {ax = (int)(bp + *pc++);} // load address for arguments. |
以上就是我們為了實現函式呼叫需要的指令了。
運算子指令
我們為 C 語言中支援的運算子都提供對應彙編指令。每個運算子都是二元的,即有兩個引數,第一個引數放在棧頂,第二個引數放在 ax
中。這個順序要特別注意。因為像 -
,/
之類的運算子是與引數順序有關的。計算後會將棧頂的引數退棧,結果存放在暫存器 ax
中。因此計算結束後,兩個引數都無法取得了(彙編的意義上,存在記憶體地址上就另當別論)。
實現如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
else if (op == OR) ax = *sp++ | ax; else if (op == XOR) ax = *sp++ ^ ax; else if (op == AND) ax = *sp++ & ax; else if (op == EQ) ax = *sp++ == ax; else if (op == NE) ax = *sp++ != ax; else if (op == LT) ax = *sp++ < ax; else if (op == LE) ax = *sp++ <= ax; else if (op == GT) ax = *sp++ > ax; else if (op == GE) ax = *sp++ >= ax; else if (op == SHL) ax = *sp++ << ax; else if (op == SHR) ax = *sp++ >> ax; else if (op == ADD) ax = *sp++ + ax; else if (op == SUB) ax = *sp++ - ax; else if (op == MUL) ax = *sp++ * ax; else if (op == DIV) ax = *sp++ / ax; else if (op == MOD) ax = *sp++ % ax; |
內建函式
程式要有用,除了核心的邏輯外還需要輸入輸出,如 C 語言中我們經常使用的 printf
函式就是用於輸出。但是 printf
函式的實現本身就十分複雜,如果我們的編譯器要達到自舉,就勢必要實現 printf
之類的函式,但它又與編譯器沒有太大的聯絡,因此我們繼續實現新的指令,從虛擬機器的角度予以支援。
編譯器中我們需要用到的函式有:exit
, open
, close
, read
, printf
, malloc
, memset
及memcmp
。程式碼如下:
1 2 3 4 5 6 7 8 |
else if (op == EXIT) { printf("exit(%d)", *sp); return *sp;} else if (op == OPEN) { ax = open((char *)sp[1], sp[0]); } else if (op == CLOS) { ax = close(*sp);} else if (op == READ) { ax = read(sp[2], (char *)sp[1], *sp); } else if (op == PRTF) { tmp = sp + pc[1]; ax = printf((char *)tmp[-1], tmp[-2], tmp[-3], tmp[-4], tmp[-5], tmp[-6]); } else if (op == MALC) { ax = (int)malloc(*sp);} else if (op == MSET) { ax = (int)memset((char *)sp[2], sp[1], *sp);} else if (op == MCMP) { ax = memcmp((char *)sp[2], (char *)sp[1], *sp);} |
這裡的原理是,我們的電腦上已經有了這些函式的實現,因此編譯編譯器時,這些函式的二進位制程式碼就被編譯進了我們的編譯器,因此在我們的編譯器/虛擬機器上執行我們提供的這些指令時,這些函式就是可用的。換句話說就是不需要我們自己去實現了。
最後再加上一個錯誤判斷:
1 2 3 4 |
else { printf("unknown instruction:%d\n", op); return -1; } |
測試
下面我們用我們的彙編寫一小段程式,來計算 10+20
,在 main
函式中加入下列程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
int main(int argc, char *argv[]) { ax = 0; ... i = 0; text[i++] = IMM; text[i++] = 10; text[i++] = PUSH; text[i++] = IMM; text[i++] = 20; text[i++] = ADD; text[i++] = PUSH; text[i++] = EXIT; pc = text; ... program(); } |
編譯程式 gcc xc-tutor.c
,執行程式:./a.out hello.c
。輸出
1 |
exit(30) |
注意我們的之前的程式需要指令一個原始檔,只是現在還用不著,但從結果可以看出,我們的虛擬機器還是工作良好的。
小結
本章中我們回顧了計算機的內部執行原理,並仿照 x86 彙編指令設計並實現了我們自己的指令集。
本章的程式碼可以在 Github 上下載,也可以直接 clone
1 |
git clone -b step-1 https://github.com/lotabout/write-a-C-interpreter |
實際計算機中,新增一個新的指令需要設計許多新的電路,會增加許多的成本,但我們的需要機中,新的指令幾乎不消耗資源,因此我們可以利用這一點,用更多的指令來完成更多的功能,從而簡化具體的實現。
打賞支援我寫出更多好文章,謝謝!
打賞作者
打賞支援我寫出更多好文章,謝謝!
任選一種支付方式