1. 程式人生 > >手把手教你做一個 C 語言編譯器(2):虛擬機器

手把手教你做一個 C 語言編譯器(2):虛擬機器

本章是“手把手教你構建 C 語言編譯器”系列的第三篇,本章我們要構建一臺虛擬的電腦,設計我們自己的指令集,執行我們的指令集,說得通俗一點就是自己實現一套匯編語言。它們將作為我們的編譯器最終輸出的目的碼。

本系列:

計算機的內部工作原理

我們關心計算機的三個基本部件:CPU、暫存器及記憶體。程式碼(彙編指令)以二進位制的形式儲存在記憶體中,CPU 從中一條條地載入指令執行。程式執行的狀態儲存在暫存器中。

記憶體

我們從記憶體開始說起。現代的作業系統都不直接使用記憶體,而是使用虛擬記憶體。虛擬記憶體可以理解為一種對映,在我們的程式眼中,我們可以使用全部的記憶體地址,而作業系統需要將它對映到實際的記憶體上。當然,這些並不重要,重要的是一般而言,程序的記憶體會被分成幾個段:

  1. 程式碼段(text)用於存放程式碼(指令)。
  2. 資料段(data)用於存放初始化了的資料,如int i = 10;,就需要存放到資料段中。
  3. 未初始化資料段(bss)用於存放未初始化的資料,如 int i[1000];,因為不關心其中的真正數值,所以單獨存放可以節省空間,減少程式的體積。
  4. 棧(stack)用於處理函式呼叫相關的資料,如呼叫幀(calling frame)或是函式的區域性變數等。
  5. 堆(heap)用於為程式動態分配記憶體。

它們在記憶體中的位置類似於下圖:

12345678910111213141516 +------------------+|stack||high address|...v||||||||||...^||heap||+------------------+|bss  segment|+------------------+|data segment|+------------------+|text segment|low address+------------------+

但我們的虛擬機器並不模擬完整的計算機,我們只關心三個內容:程式碼段、資料段以及棧。其中的資料段我們只存放字串,因為我們的編譯器並不支援初始化變數,因此我們也不需要未初始化資料段。理論上我們的虛擬器需要維護自己的堆用於記憶體分配,但實際實現上較為複雜且與編譯無關,故我們引入一個指令MSET

,使我們能直接使用編譯器(直譯器)中的記憶體。

綜上,我們需要首先在全域性新增如下程式碼:

C
1234 int*text,// text segment*old_text,// for dump text segment*stack;// stackchar*data;// data segment

注意這裡的型別,雖然是int型,但理解起來應該作為無符號的整型,因為我們會在程式碼段(text)中存放如指標/記憶體地址的資料,它們就是無符號的。其中資料段(data)由於只存放字串,所以是 char * 型的

接著,在main函式中加入初始化程式碼,真正為其分配記憶體:

Shell
123456789101112131415161718192021222324 intmain(){close(fd);...//allocate memory forvirtual machineif(!(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 個暫存器,分別如下:

  1. PC 程式計數器,它存放的是一個記憶體地址,該地址中存放著 下一條 要執行的計算機指令。
  2. SP 指標暫存器,永遠指向當前的棧頂。注意的是由於棧是位於高地址並向低地址增長的,所以入棧時 SP 的值減小。
  3. BP 基址指標。也是用於指向棧的某些位置,在呼叫函式時會使用到它。
  4. AX 通用暫存器,我們的虛擬機器中,它用於存放一條指令執行後的結果。

要理解這些暫存器的作用,需要去理解程式執行中會有哪些狀態。而這些暫存器只是用於儲存這些狀態的。

在全域性中加入如下定義:

C
1 int*pc,*bp,*sp,ax,cycle;// virtual machine registers

在 main 函式中加入初始化程式碼,注意的是PC在初始應指向目的碼中的main函式,但我們還沒有寫任何編譯相關的程式碼,因此先不處理。程式碼如下:

Shell
12345678 memset(stack,0,poolsize);...bp=sp=(int*)((int)stack+poolsize);ax=0;...program();

與 CPU 相關的是指令集,我們將專門作為一個小節。

指令集

指令集是 CPU 能識別的命令的集合,也可以說是 CPU 能理解的語言。這裡我們要為我們的虛擬機器構建自己的指令集。它們基於 x86 的指令集,但要更為簡單。

首先在全域性變數中加入一個列舉型別,這是我們要支援的全部指令:

C
1234 // instructionsenum{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 個指令,這些指令只接受一個引數,如下:

  1. IMM <num> 將 <num> 放入暫存器 ax 中。
  2. LC 將對應地址中的字元載入 ax 中,要求 ax 中存放地址。
  3. LI 將對應地址中的整數載入 ax 中,要求 ax 中存放地址。
  4. SC 將 ax 中的資料作為字元存放入地址中,要求棧頂存放地址。
  5. SI 將 ax 中的資料作為整數存放入地址中,要求棧頂存放地址。

你可能會覺得將一個指令變成了許多指令,整個系統就變得複雜了,但實際情況並非如此。首先是 MOV 指令其實有許多變種,根據型別的不同有 MOVBMOVW 等指令,我們這裡的LC/SC 和 LI/SI 就是對應字元型和整型的存取操作。

但最為重要的是,通過將 MOV 指令拆分成這些指令,只有 IMM 需要有引數,且不需要判斷型別,所以大大簡化了實現的難度。

在 eval() 函式中加入下列程式碼:

C
12345678910111213 voideval(){intop,*tmp;while(1){if(op==IMM){ax=*pc++;}// load immediate value to axelseif(op==LC){ax=*(char*)ax;}// load character to ax, address in axelseif(op==LI){ax=*(int*)ax;}// load integer to ax, address in axelseif(op==SC){ax=*(char*)*sp++=ax;}// save character to address, value in ax, address on stackelseif(op==SI){*(int*)*sp++=ax;}// save integer to address, value in ax, address on stack}...return0;}

其中的 *sp++ 的作用是退棧,相當於 POP 操作。

這裡要解釋的一點是,為什麼 SI/SC 指令中,地址存放在棧中,而 LI/LC 中,地址存放在ax 中?原因是預設計算的結果是存放在 ax 中的,而地址通常是需要通過計算獲得,所以執行 LI/LC 時直接從 ax 取值會更高效。另一點是我們的 PUSH 指令只能將 ax 的值放到棧上,而不能以值作為引數,詳細見下文。

PUSH

在 x86 中,PUSH 的作用是將值或暫存器,而在我們的虛擬機器中,它的作用是將 ax 的值放入棧中。這樣做的主要原因是為了簡化虛擬機器的實現,並且我們也只有一個暫存器 ax 。程式碼如下:

C
1 elseif(op==PUSH){*--sp=ax;}// push the value of ax onto the stack

JMP

JMP <addr> 是跳轉指令,無條件地將當前的 PC 暫存器設定為指定的 <addr>,實現如下:

C
1 elseif(op==JMP){pc=(int*)*pc;}// jump to the address

要記得,pc 暫存器指向的是 下一條 指令。所以此時它存放的是 JMP 指令的引數,即<addr> 的值。

JZ/JNZ

為了實現 if 語句,我們需要條件判斷相關的指令。這裡我們只實現兩個最簡單的條件判斷,即結果(ax)為零或不為零情況下的跳轉。

實現如下:

C
1 elseif(op==JZ){pc=ax?pc+1:(int*)*pc;}// jump if ax is zero
C
1 elseif(op==JNZ){pc=ax?(int*)*pc:pc+1;}// jump if ax is zero

子函式呼叫

這是彙編中最難理解的部分,所以合在一起說,要引入的命令有 CALLENTADJ 及LEV

首先我們介紹 CALL <addr> 與 RET 指令,CALL 的作用是跳轉到地址為 <addr> 的子函式,RET 則用於從子函式中返回。

為什麼不能直接使用 JMP 指令呢?原因是當我們從子函式中返回時,程式需要回到跳轉之前的地方繼續執行,這就需要事先將這個位置資訊儲存起來。反過來,子函式要返回時,就需要獲取並恢復這個資訊。因此實際中我們將 PC 儲存在棧中。如下:

C
12 elseif(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 語言的呼叫標準是:

  1. 由呼叫者將引數入棧。
  2. 呼叫結束時,由呼叫者將引數出棧。
  3. 引數逆序入棧。

事先宣告一下,我們的編譯器引數是順序入棧的,下面的例子(C 語言呼叫標準)取自 維基百科

C
1234567

相關推薦

手把手一個 C 語言編譯器2虛擬機器

本章是“手把手教你構建 C 語言編譯器”系列的第三篇,本章我們要構建一臺虛擬的電腦,設計我們自己的指令集,執行我們的指令集,說得通俗一點就是自己實現一套匯編語言。它們將作為我們的編譯器最終輸出的目的碼。 本系列: 計算機的內部工作原理 我們關心計算機的三個基本部件

手把手一個 C 語言編譯器8表示式

這是整個編譯器的最後一部分,解析表示式。什麼是表示式?表示式是將各種語言要素的一個組合,用來求值。例如:函式呼叫、變數賦值、運算子運算等等。 表示式的解析難點有二:一是運算子的優先順序問題,二是如何將表示式編譯成目的碼。我們就來逐一說明。 本系列: 運算子的優先順

手把手一個 C 語言編譯器7語句

整個編譯器還剩下最後兩個部分:語句和表示式的解析。它們的內容比較多,主要涉及如何將語句和表示式編譯成彙編程式碼。這章講解語句的解析,相對於表示式來說它還是較為容易的。 本系列: 語句 C 語言區分“語句”(statement)和“表示式”(expression)兩

手把手一個 C 語言編譯器9總結

恭喜你完成了自己的 C 語言編譯器,本章中我們發一發牢騷,說一說編寫編譯器值得注意的一些問題;編寫編譯器時遇到的一些難題。 本系列: 虛擬機器與目的碼 整個系列的一開始,我們就著手虛擬機器的實現。不知道你是否有同感,這部分對於整個編譯器的編寫其實是十分重要的。我認

手把手一個 C 語言編譯器6函式定義

由於語法分析本身比較複雜,所以我們將它拆分成 3 個部分進行講解,分別是:變數定義、函式定義、表示式。本章講解函式定義相關的內容。 本系列: EBNF 表示 這是上一章的 EBNF 方法中與函式定義相關的內容。 C

手把手一個 C 語言編譯器4遞迴下降

本章我們將講解遞迴下降的方法,並用它完成一個基本的四則運算的語法分析器。 本系列: 什麼是遞迴下降 傳統上,編寫語法分析器有兩種方法,一種是自頂向下,一種是自底自上。自頂向下是從起始非終結符開始,不斷地對非終結符進行分解,直到匹配輸入的終結符;自底向上是不斷地將終

手把手一個 C 語言編譯器3詞法分析器

本章我們要講解如何構建詞法分析器。 本系列: 什麼是詞法分析器 簡而言之,詞法分析器用於對原始碼字串做預處理,以減少語法分析器的複雜程度。 詞法分析器以原始碼字串為輸入,輸出為標記流(token stream),即一連串的標記,每個標記通常包括: (token,

手把手一個 C 語言編譯器0前言

“手把手教你構建 C 語言編譯器” 這一系列教程將帶你從頭編寫一個 C 語言的編譯器。希望通過這個系列,我們能對編譯器的構建有一定的瞭解,同時,我們也將構建出一個能用的 C 語言編譯器,儘管有許多語法並不支援。 在開始進入正題之前,本篇是一些閒聊,談談這個系列的初衷

手把手一個 C 語言編譯器1設計

本章是“手把手教你構建 C 語言編譯器”系列的第二篇,我們要從整體上講解如何設計我們的 C 語言編譯器。 本系列: 首先要說明的是,雖然標題是編譯器,但實際上我們構建的是 C 語言的直譯器,這意味著我們可以像執行指令碼一樣去執行 C 語言的原始碼檔案。這麼做的理由

手把手一個 C 語言編譯器5變數定義

本章中我們用 EBNF 來大致描述我們實現的 C 語言的文法,並實現其中解析變數定義部分。 由於語法分析本身比較複雜,所以我們將它拆分成 3 個部分進行講解,分別是:變數定義、函式定義、表示式。 本系列: EBNF 表示 EBNF 是對前一章提到的 BNF 的擴充

手把手構建 C 語言編譯器2

本章是“手把手教你構建 C 語言編譯器”系列的第三篇,本章我們要構建一臺虛擬的電腦,設計我們自己的指令集,執行我們的指令集,說得通俗一點就是自己實現一套匯編語言。它們將作為我們的編譯器最終輸出的目的碼。 手把手教你構建 C 語言編譯器系列共有10個部分: #計算機的內部工作原理 計算機中有三個基本部件需要

手把手藍芽聊天應用-藍芽連線模組

第4節 藍芽連線模組 藍芽連線的管理模組需要為ChatActivity提供於連線相關的所有功能,要設計的方便使用,並儘量隱藏連線的細節。 4.1 對外介面 我們首先來看看ConnectionManager需要向Chat Activity提供哪些介面。

手把手構建 C 語言編譯器6

由於語法分析本身比較複雜,所以我們將它拆分成 3 個部分進行講解,分別是:變數定義、函式定義、表示式。本章講解函式定義相關的內容。 手把手教你構建 C 語言編譯器系列共有10個部分: EBNF 表示 這是上一章的 EBNF 方法中與函式定義相關的內容。 variable_decl ::= type {'*

手把手構建 C 語言編譯器4

本章我們將講解遞迴下降的方法,並用它完成一個基本的四則運算的語法分析器。 手把手教你構建 C 語言編譯器系列共有10個部分: 什麼是遞迴下降 傳統上,編寫語法分析器有兩種方法,一種是自頂向下,一種是自底向上。自頂向下是從起始非終結符開始,不斷地對非終結符進行分解,直到匹配輸入的終結符;自底向上是不斷地將終

手把手構建 C 語言編譯器8

這是整個編譯器的最後一部分,解析表示式。什麼是表示式?表示式是將各種語言要素的一個組合,用來求值。例如:函式呼叫、變數賦值、運算子運算等等。 表示式的解析難點有二:一是運算子的優先順序問題,二是如何將表示式編譯成目的碼。我們就來逐一說明。 手把手教你構建 C 語言編譯器系列共有10個部分: 運算子的優先順

手把手構建 C 語言編譯器5

本章中我們用 EBNF 來大致描述我們實現的 C 語言的文法,並實現其中解析變數定義部分。 由於語法分析本身比較複雜,所以我們將它拆分成 3 個部分進行講解,分別是:變數定義、函式定義、表示式。 手把手教你構建 C 語言編譯器系列共有10個部分: EBNF 表示 EBNF 是對前一章提到的 BNF 的擴充

手把手構建 C 語言編譯器9

恭喜你完成了自己的 C 語言編譯器,本章中我們發一發牢騷,說一說編寫編譯器值得注意的一些問題;編寫編譯器時遇到的一些難題。 手把手教你構建 C 語言編譯器系列共有10個部分: 虛擬機器與目的碼 整個系列的一開始,我們就著手虛擬機器的實現。不知道你是否有同感,這部分對於整個編譯器的編寫其實是十分重要的。我認

手把手構建 C 語言編譯器1

本章是“手把手教你構建 C 語言編譯器”系列的第二篇,我們要從整體上講解如何設計我們的 C 語言編譯器。 手把手教你構建 C 語言編譯器系列共有10個部分: 首先要說明的是,雖然標題是編譯器,但實際上我們構建的是 C 語言的直譯器,這意味著我們可以像執行指令碼一樣去執行 C 語言的原始碼檔案。這麼做的理由

手把手構建 C 語言編譯器0

“手把手教你構建 C 語言編譯器” 這一系列教程將帶你從頭編寫一個 C 語言的編譯器。希望通過這個系列,我們能對編譯器的構建有一定的瞭解,同時,我們也將構建出一個能用的 C 語言編譯器,儘管有許多語法並不支援。 手把手教你構建 C 語言編譯器系列共有10個部分: 在開始進入正題之前,本篇是一些閒聊,談談這

手把手構建 C 語言編譯器3

本章我們要講解如何構建詞法分析器。 手把手教你構建 C 語言編譯器系列共有10個部分: 什麼是詞法分析器 簡而言之,詞法分析器用於對原始碼字串做預處理,以減少語法分析器的複雜程度。 詞法分析器以原始碼字串為輸入,輸出為標記流(token stream),即一連串的標記,每個標記通常包括: (token,