陳鍾誠

Version 1.0

編譯器設計

編譯器是將高階語言轉換為《組合語言或可執行檔》的工具,如運作流程下圖所示:

gcc 編譯器

舉例而言,我們可以使用 gcc 將 c 語言程式編譯為執行檔,然後執行之,例如:

PS D:\ccc\course\sp\code\c\01-gcc\01-toolchain> gcc sum.c -o sum
PS D:\ccc\course\sp\code\c\01-gcc\01-toolchain> ls


    目錄: D:\ccc\course\sp\code\c\01-gcc\01-toolchain


Mode                LastWriteTime         Length Name
----                -------------         ------ ----
-a----      2019/8/30  上午 10:59           2094 README.md
-a----      2019/8/30  上午 10:59            182 sum.c
-a----       2020/3/1  下午 05:22          27948 sum.exe
-a----      2019/8/30  上午 10:59           1558 sum.s


PS D:\ccc\course\sp\code\c\01-gcc\01-toolchain> ./sum.exe
sum(10)=55
PS D:\ccc\course\sp\code\c\01-gcc\01-toolchain> ./sum    
sum(10)=55

您可以看到在 gcc sum.c -o sum 這個指令執行完之後,產生了 sum.exe 這個執行檔,然後當我們用 ./sum 這個指令之後,看到 sum.exe 被執行並輸出了 sum(10)=55 這樣的結果。

更多的範例與執行方法請看 github 專案:

生成語法

想要理解編譯器是如何設計的,必須先理解何謂《生成語法》。

舉例而言,假如我們有個生成語法如下,

S = N V
N = cat | dog
V = run | eat

那麼透過 N => dog , V=> run 就可以產生 dog run 這樣的句子。

如果強化一下語法,那麼就可以生成更完整的語句。

如果繼續強化語法

那麼就可以描述更複雜的英文語句了

運算式編譯器

對於數學運算式,我們也可以用下列語法描述:

E=F ([+-] F)*
F=Number | '(' E ')'

然後根據這個生成語法,我們寫出了以下的《運算式編譯器》

PS D:\ccc\course\sp\code\c\02-compiler\00-exp0> gcc exp0.c -o exp0

PS PS D:\ccc\course\sp\code\c\02-compiler\00-exp0> ./exp0 '3+5'      
argv[0]=D:\ccc\course\sp\code\c\02-compiler\00-exp0\exp0.exe argv[1]=3+5      
=== EBNF Grammar =====
E=F ([+-] F)*
F=Number | '(' E ')'
==== parse:3+5 ========
t0=3
t1=5
t2=t0+t1

PS D:\ccc\course\sp\code\c\02-compiler\00-exp0> ./exp0 '3+(5-2)'
argv[0]=D:\ccc\course\sp\code\c\02-compiler\00-exp0\exp0.exe argv[1]=3+(5-2)  
=== EBNF Grammar =====
E=F ([+-] F)*
F=Number | '(' E ')'
==== parse:3+(5-2) ========
t0=3
t1=5
t2=2
t3=t1-t2
t4=t0+t3

PS D:\ccc\course\sp\code\c\02-compiler\00-exp0> ./exp0 '(3+5)-2' 
argv[0]=D:\ccc\course\sp\code\c\02-compiler\00-exp0\exp0.exe argv[1]=(3+5)-2  
=== EBNF Grammar =====
E=F ([+-] F)*
F=Number | '(' E ')'
==== parse:(3+5)-2 ========
t0=3
t1=5
t2=t0+t1
t3=2
t4=t2-t3

上述編譯器產生的是《中間碼》,而不是《特定處理器的組合語言》,真正的編譯器通常會產生《組合語言或機器碼》。

在以下範例中,我們加入了將中間碼轉換為組合語言的動作,產生了 nand2tetris 課程中的 HackCPU 組合語言。

$ gcc exp0hack.c -o exp0hack
$ ./exp0hack '3+(5-8)'
=== EBNF Grammar =====
E=F ([+-] F)*
F=Number | '(' E ')'
==== parse:3+(5-8) ========
# t0=3
@3
D=A
@t0
M=D
# t1=5
@5
D=A
@t1
M=D
# t2=8
@8
D=A
@t2
M=D
# t3=t1-t2
@t1
D=M
@t2
D=D-M
@t3
M=D
# t4=t0+t3
@t0
D=M
@t3
D=D+M
@t4
M=D

詞彙掃描程式

為了降低學習門檻,上述運算式編譯器只能處理數字運算,不能使用變數,也沒有迴圈等控制結構。

還有,該程式沒有詞彙解析功能,只能處理 0-9 的數字,不能處理像 37 或 173 這樣多的字元的數字。

為了可以處理《變數,多字元數字或關鍵字》,我們需要建立一個《詞彙掃描程式》。

以下是一個簡易的詞彙掃描程式。

微型編譯器

有了詞彙掃描程式後,我們就可以使用更複雜的詞彙,結合前述的生成語法,就能建構出《微型編譯器》了!

我們可以用 make 指令建置該程式

PS D:\ccc\course\sp\code\c\02-compiler\03-compiler> make
gcc -std=c99 -O0 lexer.c compiler.c main.c -o compiler

該微型編譯器使用下列語法

PROG = STMTS
BLOCK = { STMTS }
STMTS = STMT*
STMT = WHILE | BLOCK | ASSIGN
WHILE = while (E) STMT
ASSIGN = id '=' E;
E = F (op E)*
F = (E) | Number | Id

然後我們就可以寫個範例程式如下:

s=0;
i=1;
while (i < 11) {
  s = s + i;
  i = i + 1;
}

接著用該編譯器《編譯範例程式》,得到下列結果:

PS D:\ccc\course\sp\code\c\02-compiler\03-compiler> ./compiler test/sum.c     

s=0;
i=1;
while (i < 11) {
  s = s + i;
  i = i + 1;
}


========== lex ==============
token=s
token==
token=0
token=;
token=i
token==
token=1
token=;
token=while
token=(
token=i
token=<
token=11
token=)
token={
token=s
token==
token=s
token=+
token=i
token=;
token=i
token==
token=i
token=+
token=1
token=;
token=}
========== dump ==============
0:s
1:=
2:0
3:;
4:i
5:=
6:1
7:;
8:while
9:(
10:i
11:<
12:11
13:)
14:{
15:s
16:=
17:s
18:+
19:i
20:;
21:i
22:=
23:i
24:+
25:1
26:;
27:}
============ parse =============
t0 = 0
s = t0
t1 = 1
i = t1
(L0)
t2 = i
t3 = 11
t4 = t2 < t3
if not T4 goto L1
t5 = s
t6 = i
t7 = t5 + t6
s = t7
t8 = i
t9 = 1
t10 = t8 + t9
i = t10
goto L0
(L1)

這樣我們就完成了一個小型編譯器了!