手把手教你構建 C 語言編譯器(3)
本章我們要講解如何構建詞法分析器。
手把手教你構建 C 語言編譯器系列共有10個部分:
什麼是詞法分析器
簡而言之,詞法分析器用於對原始碼字串做預處理,以減少語法分析器的複雜程度。
詞法分析器以原始碼字串為輸入,輸出為標記流(token stream),即一連串的標記,每個標記通常包括: (token, token value)
即標記本身和標記的值。例如,原始碼中若包含一個數字 '998'
,詞法分析器將輸出 (Number, 998)
,即(數字,998)。再例如:
2 + 3 * (4 - 5) |
通過詞法分析器的預處理,語法分析器的複雜度會大大降低,這點在後面的語法分析器我們就能體會。
詞法分析器與編譯器
要是深入詞法分析器,你就會發現,它的本質上也是編譯器。我們的編譯器是以標記流為輸入,輸出彙編程式碼,而詞法分析器則是以原始碼字串為輸入,輸出標記流。
+-------+ +--------+ |
在這個前提下,我們可以這樣認為:直接從原始碼編譯成彙編程式碼是很困難的,因為輸入的字串比較難處理。所以我們先編寫一個較為簡單的編譯器(詞法分析器)來將字串轉換成標記流,而標記流對於語法分析器而言就容易處理得多了。
詞法分析器的實現
由於詞法分析的工作很常見,但又枯燥且容易出錯,所以人們已經開發出了許多工具來生成詞法分析器,如 lex, flex
。這些工具允許我們通過正則表示式來識別標記。
這裡注意的是,我們並不會一次性地將所有原始碼全部轉換成標記流,原因有二:
- 字串轉換成標記流有時是有狀態的,即與程式碼的上下文是有關係的。
- 儲存所有的標記流沒有意義且浪費空間。
所以實際的處理方法是提供一個函式(即前幾篇中提到的 next()
#支援的標記
在全域性中新增如下定義:
// tokens and classes (operators last and in precedence order) |
這些就是我們要支援的標記符。例如,我們會將 =
解析為 Assign
;將 ==
解析為
Eq
;將 !=
解析為 Ne
等等。
所以這裡我們會有這樣的印象,一個標記(token)可能包含多個字元,且多數情況下如此。而詞法分析器能減小語法分析複雜度的原因,正是因為它相當於通過一定的編碼(更多的標記)來壓縮了原始碼字串。
當然,上面這些標記是有順序的,跟它們在 C 語言中的優先順序有關,如 *(Mul)
的優先順序就要高於 +(Add)
。它們的具體使用在後面的語法分析中會提到。
最後要注意的是還有一些字元,它們自己就構成了標記,如右方括號 ]
或波浪號
~
等。我們不另外處理它們的原因是:
- 它們是單字元的,即並不是多個字元共同構成標記(如
==
需要兩個字元); - 它們不涉及優先順序關係。
#詞法分析器的框架
即 next()
函式的主體:
void next() { |
這裡的一個問題是,為什麼要用 while
迴圈呢?這就涉及到編譯器(記得我們說過詞法分析器也是某種意義上的編譯器)的一個問題:如何處理錯誤?
對詞法分析器而言,若碰到了一個我們不認識的字元該怎麼處理?一般處理的方法有兩種:
- 指出錯誤發生的位置,並退出整個程式
- 指出錯誤發生的位置,跳過當前錯誤並繼續編譯
這個 while
迴圈的作用就是跳過這些我們不識別的字元,我們同時還用它來處理空白字元。我們知道,C 語言中空格是用來作為分隔用的,並不作為語法的一部分。因此在實現中我們將它作為“不識別”的字元,這個 while
迴圈可以用來跳過它。
#換行符
換行符和空格類似,但有一點不同,每次遇到換行符,我們需要將當前的行號加一:
// parse token here |
#巨集定義
C 語言的巨集定義以字元 #
開頭,如 # include <stdio.h>
。我們的編譯器並不支援巨集定義,所以直接跳過它們。
else if (token == '#') { |
#識別符號與符號表
識別符號(identifier)可以理解為變數名。對於語法分析而言,我們並不關心一個變數具體叫什麼名字,而只關心這個變數名代表的唯一標識。例如 int a;
定義了變數
a
,而之後的語句 a = 10
,我們需要知道這兩個 a
指向的是同一個變數。
基於這個理由,詞法分析器會把掃描到的識別符號全都儲存到一張表中,遇到新的識別符號就去查這張表,如果識別符號已經存在,就返回它的唯一標識。
那麼我們怎麼表示識別符號呢?如下:
struct identifier { |
這裡解釋一下具體的含義:
token
:該識別符號返回的標記,理論上所有的變數返回的標記都應該是Id
,但實際上由於我們還將在符號表中加入關鍵字如if
,while
等,它們都有對應的標記。hash
:顧名思義,就是這個識別符號的雜湊值,用於識別符號的快速比較。name
:存放識別符號本身的字串。class
:該識別符號的類別,如數字,全域性變數或區域性變數等。type
:識別符號的型別,即如果它是個變數,變數是int
型、char
型還是指標型。value
:存放這個識別符號的值,如識別符號是函式,剛存放函式的地址。BXXXX
:C 語言中識別符號可以是全域性的也可以是區域性的,當局部識別符號的名字與全域性識別符號相同時,用作儲存全域性識別符號的資訊。
由上可以看出,我們實現的詞法分析器與傳統意義上的詞法分析器不太相同。傳統意義上的符號表只需要知道識別符號的唯一標識即可,而我們還存放了一些只有語法分析器才會得到的資訊,如 type
。
由於我們的目標是能自舉,而我們定義的語法不支援 struct
,故而使用下列方式。
Symbol table: |
即用一個整型陣列來儲存相關的ID資訊。每個ID佔用陣列中的9個空間,分析識別符號的相關程式碼如下:
int token_val; // value of current token (mainly for number) |
查詢已有識別符號的方法是線性查詢 symbols
表。
#數字
數字中較為複雜的一點是需要支援十進位制、十六進位制及八進位制。邏輯也較為直接,可能唯一不好理解的是獲取十六進位制的值相關的程式碼。
token_val = token_val * 16 + (token & 15) + (token >= 'A' ? 9 : 0); |
這裡要注意的是在ASCII碼中,字元a
對應的十六進位制值是 61
, A
是41
,故通過
(token & 15)
可以得到個位數的值。其它就不多說了,這裡這樣寫的目的是裝B(其實是抄 c4 的原始碼的)。
void next() { |
#字串
在分析時,如果分析到字串,我們需要將它存放到前一篇文章中說的 data
段中。然後返回它在 data
段中的地址。另一個特殊的地方是我們需要支援轉義符。例如用
\n
表示換行符。由於本編譯器的目的是達到自己編譯自己,所以程式碼中並沒有支援除
\n
的轉義符,如 \t
, \r
等,但仍支援 \a
表示字元 a
的語法,如 \"
表示 "
。
在分析時,我們將同時分析單個字元如 'a'
和字串如 "a string"
。若得到的是單個字元,我們以 Num
的形式返回。相關程式碼如下:
void next() { |
#註釋
在我們的 C 語言中,只支援 //
型別的註釋,不支援 /* comments */
的註釋。
void next() { |
這裡我們要額外介紹 lookahead
的概念,即提前看多個字元。上述程式碼中我們看到,除了跳過註釋,我們還可能返回除號 /(Div)
標記。
提前看字元的原理是:有一個或多個標記是以同樣的字元開頭的(如本小節中的註釋與除號),因此只憑當前的字元我們並無法確定具體應該解釋成哪一個標記,所以只能再向前檢視字元,如本例需向前檢視一個字元,若是 /
則說明是註釋,反之則是除號。
我們之前說過,詞法分析器本質上也是編譯器,其實提前看字元的概念也存在於編譯器,只是這時就是提前看k個“標記”而不是“字元”了。平時聽到的 LL(k)
中的 k
就是需要向前看的標記的個數了。
另外,我們用詞法分析器將原始碼轉換成標記流,能減小語法分析複雜度,原因之一就是減少了語法分析器需要“向前看”的字元個數。
#其它
其它的標記的解析就相對容易一些了,我們直接貼上程式碼:
void next() { |
程式碼較多,但主要邏輯就是向前看一個字元來確定真正的標記。
#關鍵字與內建函式
雖然上面寫完了詞法分析器,但還有一個問題需要考慮,那就是“關鍵字”,例如 if
,
while
, return
等。它們不能被作為普通的識別符號,因為有特殊的含義。
一般有兩種處理方法:
- 詞法分析器中直接解析這些關鍵字。
- 在語法分析前將關鍵字提前加入符號表。
這裡我們就採用第二種方法,將它們加入符號表,並提前為它們賦予必要的資訊(還記得前面說的識別符號 Token
欄位嗎?)。這樣當原始碼中出現關鍵字時,它們會被解析成識別符號,但由於符號表中已經有了相關的資訊,我們就能知道它們是特殊的關鍵字。
內建函式的行為也和關鍵字類似,不同的只是賦值的資訊,在main
函式中進行初始化如下:
// types of variable/function |
程式碼
本章的程式碼可以在 Github 上下載,也可以直接 clone
git clone -b step-2 https://github.com/lotabout/write-a-C-interpreter |
上面的程式碼執行後會出現 ‘Segmentation Falt’,這是正常的,因為它會嘗試執行我們上一章建立的虛擬機器,但其中並沒有任何彙編程式碼。
小結
本章我們為我們的編譯器構建了詞法分析器,通過本章的學習,我認為有幾個要點需要強調:
- 詞法分析器的作用是對原始碼字串進行預處理,作用是減小語法分析器的複雜程度。
- 詞法分析器本身可以認為是一個編譯器,輸入是原始碼,輸出是標記流。
lookahead(k)
的概念,即向前看k
個字元或標記。- 詞法分析中如何處理識別符號與符號表。
下一章中,我們將介紹遞迴下降的語法分析器。我們下一章見。