寫這個編譯器的目的,是為了完成編譯原理課上老師布置的大作業,實際上該大作業並不是真的實現一個編譯器,而我選擇硬剛,是為了完成我的小願望--手寫內核,編譯器和CPU。我花了整個上半學期,寫完了WeiOS,為了讓它支持更多的用戶態程序,甚至是基本的程序開發,必須給它量身打造一個編譯器。於是這個編譯器被提上日程。
因為我要復習考研和專業課過多,我打消了手寫詞法分析和語法分析的念頭,轉而使用FLEX和YACC,等到有時間再完成手工的版本--我認為也不是很難,如果用遞歸下降的話。
全部代碼都在該處 https://github.com/mynamevhinf/CMinusCompiler
詞法分析
因為是精簡的C語言,所以只支持基本的符號(Token),像”++”, “--”和位操作都不予考慮。另外,只支持int和void類型。於是便構成了以下符號集:
number [1-9][0-9]*
letter [a-zA-Z]
Identifier {letter}({letter}|{number})*
Newline \n
whitespace [ \t\r]+
保留字:
If else while int void return
運算和界限符號:
<= >= != == + - * / < > ; , ( ) { } [ ]
主體函數是getToken(),這個函數封裝了FLEX原生的yylex函數。而yyparser也將直接調用該函數。它的主要工作是在開始第一次詞的檢索前,初始化相關變量。然后在每次被調用的時候,返回符號的類型給yyparser,並且把構成符號的字符串臨時保存在tokenString字符數組中。所以這函數相當於什么事情都沒有干。
另外注意的是注釋的兩個符號,我直接在詞法分析處理注釋了。行注釋是”//”,利用FLEX自帶的input()函數(如果有的話,沒有就寫一個)一直讀到’\n’出現。然后就是段注釋符”/*”和”*/”,相似的做法。
語法分析
以下是BNF格式的語法規則:
Program -> declaration_list
declaration_list -> declaration_list declaration | declaration
declaration -> var_declaration | func_declaration
type_specifier -> INT | VOID
var_declaration -> type_specifier VARIABLE ; | type_specifier VARIABLE [ NUM ] ;
func_declaration -> type_specifier VARIABLE ( params ) compound_stmt
Params -> params_list | VOID
params_list -> params_list , param | param
Param -> type_specifier VARIABLE | type_specifier VARIABLE [ ]
compound_stmt -> { local_declarations stmt_list }
local_declarations -> local_declarations var_declaration | /* empty */
stmt_list ->stmt_list stmt | stmt
Stmt -> expr_stmt | if_stmt | return_stmt | compound_stmt | iteration_stmt
expr_stmt -> expr ; | ;
if_stmt ->IF ( expr ) stmt | IF ( expr ) stmt ELSE stmt
iteration_stmt -> WHILE ( expr ) stmt
return_stmt ->RET ; | RET expr ;
Expr -> var = expr | simple_expr
Var -> VARIABLE | VARIABLE [ NUM ]
Call -> VARIABLE ( args )
Args -> arg_list | /* empty */
arg_list -> arg_list , expr | expr
simple_expr -> NUM | var | call | ( expr ) | - simple_expr
| simple_expr + simple_expr
| simple_expr - simple_expr
| simple_expr * simple_expr
| simple_expr / simple_expr
| simple_expr < simple_expr
| simple_expr > simple_expr
| simple_expr >=simple_expr
| simple_expr <= simple_expr
| simple_expr != simple_expr
| simple_expr == simple_expr
我用globl.h中的TreeNode結構來保存語法樹中的每一個節點。而一些為空的轉換,我打算還是用一個該結構來表示,但是類型標記為None(也許有點浪費內存).
我實現的C-還算是個比較完整的程序語言,所以很有必要生成AST(抽象語法樹),那么語法樹中共有幾種類型的節點呢?按理說應每種語法規則對應一種類型,例如參數列表,聲明語句,普通語句和表達式等都對應一個節點類型,詳細可以參見NodeType枚舉類型。Parser.c文件是處理與語法樹相關的函數,目前來說當中幾個函數還沒寫清楚,TreeNode需要大改一下我估計,過幾天也許就明了了。--2018/05/29
2018/05/30
沒想到只過了一天不到,就完成了語法分析部分,總體上來說還是很簡單的。
有些語法規則導出兩種相似的子規則,用專業術語來講就是就是要做左因子消除,但好像yacc已經代替我們做了這個工作--我猜測它在底下優化了我們混亂的語法規則,包括左遞歸也解決了。據來說就是if_stmt導出的兩種情況,我並不打算在StmtType枚舉中添加新的枚舉類型來處理,而是利用原有的結構,用nkids來辨別是哪種情況。而在處理var_declaration第二種導出的規則時,原有的結構不夠用了,因為我要存儲VARIABLE和NUM,很顯然一個attr聯合體不夠用,所以我引入了第二個聯合體分別來存儲兩個值。分別叫attrA和attrB。有些時候這樣做也無法解決結構上的問題,才不得不用兩個枚舉類型來解決。現在我也不確定這樣做是否多余,畢竟我也是第一次寫編譯器,但它確實解決了當下的問題。
我刪除了封裝yylex()的getToken()函數,並解決了注釋代碼的問題,現在可以支持/**/的段注釋和//行注釋了。另外,我不想讓我代碼變得冗余,所以我把構建符號表(Symbol table)的任務放在了語義分析,直接放在語法分析中雖然節約了時間,但實在是難以維護。
最后根目錄下source*.c都成功地進行了語法分析,可能也只是表面上...
語義分析還沒學,翻書去了...
2018/06/02
經過兩天的學習,我知道了符號表基本的構建方式和運行時環境的搭建。但是還來不及完全轉換成代碼。今天我抽了一點時間寫了符號表的管理函數。我的編譯器有多級符號表:一個全局符號表以及每個函數一個局部符號表。在全局符號表中,每個屬於函數的變量名,都有一個指針指向其局部符號表。而局部符號表通過Parent字段與父符號表相連,在當前符號表無法檢索到符號信息的時候,就會去搜尋父符號表,層層遞增。由於C語言本身的規定,和我的懶惰,我不支持動態作用域,也就是說對一切外部(相對與當前代碼塊)的符號引用而言,其偏移和效果都是固定的;也不支持函數聲明嵌套,也就是說對於任何局部符號表,其父符號表都指向全局符號表。因此我覺得這樣簡單的實現方式是可行的。
符號表的基本數據結構是哈希表。具體實現不做闡述,我只寫了插入和尋找的函數,因為這個爛編譯器暫時不支持類型聲明和類型別名,所以我沒有寫刪除。
這里留下一個問題,除了全局聲明函數,代碼塊--也就是被{}包圍起來的部分,也需要局部符號表,而且是支持嵌套的。那么我該如何解決這種相當於“匿名”函數的符號表問題,我還沒有思路。或許可以給它們各自賦予決不會引起沖突的名字?又或者是留到代碼生成階段再處理,這樣就不必緩存“匿名”符號表了,直接生成代碼就完事了。
主要代碼都在symbol.c 和 symbol.h里面。
2018/06/07
經過幾天的咸魚和跟進,我完成了類型檢查和符號表生成,中間代碼生成。
關於類型檢查,是完全仿造gcc的,檢查順序是后序遍歷,例如表達式:
a = test() + b;
我們必須先檢查test()是否有返回值,然后b是什么類型的數據,如果test()返回值是void,那么這個語句就可以直接判斷為錯誤了。若兩個加數通過檢查,而a是數組類型的話,也會報錯。
由上所述,類型檢查必須是后序遍歷,而我生成符號表的時候采取先序遍歷,這里就產生了矛盾。解決的方法是,我對語法樹中的表達式(expr)類型的節點,通通執行后續遍歷,而其他如If語句,while語句等采取常規做法。原因是我在表達式的語句中,不可能出現聲明新函數或者變量的語句,反而是要檢查所用的變量和函數在之前是否聲明,如果沒有就報錯,因此后序遍歷是可行的。
也就是說我的buildSymtab()函數不是安裝樹狀遞歸展開的,而且名不副實,因為它除開生成符號表,還同時完成了類型檢查工作。其實我是想把這兩個步驟分開的,用2-pass來完成,代碼也比較精簡,想砍掉哪部分也很方便。但由於當時寫的太爽了,不想再寫類似於buildSymtab()這樣結構的函數了,所以直接做完了。
上一篇遺留的問題,匿名代碼塊怎么辦?我的處理方法是為它們生成單獨的符號表,並且在語法分析階段,調用randomFuncName()函數為每一個匿名代碼塊生成一個名字,類似真正的函數那樣管理,這個名字不是真正隨機的,因為它是AAAnonymous*的形式,但無所謂了,反正這編譯器也爛。
具體的代碼在analyzer.c里,相關結構的定義在analyzer.h中。
接下來是一張類型檢查的貼圖: 左邊是我的fcc,右邊是gcc,錯誤和警告都檢測出來了,但是他們比我提示信息更加多...
運行時環境(run-time):
當然是選擇和C語言一樣啊,沒有什么好說的。
要提的一點還是匿名代碼塊的問題,對於這種代碼塊中聲明和定義的“局部變量”,我觀察了gcc的做法,他們把這些“局部變量”當作了其所屬的函數的局部變量,舉個例子:
void test()
{
int j;
{
int i;
}
}
那么在函數test()為局部變量分配空間的時候,不是分配4個字節,而是8個字節。所以我也這么干。因為語法的原因,函數體本身聲明局部變量肯定在匿名代碼塊之前,故當我們處理到匿名代碼塊的時候,必須要返回去更改所屬函數的局部變量信息(用於代碼生辰的時候分配具體空間)。之前我的FuncProperty結構並沒有維護參數個數和局部變量大小的信息,所以我做了一些更改,添加了nparams和nlocalv域。
中間代碼
本來我都不打算寫中間代碼,直接生成x86的匯編代碼,但老師說不給我分,我就寫了。然后我看了一下vsl的中間代碼,我很郁悶遂決定自己創造一套新的三地址碼,我覺得還挺有意思的,我甚至都考慮用這套三地址碼寫一個虛擬機,直接在虛擬機上面跑。。。
折中了一下,我的這套三地址碼已經非常像x86的匯編代碼了,除了一些沒用的臨時寄存器。其對於我來說,最大的作用就是把樹形的信息,轉換成了鏈狀的,接下來順着三地址碼構成的鏈表翻譯就好了。相關代碼見irgen.c而數據結構等在irgen.h.下面是一個源代碼文件(source2.c)以及其生成的三地址碼(midcode.f)的部分。
source2.c:
int jb; int x[10]; /* void test(void) { } */ int minloc(int a[], int low, int high) { int i; int x; int k; k = low; x = a[low]; i = low + i; /* k = a; ok = 10; i = a[test()]; */ while (i < high) { if (a[i] < x) { x = a[i]; k = i; } i = i + 1; } return k; } void sort(int a[], int low, int high) { int i; int k;
midcode.f:
1 program: 2 glob_var jb 4 3 glob_var x 40 4 5 _minloc: 6 loc_var 16 7 k = low 8 mull t0 low 4 9 addl t1 &a t0 10 x = t1 11 addl t2 low i 12 i = t2 13 L0: 14 lt t3 i high 15 if_false t3 goto L1 16 mull t4 i 4 17 addl t5 &a t4 18 lt t6 t5 x 19 if_false t6 goto L2 20 mull t7 i 4 21 addl t8 &a t7 22 x = t8 23 k = i 24 L2: 25 addl t9 i 1 26 i = t9 27 goto L0 28 L1: 29 ret k 30 31 _sort: 32 loc_var 16 33 i = low 34 L3: 35 subl t10 high 1 36 lt t11 i t10 37 if_false t11 goto L4 38 loc_var 16 39 begin_args
可以看到真的是一一對應的,雖然還有個小Bug,但是我懶得管了。
2018/06/11
我的寄存器分配函數寫得有點混亂,當然如果我不想做那些奇奇怪怪的優化,就不會存在這個問題。我寫得很難受,但也比較享受。我現在是考慮把對中間變量的處理也統一起來,但是還沒有思路...
isReside():判斷一個地址是否駐留在寄存器中,如果該地址類型為TempPtr或TempReg,
regWriteBack(RegPtr Rp, RegPtr Rbp,FILE *fp): 把Rp寄存器的內容寫會所屬變量的內存地址。一定要先判斷Hp是否為空,若為空就表示當前存了一個中間變量!!!! 如果不為空,但是沒被修改過--就是dirty為0,也沒必要寫回去!!!
regReadIn(RegPtr Address *addr, FILE *fp): 把地址addr所屬的變量的值讀到到Rp指向的寄存器中,並更新相關內容.如果該變量/中間變量已存在寄存器中,則調用regMoveReg()直接從原寄存器移動.如果不在,則根據addr的類型采取不同的操作.我暫時處理了一般變量和常數的讀取。
regBindVar(RegPtr Rp, Address *addr, HashList Hp, int dirty):如函數名,把addr,Hp,dirty賦給寄存器Rp.函數體內檢測是否有其他寄存器已保存了該變量,調用FreeReg()把其addr和Hp域清空.
RegPtr regSearchOne(HashList Hp, FILE *fp):為第一個操作數搜尋合適的寄存器.我判斷的是addr這個域,但感覺好像不夠圓滿...
2018/06/14
懶得寫了,終於完成了代碼生成,我自己寫的那套寄存器分配,也很好用,甚至超過了vsl作者們寫的算法= =
但是遺留了一個問題,就是我在代碼生成的時候,忘記考慮到代碼重入性的問題了,在生成循環代碼塊的時候,根據前文的數據流生成了代碼,本意是盡量減少內存存取的次數,但是我沒有考慮到,在真正運行的過程中,每次循環開始的時候,寄存器中存儲的信息不一定是“第一次"進入循環塊時的信息,因此我生成的代碼有語義錯誤,只有偶爾情況下才是正確的。我不打算改這個bug了,老師說這個涉及到龍書上講解的數據流分析,但我還要考研,所以推遲了吧,等真正需要的時候再搞這個。
下面是測試代碼,就是一個簡單的選擇排序 source2.c
/* A program to perform selection sort on a 10 element array. */ /* void test(void) { } */ int minloc(int a[], int low, int high) { int i; int x; int k; k = low; x = a[low]; i = low + i; while (i < high) { if (a[i] < x) { x = a[i]; k = i; } i = i + 1; } return k; } void sort(int a[], int low, int high) { int i; int k; i = low; while (i < high - 1) { int t; k = minloc(a, i, high); t = a[k]; a[k] = a[i]; a[i] = t; i = i + 1; } }
我生成的匯編代碼 source2.s:
.file "testfile/source2.c" .text .globl minloc .type minloc, @function minloc: pushl %ebp movl %esp, %ebp subl $16, %esp movl 12(%ebp), %eax movl %eax, -12(%ebp) movl 12(%ebp), %edx leal 0(,%edx,4), %ebx movl 8(%ebp), %ecx addl %ecx, %ebx movl (%ebx), %ebx movl %ebx, -8(%ebp) movl -4(%ebp), %eax addl %eax, %edx movl %edx, -4(%ebp) .L0: cmpl 16(%ebp), %edx jge .L1 leal 0(,%edx,4), %eax addl %ecx, %eax movl (%eax), %eax cmpl -8(%ebp), %eax jge .L2 leal 0(,%edx,4), %ebx addl %ecx, %ebx movl (%ebx), %ebx movl %ebx, -8(%ebp) movl %edx, -12(%ebp) .L2: movl -4(%ebp), %edx addl $1, %edx movl %edx, -4(%ebp) jmp .L0 .L1: movl -12(%ebp), %eax leave ret .globl sort .type sort, @function sort: pushl %ebp movl %esp, %ebp subl $16, %esp movl 12(%ebp), %eax movl %eax, -4(%ebp) .L3: movl 16(%ebp), %edx subl $1, %edx cmpl %edx, %eax jge .L4 movl 16(%ebp), %ebx pushl %ebx pushl %eax movl 8(%ebp), %ecx pushl %ecx call minloc addl $12, %esp movl %eax, -8(%ebp) leal 0(,%eax,4), %edx addl %ecx, %edx movl (%edx), %edx movl %edx, -12(%ebp) movl -4(%ebp), %eax leal 0(,%eax,4), %edx addl %ecx, %edx movl -8(%ebp), %eax leal 0(,%eax,4), %ebx addl %ecx, %ebx movl (%edx), %edx movl %edx, (%ebx) movl -4(%ebp), %eax leal 0(,%eax,4), %ecx movl 8(%ebp), %eax addl %eax, %ecx movl -12(%ebp), %eax movl %eax, (%ecx) movl -4(%ebp), %eax addl $1, %eax movl %eax, -4(%ebp) jmp .L3 .L4: leave ret
最后上測試結果,難得的正確了一次:
2018/06/24
本來不打算寫了,但是在滿分的激勵下我做了以下修改,並最終解決了上面的Bug
1.被調用函數保存可能改變的所有寄存器。
2.實現循環代碼的語義正確(其實就是保證了可重入性)。
當然還有一個問題就是,棧內容初始化。因為物理頁面分配的不確定性,因此會留下上一個進程的數據,所以必須在程序一開始對可能用到的棧空間進行初始化。嚴格來說,這並不是我的問題。
最后附上一張成功運行的圖: