手把手教你構建 C 語言編譯器(8)
這是整個編譯器的最後一部分,解析表示式。什麼是表示式?表示式是將各種語言要素的一個組合,用來求值。例如:函式呼叫、變數賦值、運算子運算等等。
表示式的解析難點有二:一是運算子的優先順序問題,二是如何將表示式編譯成目的碼。我們就來逐一說明。
手把手教你構建 C 語言編譯器系列共有10個部分:
運算子的優先順序
運算子的優先順序決定了表示式的運算順序,如在普通的四則運算中,乘法 *
優先順序高於加法 +
,這就意味著表示式 2 + 3 * 4
的實際執行順序是 2 + (3 * 4)
而不是
(2 + 3) * 4
。
C 語言定義了各種表示式的優先順序,可以參考 C 語言運算子優先順序。
傳統的程式設計書籍會用“逆波蘭式”實現四則運算來講解優先順序問題。實際上,優先順序關心的就是哪個運算子先計算,哪個運算子後計算(畢竟叫做“優先順序”嘛)。而這就意味著我們需要決定先為哪個運算子生成目的碼(彙編),因為彙編程式碼是順序排列的,我們必須先計算優先順序高的運算子。
那麼如何確定運算子的優先順序呢?答曰:棧(遞迴呼叫的實質也是棧的處理)。
舉一個例子:2 + 3 - 4 * 5
,它的運算順序是這樣的:
- 將
2
入棧 - 遇到運算子
+
,入棧,此時我們期待的是+
的另一個引數 - 遇到數字
3
,原則上我們需要立即計算2+3
的值,但我們不確定數字3
是否屬於優先順序更高的運算子,所以先將它入棧。 - 遇到運算子
-
,它的優先順序和+
相同,此時判斷引數3
屬於這前的+
。將運算子+
出棧,並將之前的2
和3
出棧,計算2+3
的結果,得到5
入棧。同時將運算子-
入棧。 - 遇到數字
4
,同樣不能確定是否能立即計算,入棧 - 遇到運算子
*
優先順序大於-
,入棧 - 遇到數字
5
,依舊不能確定是否立即計算,入棧 - 表示式結束,運算子出棧,為
*
,將引數出棧,計算4*5
得到結果20
入棧。 - 運算子出棧,為
-
,將引數出棧,計算5-20
,得到-15
入棧。 - 此時運算子棧為空,因此得到結果
-15
。
// after step 1, 2 |
綜上,在計算一個運算子‘x’之前,必須先檢視它的右方,找出並計算所有優先順序大於‘x’的運算子,之後再計算運算子‘x’。
最後注意的是優先通常只與多元運算子相關,單元運算子往往沒有這個問題(因為只有一個引數)。也可以認為“優先順序”的實質就是兩個運算子在搶引數。
一元運算子
上節中說到了運算子的優先順序,也提到了優先順序一般只與多元運算子有關,這也意味著一元運算子的優先順序總是高於多元運算子。因為我們需要先對它們進行解析。
當然,這部分也將同時解析引數本身(如變數、數字、字串等等)。
關於表示式的解析,與語法分析相關的部分就是上文所說的優先順序問題了,而剩下的較難較煩的部分是與目的碼的生成有關的。因此對於需要講解的運算子,我們主要從它的目的碼入手。
#常量
首先是數字,用 IMM
指令將它載入到 AX
中即可:
if (token == Num) { |
接著是字串常量。它比較特殊的一點是 C 語言的字串常量支援如下風格:
char *p; |
即跨行的字串拼接,它相當於:
char *p; |
所以解析的時候要注意這一點:
else if (token == '"') { |
#sizeof
sizeof
是一個一元運算子,我們需要知道後面引數的型別,型別的解析在前面的文章中我們已經很熟悉了。
else if (token == Sizeof) { |
注意的是隻支援 sizeof(int)
,sizeof(char)
及 sizeof(pointer type...)
。並且它的結果是 int
型。
#變數與函式呼叫
由於取變數的值與函式的呼叫都是以 Id
標記開頭的,因此將它們放在一起處理。
else if (token == Id) { |
①中注意我們是順序將引數入棧,這和第三章:虛擬機器中講解的指令是對應的。與之不同,標準 C 是逆序將引數入棧的。
②中判斷函式的型別,同樣在第三章:“虛擬機器”中我們介紹過內建函式的支援,如
printf
, read
, malloc
等等。內建函式有對應的彙編指令,而普通的函式則編譯成 CALL <addr>
的形式。
③用於清除入棧的引數。因為我們不在乎出棧的值,所以直接修改棧指標的大小即可。
④:當該識別符號是全域性定義的列舉型別時,直接將對應的值用 IMM
指令存入 AX
即可。
⑤則是用於載入變數的值,如果是區域性變數則採用與 bp
指標相對位置的形式(參見第
7章函式定義)。而如果是全域性變數則用 IMM
載入變數的地址。
⑥:無論是全域性還是區域性變數,最終都根據它們的型別用 LC
或 LI
指令載入對應的值。
關於變數,你可能有疑問,如果遇到識別符號就用 LC/LI
載入相應的值,那諸如
a[10]
之類的表示式要如何實現呢?後面我們會看到,根據識別符號後的運算子,我們可能會修改或刪除現有的 LC/LI
指令。
#強制轉換
雖然我們前面沒有提到,但我們一直用 expr_type
來儲存一個表示式的型別,強制轉換的作用是獲取轉換的型別,並直接修改 expr_type
的值。
else if (token == '(') { |
#指標取值
諸如 *a
的指標取值,關鍵是判斷 a
的型別,而就像上節中提到的,當一個表示式解析結束時,它的型別儲存在變數 expr_type
中。
else if (token == Mul) { |
#取址操作
這裡我們就能看到“變數與函式呼叫”一節中所說的修改或刪除 LC/LI
指令了。前文中我們說到,對於變數,我們會先載入它的地址,並根據它們型別使用 LC/LI
指令載入實際內容,例如對變數 a
:
IMM <addr> |
那麼對變數 a
取址,其實只要不執行 LC/LI
即可。因此我們刪除相應的指令。
else if (token == And) { |
#邏輯取反
我們沒有直接的邏輯取反指令,因此我們判斷它是否與數字 0 相等。而數字 0 代表了邏輯 “False”。
else if (token == '!') { |
#按位取反
同樣我們沒有相應的指令,所以我們用異或來實現,即 ~a = a ^ 0xFFFF
。
else if (token == '~') { |
#正負號
注意這裡並不是四則運算中的加減法,而是單個數字的取正取負操作。同樣,我們沒有取負的操作,用 0 - x
來實現 -x
。
else if (token == Add) { |
#自增自減
注意的是自增自減操作的優先順序是和它的位置有關的。如 ++p
的優先順序高於 p++
,這裡我們解析的就是類似 ++p
的操作。
else if (token == Inc || token == Dec) { |
對應的彙編程式碼也比較直觀,只是在實現 ++p
時,我們要使用變數 p
的地址兩次,所以我們需要先 PUSH
(①)。
②則是因為自增自減操作還需要處理是指標的情形。
二元運算子
這裡,我們需要處理多運算子的優先順序問題,就如前文的“優先順序”一節提到的,我們需要不斷地向右掃描,直到遇到優先順序 小於 當前優先順序的運算子。
回想起我們之前定義過的各個標記,它們是以優先順序從低到高排列的,即 Assign
的優先順序最低,而 Brak
([
) 的優先順序最高。
enum { |
所以,當我們呼叫 expression(level)
進行解析的時候,我們其實通過了引數
level
指定了當前的優先順序。在前文的一元運算子處理中也用到了這一點。
所以,此時的二元運算子的解析的框架為:
while (token >= level) { |
解決了優先順序的問題,讓我們繼續講解如何把運算子編譯成彙編程式碼吧。
#賦值操作
賦值操作是優先順序最低的運算子。考慮諸如 a = (expession)
的表示式,在解析 =
之前,我們已經為變數 a
生成了如下的彙編程式碼:
IMM <addr> |
當解析完=
右邊的表示式後,相應的值會存放在 ax
中,此時,為了實際將這個值儲存起來,我們需要類似下面的彙編程式碼:
IMM <addr> |
明白了這點,也就能理解下面的原始碼了:
tmp = expr_type; |
#三目運算子
這是 C 語言中唯一的一個三元運算子: ? :
,它相當於一個小型的 If 語句,所以生成的程式碼也類似於 If 語句,這裡就不多作解釋。
else if (token == Cond) { |
#邏輯運算子
這包括 ||
和 &&
。它們對應的彙編程式碼如下:
<expr1> || <expr2> <expr1> && <expr2> |
所以原始碼如下:
else if (token == Lor) { |
#數學運算子
它們包括 |
, ^
, &
, ==
, !=
<=
, >=
, <
, >
, <<
, >>
, +
, -
,
*
, /
, %
。它們的實現都很類似,我們以異或 ^
為例:
<expr1> ^ <expr2> |
所以它對應的程式碼為:
else if (token == Xor) { |
其它的我們便不再詳述。但這當中還有一個問題,就是指標的加減。在 C 語言中,指標加上數值等於將指標移位,且根據不同的型別移動的位移不同。如 a + 1
,如果 a
是 char *
型,則移動一位元組,而如果 a
是 int *
型,則移動 4 個位元組(32位系統)。
另外,在作指標減法時,如果是兩個指標相減(相同型別),則結果是兩個指標間隔的元素個數。因此要有特殊的處理。
下面以加法為例,對應的彙編程式碼為:
<expr1> + <expr2> |
即當 <expr1>
是指標時,要根據它的型別放大 <expr2>
的值,因此對應的原始碼如下:
else if (token == Add) { |
相應的減法的程式碼就不貼了,可以自己實現看看,也可以看文末給出的連結。
#自增自減
這次是字尾形式的,即 p++
或 p--
。與字首形式不同的是,在執行自增自減後,
ax
上需要保留原來的值。所以我們首先執行類似字首自增自減的操作,再將 ax
中的值執行減/增的操作。
// 字首形式 生成彙編程式碼 |
#陣列取值操作
在學習 C 語言的時候你可能已經知道了,諸如 a[10]
的操作等價於 *(a + 10)
。因此我們要做的就是生成類似的彙編程式碼:
else if (token == Brak) { |
程式碼
除了上述對錶達式的解析外,我們還需要初始化虛擬機器的棧,我們可以正確呼叫
main
函式,且當 main
函式結束時退出程序。
int *tmp; |
當然,最後要注意的一點是:所有的變數定義必須放在語句之前。
本章的程式碼可以在 Github 上下載,也可以直接 clone
git clone -b step-6 https://github.com/lotabout/write-a-C-interpreter |
通過 gcc -o xc-tutor xc-tutor.c
進行編譯。並執行 ./xc-tutor hello.c
檢視結果。
正如我們保證的那樣,我們的程式碼是自舉的,能自己編譯自己,所以你可以執行
./xc-tutor xc-tutor.c hello.c
。可以看到和之前有同樣的輸出。
小結
本章我們進行了最後的解析,解析表示式。本章有兩個難點:
- 如何通過遞迴呼叫
expression
來實現運算子的優先順序。 - 如何為每個運算子生成對應的彙編程式碼。
儘管程式碼看起來比較簡單(雖然多),但其中用到的原理還是需要仔細推敲的。
最後,恭喜你!通過一步步的學習,自己實現了一個C語言的編譯器(好吧,是直譯器)。