自己写编译器学习总结
生活随笔
收集整理的這篇文章主要介紹了
自己写编译器学习总结
小編覺得挺不錯的,現在分享給大家,幫大家做個參考.
如何寫一個簡單的編譯器?
https://www.zhihu.com/question/36756224初學編譯原理,想寫一個簡單的編譯器。
是時候亮出我的 LL 語言了,全稱:Lambda Lite Js。
LL 是一個類似 haskell 的函數式語言,使用 Javascript 實現。在線demo:Lambda-lite-js。項目地址:GitHub - moevis/lambda-lite-js: a tiny FUNCITONAL LANGUAGE implemented by javascript. 一個函數式語言,使用 js 實現。。
大概寫了兩周多,有了很多很有趣的功能了,比如可以玩 lambda 演算,可以柯里化,可以玩閉包,可以玩模式匹配,可以使用 Point-Free 風格,具有局部 lazy-evaluation 特性等等。
先給幾個階乘的示例代碼。
普通階乘
let fact = \n ->
? ? if n == 1 then 1 else n * (fact n - 1);
print $ fact 5;
利用模式匹配寫階乘:
let fact n@1 = 1;
let fact n@Number = n * (fact n - 1);
print $ fact 5;
不動點組合子階乘:
let z = \f -> (\x -> f (\y -> x x y)) (\x -> f (\y -> x x y));
let makeFact = \g -> \n -> if n < 2
? ? then 1
? ? else n * (g n - 1);
let fact = z makeFact;
print $ fact 5;
網頁版運行截圖:
是的,就是和 haskell 這么像。
=====說正事=====
之前我寫過一個 js 代碼高亮插件,用于高亮 html、js、css 代碼,GitHub - moevis/code-lighter: a lightweight code highlighting tool, 代碼高亮工具。原理是將代碼切分成一系列 tokens, 然后給這些 tokens 添加著色就好了。
至于如何切分 tokens 呢?Parser 就是負責這個事情的。我們先將輸入的代碼先分成以下幾個基本元素:
數字 Number
字面量 Literial
標點 Punctuar
空白符 White
文件結束符 EOF
我們的任務就是要區分它們,這個目標還是蠻容易的,我們使用遞歸下降法來解析,這就是一個狀態機的模型:
首先,我們的初始狀態機 State 處于默認狀態。
讀入待解析代碼,讀第一個字符 c ,這時候有五種可能:
c 屬于 '9' ~ '0',State 轉為數字模式。
c 屬于 'a' ~ 'z' 或者 'A' ~ 'Z',State 轉為字面量模式。
c 屬于 '*,;"+'... 的標點,State 轉為標點模式。
c 屬于 '\t' 或者 '\n' 或者空格,State 轉為空白符模式。
c 為空,則到了代碼的最后,State 為文件結束模式。
進入另一個模式后,我們會用不同的策略來接收下一個字符。比如,在數字模式假如下一個字符是 '0' ~ '9',那么讀入數字,并拼接到之前的數字后;直到接收到一個非數字的字符(這里我們就不考慮小數了)。當退出數字模式,我們就能得到一個數字的 token 啦。同理我們可以得到標點,字面量等等。
關于字面量,我們還需要做一些分類,字面量包括變量名,關鍵字,所以我們實現要有一個關鍵字表,當字面量不再關鍵字表里面,我們視為一個變量或者是函數名,否則就是有語法意義的字符比如 if else 。
初步的 tokens 切分結束后,去除空白 tokens,我們就得到一個 token 數組。
這時候我們要建立抽象語法樹 (Abstract syntax tree)了。先設計幾個基本語法,比如聲明一個函數或者變量時,使用`let`關鍵字,`if...else...then...`做分支判斷,聲明匿名函數使用類似'\n -> n + 1'的語法(有且只有一個參數 n ,n + 1 為返回值),表達式采用中綴 `1 + 2 + a`。
這時候我們可以定義幾個樹節點類型了:
聲明節點, defineNode (let x = 5)
匿名函數節點, lambdaNode (\n -> n + 1)
分支節點,conditionNode (if ... then ... else ...)
引用節點,代表某一個已聲明的變量值或者函數名 objectNode
數字節點,代表一個數字,numberNode
表達式節點, 代表某一中綴表達式,其本身儲存運算符,比如 a + b,節點儲存 `+`,expressNode
函數調用節點,表示對函數的調用,比如 print x,callNode
我們的任務就是將 tokens 轉為這些節點的相互關系。
首先還是讀入第一個 token,判斷 token 類型,如果是`let`,那么就按照聲明節點來解析; 如果是`\`則是一個匿名函數聲明節點,如果是`if`,則解析一個分支節點…… 解析表達式節點會難一點,因為有運算符的優先級,我參照了 llvm 教程中的 kaleidoscope 語言來寫(傳送門,Kaleidoscope: 實現解析器和抽象語法樹)。
總之這是需要你認真理解的一步,這一步完后,就可以根據語法樹來得到代碼運行結果了。每一種節點的結果計算方式都不同,但是有統一的接口 getValue()。比如聲明節點無返回值,匿名函數返回一個函數,分支節點先對 condition 求值,當 condition 為 true,返回第一個分支的 value,否則是第二個……
當這一切結束后,你的第一個語言就完成了。當然,現在我的語言已經比較復雜了,比如加入了模式匹配節點,聲明多參數函數的語法糖,改變結合方向等等。
你可以看我的 commit log 來看到我是如何一步一步添加這些功能的。
編譯原理這個方向,龍書虎書是經典,若要深入學習,一定要看。不過我也沒有時間啃下來,不過有一本書也是簡單有趣,叫做《計算的本質》,O‘REILY 出的,值得信賴。
說了那么多,我這個只能算是解釋器,并沒有真正編譯,當然對于初級已經夠了。你不滿足的話我再給你指條路,llvm 教程中有一個小語言叫做 kaleidoscope,你按照這個教程來,https://github.com/moevis/Kaleidoscope-LLVM-tutorial-zh-cn,這是我當年留下的中文翻譯坑,當時翻了一些后就去做實驗室任務了。這個從 parser 到 ast 到 ir 到各種優化都很全。
如果是用 Haskell 的話,三篇文章足矣。
prerequisite: 懂 state monad就行了
第一篇,《How to build a monadic interpreter in one day》http://citeseerx.ist.psu.edu/viewdoc/summary?doi=10.1.1.368.2522
跟著做,可以完成一個簡單的backend, 也就是架構基本的AST 并執行的步驟。
然后到frontend, 也就是parser的部分你就會發現你看不懂了, 這個時候看第二篇。(因為該文的 parser ?部分其實是 第二篇 的一個濃縮版,所以不看第二篇基本很難看懂這個部分)
第二篇,《Monadic Parser Combinator》 ,http://www.cs.nott.ac.uk/~pszgmh/monparsing.pdf
看了也就能對照第一篇,把 parser 寫出來了, 然后就能和之前的backend 組合,完成一個基本的,完全由自己手寫的,monadic的解釋器(parser 和 backend 分別由一個自定義的 state monad 實現)。順便加深對 monad 的理解。
看第二篇的時候,回過頭對照第一篇看效果會更高,雖然邏輯一樣,但第二篇是用 monad comprehension 的形式來寫, 第一篇是用 do notation 來寫。有的復雜的地方你兩種方式對照看一下,會有茅塞頓開的效果。
然后再看第三篇
第三篇,llvm的haskell 教程, Implementing a JIT Compiler with Haskell and LLVM ( Stephen Diehl ) ?, 把你的backend 換成llvm. (注:事先對 llvm 不熟的話,可以和 hackage 上面 llvm-general, llvm-general-pure 這兩個庫的 wiki, 以及 LLVM Language Reference Manual對照著看)
至于frontend, 可以換成Parsec之類,但也可以就不斷擴充之前自己寫的版本。
齊活兒~
------
今天閑著沒事兒,擼了一個 swift 的版本,GitHub - aaaron7/swift_monadic_parser: A simple haskell-style monadic combinator-based parser written in Swift. ? 僅包含最基本的 parser 和 interpreter 的功能,寫法完全按照前面兩篇文章的思路實現,有興趣可以看看。
這個題目有點久了,現在才想起來答,主要還是因為暑假才找到空閑的時間,把我的C11編譯器寫完了。
這個編譯器,(GitHub - wgtdkp/wgtcc: a tiny C compiler in C++) 一方面是為了學習 C11, 一方面為了練習C++。
在約11k代碼中實現了:
幾乎完整的C11語法解析(除去變長數組);
語義與類型檢查(仿gcc的錯誤提示)
預處理器
x86-64匯編代碼生成, 多謝?
@RednaxelaFX
?的回答寄存器分配問題? - 編譯器,把我從無休止的手動優化中拯救回來
精簡的x86-64 ABI
心形很流行嘛,wgtcc編譯的M大(?
@Milo Yip
?) 在 如何用C語言畫一個“心形”? - C(編程語言)回答里的代碼:
#include <stdio.h>
int main() {
? ? for (float y = 1.5f; y > -1.5f; y -= 0.1f) {
? ? ? ? for (float x = -1.5f; x < 1.5f; x += 0.05f) {
? ? ? ? ? ? float a = x * x + y * y - 1;
? ? ? ? ? ? putchar(a * a * a - x * x * y * y * y <= 0.0f ? '*' : ' ');
? ? ? ? } putchar('\n');
? ? }
}?
C11 中有一些非常實用或者好玩的新特性,如 compound literal. 一個典型的用途是當我們想獲得一個數據的另一種表示的時候, 我們可能會這么做:
float f = 1.5;
int i = *(int*)&f;?
然而gcc 在開 -O2 時會報 break strict-aliasing rules 的warning。 有了 compound literal, 我們可以這么做:
#define CAST(s_t, d_t, sv) \
? ? (union {s_t sv; d_t dv;}){sv}.dv
float f = 1.5;
int i = CAST(float, int, f);
而且這是一個模板呢~
C11 也支持在identifier 中使用unicode字符了,中文編程很exciting:
#define 整型 ? ?int
#define 輸出 ? ?printf
#define 面函數 ?main
#define 返回 ? ?return
#define 定義 ? ?typedef
#define 不可變 ?const
#define 字符 ? ?char
#define 指針 ? ?*
#define 為
定義 不可變 字符 指針 為 字面值;
整型 面函數() {
? 字面值 蛤蛤 = "\u82df\u5229\u56fd\u5bb6\u751f\u6b7b\u4ee5\uff0c"
? ? ? ? ? ? ? ?"\u5c82\u56e0\u7978\u798f\u907f\u8d8b\u4e4b";
? 輸出("%s\n", 蛤蛤);
? 返回 0;
}
這些例子在example/目錄下可以找到。
說說寫這個小編譯器總結的方法吧:
以最快的速度做到能夠解析下面這段代碼:
int main(int argc, char** argv) {
? ? ?int i;
? ? ?return 0;
}
以最快的速度看到hello,world。
開始對照語言標準一個一個實現特性,并同步做單元測試。因為已經看到hello world,這一步雖然工作量有點大,但是因為有了前面的經驗,可以很快。
解析聲明是最復雜的,所以先寫解析聲明。
龍書是必需的,一個好的參考也非常重要(如GitHub - rui314/8cc: A Small C Compiler)。
嘗試自舉(因為我用的C++,所以沒法自舉)。
寫一個編譯器的壞處是,以后寫一段代碼都試圖在腦子里面翻譯成匯編。。。
// Update_1
// Date: 09/20/2016
匆忙寫的回答,感覺僅僅是拋出結果和小結,實在是太不負責任了=-=。沒有實現過的同學可能還是不知如何入手,下面就寫寫我是如何一步一步做的吧(某些內容只限于C編譯器)
1. 初始狀態,你必須有一本第二版的龍書。其它的答案可能會推薦《編譯器實現》或者《編程語言實現模式》。《編譯器實現》因為中文翻譯的比較生硬,讀了幾章,發現還是龍書比較好,后來基本沒有看《編譯器實現》了。如果你是直接讀原版,那么還是推薦龍書,畢竟都有決心讀原版了,干脆徹底一點讀龍書原版好了^_^。其實龍書并沒有那么難,公式記不住,不理解,跳過就好了。《編程語言實現模式》其實很好的,各種實現方法都有所涉及。唯一不足的是,作者沒有向《代碼大全》的作者那樣,對我耳提面命 ----- 直接告訴我怎么做就好了(相信這是新手最需要的...)。
2. 你必須有C11 standard。open-std 上面的draft就夠了(http://www.open-std.org/jtc1/sc22/wg14/www/docs/n1548.pdf)。(如果是其他語言的編譯器,對應之。如果是自己設計,那么應該學著standard那樣,將grammar,constraints等寫下來)
3. 一個簡單的不帶優化的編譯器,基本只需要3個步驟:詞法分析,語法分析,代碼生成;對應到代碼里面,就是3個class:Scanner, Parser, Generator;?
4. 對于C語言編譯器,支持預處理器,相當于又支持了一門新的簡單語言了。所以一開始不必去支持預處理器。在測試時,只使用不包含預處理器的代碼就好了。或者,用gcc進行預處理,將其輸出作為你的編譯器的輸入。
5. 在真的動手開始寫Scanner之前,有必要讀n1548的5.1節,對C源程序的編譯步驟有基本的了解。其實ad-hoc的詞法解析一點也不比寫一個split()函數更復雜,尤其是開始的時候不必去記錄 Source Location(當然后面如果寫錯誤提示,可以再加進來)。實現的時候,也不要吝惜內存了,直接將所有的token一次解析掉存起來就好。因為這對于后面Parser要回溯的時候會方便一點。
6. 已經得到Token List 之后,就可以開始做Parser部分了。暫時不做錯誤恢復,即遇到錯誤即exit。這里有一個很實用的設計建議:準備好下面這四個函數:
a. Peek() 返回下一個Token(只測試該Token,不向前移動Token List的offset指針)
b. Next() 消費下一個Token
c. Expect(expectedToken) , 這代替了下面的這段錯誤處理:
if (Peek() != expectedToken) {
? ? Error("expect %s, but got %s\n", expectedToken, Peek());
}?
d. Try(expectedToken), 這代替了下面這段代碼:
if (Peek() == expectedToken) {
? ? Next(); // 消費之
}?
? ? ?這很有用,因為Parser里面可能會有上百個這段代碼,在我的Parser里面,有84個Expect()調用,81個Peek()(Peek和Test), 39個Next(),62個Try()。相信我,這4個函數會讓你的代碼干凈一倍。
7. C的語言組成,大致可以分為 Expression, Declaration, Statement and Block 三個部分。這里面Statement and Block是最簡單的,Declaration難一點。按照我前面的心得體驗,應該從簡單的入手,但是我們非先做Declaration不可。因為Statements都是在函數內的啊,不搞定Declaration就沒法繼續了呢~ 其實,Declaration也好做,對著n1548的7.A.2.2節一個一個將grammar翻譯為Parser里面的邏輯就好了(是的,除去語義和類型檢查,Parser就是這么簡單)。做完Declaration,你還沒有往AST上添加任何node,是的,僅僅是Declaration,是沒有一行代碼需要生成的。所有的成就都在符號表里面。這里又有tip:暫時不要做Initializer,它有一點煩人的(C標準把它搞得好繁瑣)。
8. struct/union 類型;如果只是支持一個小小的子集,那么大可以跳過這一部分不做。struct會引入一些工作量,一方面,需要為tag(tag 和普通的identifier不在同一個命名空間)另開一個符號表(我使用一個小trick避免了這個麻煩);另一方面,它也是使Initializer變復雜的原因之一,尤其是匿名struct/union。tip:對struct/union的支持步驟是:普通嵌套的結構,匿名無tag的 struct成員,位域,union;這里把union放到最后是因為,它和struct除去存儲布局之外,別無二致(所以你甚至不必區分這兩個類型);你也可以在任何一步就開始支持union。
9. 數組和函數;除去作為sizeof關鍵字的操作數,數組總是會被cast成一個指針;你一定寫過這樣的代碼:
typedef void (*func_t)(void);
func_t f = func; f(); // 難道不應該是 (*f)(); ??
其實函數也是被cast成指針了,所以上面的調用方式都對,更微妙的是,任何一個函數調用都會被先cast成指針,再解引用(至少我的實現是這樣的);
10. storage 和 linkage;起初只實現所有的對象分配在棧空間;這會大大簡化代碼生成部分,因此對于“以最快速度看到hello world”是非常重要的;linkage對于前向聲明是重要的,如果你沒有打算支持,那么也可以跳過,等你看到hello world再回來支持,或者等你的函數和標準庫的沖突了。
11. expression;這個最簡單,該怎么樣就怎么樣=-=。tip:聯合賦值運算符:
a *= 5;
a = a * 5;?
是不是總是被告知效果和下面那行效果相等?那么不要害羞,就這么翻譯!(嗯,這么做是會產生bug(如果左值表達式有副作用),但是可以通過額外的檢查規避掉;對于帶優化的編譯器,這根本不是問題,因為它們怎么會對公共子表達式求兩遍值呢?)
12. statement;這是我最喜歡的部分,不僅僅是因為它簡單,而且讓我明白那些控制語句是怎么生成到匯編代碼的(對應請看龍書6.6和6.7節);如最簡單的while循環的展開:
/*
?* while (expression) statement ?
?* 展開后:?
?* cond: ?
?* ? ? ? if (expression) then?
?* ? ? ? ? ? empty?
?* ? ? ? else?
?* ? ? ? ? ? goto end?
?* statement?
?* goto cond?
?* end:?
?*/?
這里,我將 if 語句保留為基本的翻譯單元,因為將其他的控制結構翻譯為 if 語句會帶來很大的便利。tip:支持順序:if-else, while/do-while, for, switch-case;?
這些基本是一個C語言Parser的動手步驟了,現在你可以parse這段代碼了:
int main() {
? ? puts("hello world\n");
? ? return 0;
}
你可以手動將 puts插入到符號表以應付過去(某些builtin的函數還真就是這么干的),也可以在這個hello.c文件中聲明puts:
extern int puts(const char* str);?
或者你就要實現對struct/union的支持, 不然是沒有辦法 #include <stdio.h> 的了。這里沒有使用 printf,因為暫時沒有必要實現變參函數。
這樣,你與hello world只有一步之遙了:匯編代碼生成。
// TODO(wgtdkp): 匯編代碼生成
// End of update_1
// Update_2
// Date: 09/21/2016
因為按照"以最快的速度看到hello world"來做的話,語義檢查和類型檢查可以暫且放一放,或者只是實現parse過程中必不可少的一部分。下面是以x86-64 匯編代碼生成為例,說說代碼生成。這里又可以跳過中間代碼生成,直接由AST生成匯編代碼~
1. intel x86-64 手冊;顯然這是必需的,雖然我拿到這3000多頁的手冊時,也是虎軀一震的。不過,實實在在地講,我只看了大概30頁的內容;更多的時候,我是對照著gcc生成的匯編代碼照虎畫貓; tip:對于某些指令,如乘除法,移位,對照gcc進行翻譯是非常有效的;但你不應該企圖生成像gcc那么高效的代碼!(具體方法見下面)
2. System V x64 ABI;你必須至少看chapter 3(chapter 3就夠用了, 不過只有30多頁,放心吧);至少掌握stack frame的結構和對齊。注意x86-64的調用規約會稍微復雜一點,不過你可以做一些大膽的簡化:
?a. scalar type (除去struct/union,剩下的都是)按照 ABI里面的就好;
?b. struct/union 是比較復雜的,這里可以直接按照stack傳參(而不是寄存器傳參去做),畢竟又有多少代碼會直接傳遞struct/union 呢?等到你決意要做一個full featured的編譯器時,再來考慮它吧。
可以參考這里Introduction to X86-64 Assembly for Compiler Writers
3. visitor 模式;相信這個不必贅述,取決于怎么使用它,可以有很多作用;比如Parser中會需要做常量表達式求值,我們會用到它;獲得一個表達式的左值地址時,我們又需要用到它;(考慮你該如何翻譯賦值表達式)
4. 數據類型;在代碼生成這一步,我們的數據類型只有3種:整型,浮點型,struct/union(集合);我將能夠用通用寄存器存儲的數據稱為整型,包括指針;struct/union的處理比較特殊,對于一個類型為struct/union的表達式,visit該表達式,總是得到此struct/union對象的地址值(存儲于%rax寄存器中);只要我們在所有代碼中遵守這一規則,那么它確實很管用,即使會有一些冗余的拷貝;
5. 翻譯函數定義;一個函數的翻譯可以分為下面幾個步驟:
保存棧指針;
確定函數參數的位置(在哪個寄存器或者stack上的位置),將它們復制到當前stack frame的上,更新每個參數(object)的offset成員(object的地址)。更新當前stack frame的偏移量;
確定當前作用域內的object的地址, 這只需要掃描當前scope內的所有object,并線性地分配到stack frame上面就好;注意不包括內層scope內定義的object。這是一種優化,能夠充分利用棧空間,而且實現更簡單。更新當前的stack frame偏移量。
翻譯函數體;
在return 語句和函數結尾處,恢復棧指針并退出函數;
6. 翻譯函數調用;也可以分為下面幾個步驟:
確定函數地址;這可能是函數的名字,也可能是一個寄存器;這里就需要一個能夠計算表達式左值地址的 evaluator 了(后面會講到);
對函數參數求值,暫存之(push到stack上);
確定函數參數的位置,即,應該在哪個寄存器或stack的位置;拷貝參數值到對應位置;
調整棧指針以正確地對齊(這個很重要,不然會有segment fault的,都是淚);
調用之~
7. 翻譯賦值表達式;對左值表達式的賦值,需要獲得左值表達式的地址,而不是值;因此我們需要一個 LValGenerator 來取得左值表達式的地址,然后將右操作數的值load到該地址中;
8. 翻譯表達式;建議采用1-TOSCA方法,不懂的可以看看R大的回答寄存器分配問題? - 編譯器;這里的tip:不要被gcc生成的各種高效代碼蠱惑了而去做大量的手動優化,那是一個很大的坑,尤其是我們沒有生成中間代碼,是做不到全局寄存器分配的效果的。
========
第一個 C 語言編譯器是怎樣編寫的
http://www.csdn.net/article/2015-11-27/2826350摘要:當今幾乎所有的實用的編譯器/解釋器(以下統稱編譯器)都是用C語言編寫的,有一些語言比如Clojure,Jython等是基于JVM或者說是用Java實現的,IronPython等是基于。
?
當今幾乎所有的實用的編譯器/解釋器(以下統稱編譯器)都是用C語言編寫的,有一些語言比如Clojure,Jython等是基于JVM或者說是用Java實現的,IronPython等是基于.NET實現的,但是Java和C#等本身也要依靠C/C++來實現,等于是間接調用了C。所以衡量某種高級語言的可移植性其實就是在討論ANSI/ISO C的移植性。
C語言是很低級的語言,很多方面都近似于匯編語言,在《Intel 32位匯編語言程序設計》一書中,甚至介紹了手工把簡單的C語言翻譯成匯編的方法。對于編譯器這種系統軟件,用C語言來編寫是很自然不過的,即使是像Python這樣的高級語言依然在底層依賴于C語言(舉Python的例子是因為Intel的黑客正在嘗試讓Python不需要操作系統就能運行——實際上是免去了BIOS上的一次性C代碼)。現在的學生,學過編譯原理后,只要有點編程能力的都可以實現一個功能簡單的類C語言編譯器。
可是問題來了,不知道你有沒有想過,大家都用C語言或基于C語言的語言來寫編譯器,那么世界上第一個C語言編譯器又是怎么編寫的呢?這不是一個“雞和蛋”的問題……
還是讓我們回顧一下C語言歷史:1970年Tomphson和Ritchie在BCPL(一種解釋型語言)的基礎上開發了B語言,1973年又在B語言的基礎上成功開發出了現在的C語言。在C語言被用作系統編程語言之前,Tomphson也用過B語言編寫過操作系統。可見在C語言實現以前,B語言已經可以投入實用了。因此第一個C語言編譯器的原型完全可能是用B語言或者混合B語言與PDP匯編語言編寫的。我們現在都知道,B語言的執行效率比較低,但是如果全部用匯編語言來編寫,不僅開發周期長、維護難度大,更可怕的是失去了高級程序設計語言必需的移植性。所以早期的C語言編譯器就采取了一個取巧的辦法:先用匯編語言編寫一個C語言的一個子集的編譯器,再通過這個子集去遞推完成完整的C語言編譯器。詳細的過程如下:
先創造一個只有C語言最基本功能的子集,記作C0語言,C0語言已經足夠簡單了,可以直接用匯編語言編寫出C0的編譯器。依靠C0已有的功能,設計比C0復雜,但仍然不完整的C語言的又一個子集C1語言,其中C0屬于C1,C1屬于C,用C0開發出C1語言的編譯器。在C1的基礎上設計C語言的又一個子集C2語言,C2語言比C1復雜,但是仍然不是完整的C語言,開發出C2語言的編譯器……如此直到CN,CN已經足夠強大了,這時候就足夠開發出完整的C語言編譯器的實現了。至于這里的N是多少,這取決于你的目標語言(這里是C語言)的復雜程度和程序員的編程能力——簡單地說,如果到了某個子集階段,可以很方便地利用現有功能實現C語言時,那么你就找到N了。下面的圖說明了這個抽象過程:
那么這種大膽的子集簡化的方法,是怎么實現的,又有什么理論依據呢?先介紹一個概念,“自編譯”Self-Compile,也就是對于某些具有明顯自舉性質的強類型(所謂強類型就是程序中的每個變量必須聲明類型后才能使用,比如C語言,相反有些腳本語言則根本沒有類型這一說法)編程語言,可以借助它們的一個有限小子集,通過有限次數的遞推來實現對它們自身的表述,這樣的語言有C、Pascal、Ada等等,至于為什么可以自編譯,可以參見清華大學出版社的《編譯原理》,書中實現了一個Pascal的子集的編譯器。總之,已經有計算機科學家證明了,C語言理論上是可以通過上面說的CVM的方法實現完整的編譯器的,那么實際上是怎樣做到簡化的呢?這張圖是不是有點熟悉?對了就是在講虛擬機的時候見到過,不過這里是CVM(C Language Virtual Machine),每種語言都是在每個虛擬層上可以獨立實現編譯的,并且除了C語言外,每一層的輸出都將作為下一層的輸入(最后一層的輸出就是應用程序了),這和滾雪球是一個道理。用手(匯編語言)把一小把雪結合在一起,一點點地滾下去就形成了一個大雪球,這大概就是所謂的0生1,1生C,C生萬物吧?
下面是C99的關鍵字:?
?
仔細看看,其實其中有很多關鍵字是為了幫助編譯器進行優化的,還有一些是用來限定變量、函數的作用域、鏈接性或者生存周期(函數沒有)的,這些在編譯器實現的早期根本不必加上,于是可以去掉auto, restrict, extern, volatile, const, sizeof, static, inline, register, typedef,這樣就形成了C的子集,C3語言,C3語言的關鍵字如下:?
?
再想一想,發現C3中其實有很多類型和類型修飾符是沒有必要一次性都加上去的,比如三種整型,只要實現int就行了,因此進一步去掉這些關鍵詞,它們是:unsigned, float, short, char(char 是 int), signed, _Bool, _Complex, _Imaginary, long,這樣就形成了我們的C2語言,C2語言關鍵字如下:
?
繼續思考,即使是只有18個關鍵字的C2語言,依然有很多高級的地方,比如基于基本數據類型的復合數據結構,另外我們的關鍵字表中是沒有寫運算符的,在C語言中的復合賦值運算符->、運算符的++、– 等過于靈活的表達方式此時也可以完全刪除掉,因此可以去掉的關鍵字有:enum, struct, union,這樣我們可以得到C1語言的關鍵字:?
接近完美了,不過最后一步手筆自然要大一點。這個時候數組和指針也要去掉了,另外C1語言其實仍然有很大的冗雜度,比如控制循環和分支的都有多種表述方法,其實都可簡化成一種,具體的來說,循環語句有while循環,do…while循環和for循環,只需要保留while循環就夠了;分支語句又有if…{}, if…{}…else, if…{}…else if…, switch,這四種形式,它們都可以通過兩個以上的if…{}來實現,因此只需要保留if,…{}就夠了。可是再一想,所謂的分支和循環不過是條件跳轉語句罷了,函數調用語句也不過是一個壓棧和跳轉語句罷了,因此只需要goto(未限制的goto)。因此大膽去掉所有結構化關鍵字,連函數也沒有,得到的C0語言關鍵字如下:
?
只有5個關鍵字,已經完全可以用匯編語言快速的實現了。通過逆向分析我們還原了第一個C語言編譯器的編寫過程,也感受到了前輩科學家們的智慧和勤勞!我們都不過是巨人肩膀上的灰塵罷了!0生1,1生C,C生萬物,實在巧妙!
========
學習較底層編程:動手寫一個C語言編譯器
http://blog.jobbole.com/77305/動手編寫一個編譯器,學習一下較為底層的編程方式,是一種學習計算機到底是如何工作的非常有效方法。
編譯器通常被看作是十分復雜的工程。事實上,編寫一個產品級的編譯器也確實是一個龐大的任務。但是寫一個小巧可用的編譯器卻不是這么困難。
秘訣就是首先去找到一個最小的可用工程,然后把你想要的特性添加進去。這個方法也是Abdulaziz Ghuloum在他那篇著名的論文“一種構造編譯器的捷徑”里所提到的辦法。不過這個辦法確實可行。你只需要按照這篇論文中的第一步來操作,就可以得到一個真正可用的編譯器!當然,它只能編譯程序語言中的非常小的子集,但是它確實是一個真實可用的編譯器。你可以隨意的擴展這個編譯器,然后從中學到更多更深的知識。
受到這篇文章的鼓舞,我就寫了一個C編譯器。從某種意義上來說這比寫一個scheme的編譯器要困難一些(因為你必須去解析C那復雜的語法),但是在某些方面又很便利(你不需要去處理運行時類型)。要寫這樣一個編譯器,你只需要從你那個可用的最小的編譯器開始。
對于我寫的編譯器來說,我把它叫 babyc,我選了這段代碼來作為我需要運行的第一個程序:
C
int main() {
? ? return 2;
}
int main() {
? ? return 2;
}
沒有變量,沒有函數調用,沒有額外的依賴,甚至連if語句,循環語句都沒有,一切看起來是那么簡單。
我們首先需要解析這段代碼。我們將使用 Flex 和 Bison 來做到這點。這里有怎么用的例子可以參考,幸好我們的語法是如此簡單,下面就是詞法分析器:
"{" { return '{'; }
"}" { return '}'; }
"(" { return '('; }
")" { return ')'; }
";" { return ';'; }
[0-9]+ { return NUMBER; }
"return" { return RETURN; }
"int" { return TYPE; }
"main" { return IDENTIFIER; }
"{" { return '{'; }
"}" { return '}'; }
"(" { return '('; }
")" { return ')'; }
";" { return ';'; }
[0-9]+ { return NUMBER; }
"return" { return RETURN; }
"int" { return TYPE; }
"main" { return IDENTIFIER; }
這里是語法分析器:
function:
TYPE IDENTIFIER '(' ')' '{' expression '}'
;
expression:
RETURN NUMBER ';'
;
function:
TYPE IDENTIFIER '(' ')' '{' expression '}'
;
?
expression:
RETURN NUMBER ';'
;
最終,我們需要生成一些匯編代碼。我們將使用32位的X86匯編,因為它非常的通用而且可以很容易的運行在你的機器上。這里有X86匯編的相關網站。
下面就是我們需要生成的匯編代碼:
.text
? ? ? ? .global _start # Tell the loader we want to start at _start.
_start:
? ? ? ? movl ? ?$2,%ebx # The argument to our system call.
? ? ? ? movl ? ?$1,%eax # The system call number of sys_exit is 1.
? ? ? ? int ? ? $0x80 # Send an interrupt
.text
? ? ? ? .global _start # Tell the loader we want to start at _start.
?
_start:
? ? ? ? movl ? ?$2,%ebx # The argument to our system call.
? ? ? ? movl ? ?$1,%eax # The system call number of sys_exit is 1.
? ? ? ? int ? ? $0x80 # Send an interrupt
然后加上上面的詞法語法分析代碼,把這段匯編代碼寫進一個文件里。恭喜你!你已經是一個編譯器的編寫者了!
Babyc 就是這樣誕生的,你可以在這里看到它最開始的樣子。
當然,如果匯編代碼沒辦法運行也是枉然。讓我們來用編譯器生成我們所希望的真正的匯編代碼。
Shell
# Here's the file we want to compile.
$ cat return_two.c
#include <stdio.h>
int main() {
? ? return 2;
}
# Run the compiler with this file.
$ ./babyc return_two.c
Written out.s.
# Check the output looks sensible.
$ cat out.s
.text
? ? .global _start
_start:
? ? movl ? ?$2, %ebx
? ? movl ? ?$1, %eax
? ? int ? ? $0x80
# Here's the file we want to compile.
$ cat return_two.c
#include <stdio.h>
?
int main() {
? ? return 2;
}
?
# Run the compiler with this file.
$ ./babyc return_two.c
Written out.s.
?
# Check the output looks sensible.
$ cat out.s
.text
? ? .global _start
?
_start:
? ? movl ? ?$2, %ebx
? ? movl ? ?$1, %eax
? ? int ? ? $0x80
非常棒!接著讓我們來真正的運行一下編譯之后代碼來確保它能得到我們所想的結果。
Shell
# Assemble the file. We explicitly assemble as 32-bit
# to avoid confusion on x86_64 machines.
$ as out.s -o out.o --32
# Link the file, again specifying 32-bit.
$ ld -m elf_i386 -s -o out out.o
# Run it!
$ ./out
# What was the return code?
$ echo $?
2 # Woohoo!
# Assemble the file. We explicitly assemble as 32-bit
# to avoid confusion on x86_64 machines.
$ as out.s -o out.o --32
?
# Link the file, again specifying 32-bit.
$ ld -m elf_i386 -s -o out out.o
?
# Run it!
$ ./out
?
# What was the return code?
$ echo $?
2 # Woohoo!
我們踏出了第一步,接下去怎么做就全看你了。你可以按照那篇文章所指導的全部做一遍,然后制作一個更加復雜的編譯器。你需要去寫一個更加精巧的語法樹來生成匯編代碼。接下去的幾步分別是:(1)允許返回任意的值(比如,return 3; 一些可執行代碼);(2)添加對“非”的支持(比如,return ~1; 一些可執行代碼)。每一個額外的特性都可以教你關于C語言的更多知識,編譯器到底是怎么執行的,以及世界上其他編寫編譯器的人是如何想的。
這是構建 babyc 的方法。Babyc 現在已經擁有了if語句,循環,變量以及最基礎的數據結構。歡迎你來check out它的代碼,但是我希望看完我的文章你能夠自己動手寫一個。
不要害怕底層的一些事情。這是一個非常奇妙的世界。
========
Yacc 與 Lex 快速入門
https://www.ibm.com/developerworks/cn/linux/sdk/lex/Lex 與 Yacc 介紹
Lex 和 Yacc 是 UNIX 兩個非常重要的、功能強大的工具。事實上,如果你熟練掌握 Lex 和 Yacc 的話,它們的強大功能使創建 FORTRAN 和 C 的編譯器如同兒戲。Ashish Bansal 為您詳細的討論了編寫自己的語言和編譯器所用到的這兩種工具,包括常規表達式、聲明、匹配模式、變量、Yacc 語法和解析器代碼。最后,他解釋了怎樣把 Lex 和 Yacc 結合起來。
Lex 代表 Lexical Analyzar。Yacc 代表 Yet Another Compiler Compiler。 讓我們從 Lex 開始吧。
Lex
Lex 是一種生成掃描器的工具。掃描器是一種識別文本中的詞匯模式的程序。 這些詞匯模式(或者常規表達式)在一種特殊的句子結構中定義,這個我們一會兒就要討論。
一種匹配的常規表達式可能會包含相關的動作。這一動作可能還包括返回一個標記。 當 Lex 接收到文件或文本形式的輸入時,它試圖將文本與常規表達式進行匹配。 它一次讀入一個輸入字符,直到找到一個匹配的模式。 如果能夠找到一個匹配的模式,Lex 就執行相關的動作(可能包括返回一個標記)。 另一方面,如果沒有可以匹配的常規表達式,將會停止進一步的處理,Lex 將顯示一個錯誤消息。
Lex 和 C 是強耦合的。一個 .lex 文件(Lex 文件具有 .lex 的擴展名)通過 lex 公用程序來傳遞,并生成 C 的輸出文件。這些文件被編譯為詞法分析器的可執行版本。
Lex 的常規表達式
常規表達式是一種使用元語言的模式描述。表達式由符號組成。符號一般是字符和數字,但是 Lex 中還有一些具有特殊含義的其他標記。 下面兩個表格定義了 Lex 中使用的一些標記并給出了幾個典型的例子。
用 Lex 定義常規表達式
字符 含義
A-Z, 0-9, a-z 構成了部分模式的字符和數字。
. 匹配任意字符,除了 \n。
- 用來指定范圍。例如:A-Z 指從 A 到 Z 之間的所有字符。
[ ] 一個字符集合。匹配括號內的 任意 字符。如果第一個字符是 ^ 那么它表示否定模式。例如: [abC] 匹配 a, b, 和 C中的任何一個。
* 匹配 0個或者多個上述的模式。
+ 匹配 1個或者多個上述模式。
? 匹配 0個或1個上述模式。
$ 作為模式的最后一個字符匹配一行的結尾。
{ } 指出一個模式可能出現的次數。 例如: A{1,3} 表示 A 可能出現1次或3次。
\ 用來轉義元字符。同樣用來覆蓋字符在此表中定義的特殊意義,只取字符的本意。
^ 否定。
| 表達式間的邏輯或。
"<一些符號>" 字符的字面含義。元字符具有。
/ 向前匹配。如果在匹配的模版中的“/”后跟有后續表達式,只匹配模版中“/”前 面的部分。如:如果輸入 A01,那么在模版 A0/1 中的 A0 是匹配的。
( ) 將一系列常規表達式分組。
常規表達式舉例
常規表達式 含義
joke[rs] 匹配 jokes 或 joker。
A{1,2}shis+ 匹配 AAshis, Ashis, AAshi, Ashi。
(A[b-e])+ 匹配在 A 出現位置后跟隨的從 b 到 e 的所有字符中的 0 個或 1個。
Lex 中的標記聲明類似 C 中的變量名。每個標記都有一個相關的表達式。 (下表中給出了標記和表達式的例子。) 使用這個表中的例子,我們就可以編一個字數統計的程序了。 我們的第一個任務就是說明如何聲明標記。
標記聲明舉例
標記 相關表達式 含義
數字(number) ([0-9])+ 1個或多個數字
字符(chars) [A-Za-z] 任意字符
空格(blank) " " 一個空格
字(word) (chars)+ 1個或多個 chars
變量(variable) (字符)+(數字)*(字符)*(數字)*
回頁首
Lex 編程
Lex 編程可以分為三步:
以 Lex 可以理解的格式指定模式相關的動作。
在這一文件上運行 Lex,生成掃描器的 C 代碼。
編譯和鏈接 C 代碼,生成可執行的掃描器。
注意: 如果掃描器是用 Yacc 開發的解析器的一部分,只需要進行第一步和第二步。 關于這一特殊問題的幫助請閱讀 Yacc和 將 Lex 和 Yacc 結合起來部分。
現在讓我們來看一看 Lex 可以理解的程序格式。一個 Lex 程序分為三個段:第一段是 C 和 Lex 的全局聲明,第二段包括模式(C 代碼),第三段是補充的 C 函數。 例如, 第三段中一般都有 main() 函數。這些段以%%來分界。 那么,回到字數統計的 Lex 程序,讓我們看一下程序不同段的構成。
回頁首
C 和 Lex 的全局聲明
這一段中我們可以增加 C 變量聲明。這里我們將為字數統計程序聲明一個整型變量,來保存程序統計出來的字數。 我們還將進行 Lex 的標記聲明。
字數統計程序的聲明
? ? ? ?%{
? ? ? ? int wordCount = 0;
? ? ? ? %}
? ? ? ? chars [A-za-z\_\'\.\"]
? ? ? ? numbers ([0-9])+
? ? ? ? delim [" "\n\t]
? ? ? ? whitespace {delim}+
? ? ? ? words {chars}+
? ? ? ? %%
兩個百分號標記指出了 Lex 程序中這一段的結束和三段中第二段的開始。
回頁首
Lex 的模式匹配規則
讓我們看一下 Lex 描述我們所要匹配的標記的規則。(我們將使用 C 來定義標記匹配后的動作。) 繼續看我們的字數統計程序,下面是標記匹配的規則。
字數統計程序中的 Lex 規則
? ? ? ?{words} { wordCount++; /*
? ? ? ? increase the word count by one*/ }
? ? ? ? {whitespace} { /* do
? ? ? ? nothing*/ }
? ? ? ? {numbers} { /* one may
? ? ? ? want to add some processing here*/ }
? ? ? ? %%
C 代碼
Lex 編程的第三段,也就是最后一段覆蓋了 C 的函數聲明(有時是主函數)。注意這一段必須包括 yywrap() 函數。 Lex 有一套可供使用的函數和變量。 其中之一就是 yywrap。 一般來說,yywrap() 的定義如下例。我們將在 高級 Lex 中探討這一問題。
字數統計程序的 C 代碼段
? ? ? ?void main()
? ? ? ? {
? ? ? ? yylex(); /* start the
? ? ? ? analysis*/
? ? ? ? printf(" No of words:
? ? ? ? %d\n", wordCount);
? ? ? ? }
? ? ? ? int yywrap()
? ? ? ? {
? ? ? ? return 1;
? ? ? ? }
上一節我們討論了 Lex 編程的基本元素,它將幫助你編寫簡單的詞法分析程序。 在 高級 Lex 這一節中我們將討論 Lex 提供的函數,這樣你就能編寫更加復雜的程序了。
將它們全部結合起來
.lex文件是 Lex 的掃描器。它在 Lex 程序中如下表示:
? ?$ lex <file name.lex>
這生成了 lex.yy.c 文件,它可以用 C 編譯器來進行編譯。它還可以用解析器來生成可執行程序,或者在鏈接步驟中通過選項 �ll 包含 Lex 庫。
這里是一些 Lex 的標志:
-c表示 C 動作,它是缺省的。
-t寫入 lex.yy.c 程序來代替標準輸出。
-v提供一個兩行的統計匯總。
-n不打印 -v 的匯總。
高級 Lex
Lex 有幾個函數和變量提供了不同的信息,可以用來編譯實現復雜函數的程序。 下表中列出了一些變量和函數,以及它們的使用。 詳盡的列表請參考 Lex 或 Flex 手冊(見后文的 資源)。
Lex 變量
yyin FILE* 類型。 它指向 lexer 正在解析的當前文件。
yyout FILE* 類型。 它指向記錄 lexer 輸出的位置。 缺省情況下,yyin 和 yyout 都指向標準輸入和輸出。
yytext 匹配模式的文本存儲在這一變量中(char*)。
yyleng 給出匹配模式的長度。
yylineno 提供當前的行數信息。 (lexer不一定支持。)
Lex 函數
yylex() 這一函數開始分析。 它由 Lex 自動生成。
yywrap() 這一函數在文件(或輸入)的末尾調用。 如果函數的返回值是1,就停止解析。 因此它可以用來解析多個文件。 代碼可以寫在第三段,這就能夠解析多個文件。 方法是使用 yyin 文件指針(見上表)指向不同的文件,直到所有的文件都被解析。 最后,yywrap() 可以返回 1 來表示解析的結束。
yyless(int n) 這一函數可以用來送回除了前�n? 個字符外的所有讀出標記。
yymore() 這一函數告訴 Lexer 將下一個標記附加到當前標記后。
對 Lex 的討論就到這里。下面我們來討論 Yacc...
Yacc
Yacc 代表 Yet Another Compiler Compiler。 Yacc 的 GNU 版叫做 Bison。它是一種工具,將任何一種編程語言的所有語法翻譯成針對此種語言的 Yacc 語 法解析器。它用巴科斯范式(BNF, Backus Naur Form)來書寫。按照慣例,Yacc 文件有 .y 后綴。編譯行如下調用 Yacc 編譯器:
? ? ? ?$ yacc <options>
? ? ? ? <filename ending with .y>
在進一步闡述以前,讓我們復習一下什么是語法。在上一節中,我們看到 Lex 從輸入序列中識別標記。 如果你在查看標記序列,你可能想在這一序列出現時執行某一動作。 這種情況下有效序列的規范稱為語法。Yacc 語法文件包括這一語法規范。 它還包含了序列匹配時你想要做的事。
為了更加說清這一概念,讓我們以英語為例。 這一套標記可能是:名詞, 動詞, 形容詞等等。為了使用這些標記造一個語法正確的句子,你的結構必須符合一定的規則。 一個簡單的句子可能是名詞+動詞或者名詞+動詞+名詞。(如 I care. See spot run.)
所以在我們這里,標記本身來自語言(Lex),并且標記序列允許用 Yacc 來指定這些標記(標記序列也叫語法)。
終端和非終端符號
終端符號 : 代表一類在語法結構上等效的標記。 終端符號有三種類型:
命名標記: 這些由 %token 標識符來定義。 按照慣例,它們都是大寫。
字符標記 : 字符常量的寫法與 C 相同。例如, -- 就是一個字符標記。
字符串標記 : 寫法與 C 的字符串常量相同。例如,"<<" 就是一個字符串標記。
lexer 返回命名標記。
非終端符號 : 是一組非終端符號和終端符號組成的符號。 按照慣例,它們都是小寫。 在例子中,file 是一個非終端標記而 NAME 是一個終端標記。
用 Yacc 來創建一個編譯器包括四個步驟:
通過在語法文件上運行 Yacc 生成一個解析器。
說明語法:
編寫一個 .y 的語法文件(同時說明 C 在這里要進行的動作)。
編寫一個詞法分析器來處理輸入并將標記傳遞給解析器。 這可以使用 Lex 來完成。
編寫一個函數,通過調用 yyparse() 來開始解析。
編寫錯誤處理例程(如 yyerror())。
編譯 Yacc 生成的代碼以及其他相關的源文件。
將目標文件鏈接到適當的可執行解析器庫。
用 Yacc 編寫語法
如同 Lex 一樣, 一個 Yacc 程序也用雙百分號分為三段。 它們是:聲明、語法規則和 C 代碼。 我們將解析一個格式為 姓名 = 年齡 的文件作為例子,來說明語法規則。 我們假設文件有多個姓名和年齡,它們以空格分隔。 在看 Yacc 程序的每一段時,我們將為我們的例子編寫一個語法文件。
回頁首
C 與 Yacc 的聲明
C 聲明可能會定義動作中使用的類型和變量,以及宏。 還可以包含頭文件。每個 Yacc 聲明段聲明了終端符號和非終端符號(標記)的名稱,還可能描述操作符優先級和針對不同符號的數據類型。 lexer (Lex) 一般返回這些標記。所有這些標記都必須在 Yacc 聲明中進行說明。
在文件解析的例子中我們感興趣的是這些標記:name, equal sign, 和 age。Name 是一個完全由字符組成的值。 Age 是數字。于是聲明段就會像這樣:
文件解析例子的聲明
? ? ? ?%
? ? ? ? #typedef char* string; /*
? ? ? ? to specify token types as char* */
? ? ? ? #define YYSTYPE string /*
? ? ? ? a Yacc variable which has the value of returned token */
? ? ? ? %}
? ? ? ? %token NAME EQ AGE
? ? ? ? %%
你可能會覺得 YYSTYPE 有點奇怪。但是類似 Lex, Yacc 也有一套變量和函數可供用戶來進行功能擴展。 YYSTYPE 定義了用來將值從 lexer 拷貝到解析器或者 Yacc 的 yylval (另一個 Yacc 變量)的類型。 默認的類型是 int。 由于字符串可以從 lexer 拷貝,類型被重定義為 char*。 關于 Yacc 變量的詳細討論,請參考 Yacc 手冊(見 資源)。
Yacc 語法規則
Yacc 語法規則具有以下一般格式:
? ? ? ?result: components { /*
? ? ? ? action to be taken in C */ }
? ? ? ? ;
在這個例子中,result 是規則描述的非終端符號。Components 是根據規則放在一起的不同的終端和非終端符號。 如果匹配特定序列的話 Components 后面可以跟隨要執行的動作。 考慮如下的例子:
? ? ? ?param : NAME EQ NAME {
? ? ? ? printf("\tName:%s\tValue(name):%s\n", $1,$3);}
? ? ? ? ? ? | NAME EQ VALUE{
? ? ? ? ? ? printf("\tName:%s\tValue(value):%s\n",$1,$3);}
? ? ? ? ;
如果上例中序列 NAME EQ NAME 被匹配,將執行相應的 { } 括號中的動作。 這里另一個有用的就是 $1 和 $3 的使用, 它們引用了標記 NAME 和 NAME(或者第二行的 VALUE)的值。 lexer 通過 Yacc 的變量 yylval 返回這些值。標記 NAME 的 Lex 代碼是這樣的:
? ? ? ?char [A-Za-z]
? ? ? ? name {char}+
? ? ? ? %%
? ? ? ? {name} { yylval = strdup(yytext);
? ? ? ? return NAME; }
文件解析例子的規則段是這樣的:
文件解析的語法
? ? ? ?file : record file
? ? ? ? | record
? ? ? ? ;
? ? ? ? record: NAME EQ AGE {
? ? ? ? printf("%s is now %s years old!!!", $1, $3);}
? ? ? ? ;
? ? ? ? %%
附加 C 代碼
現在讓我們看一下語法文件的最后一段,附加 C 代碼。 (這一段是可選的,如果有人想要略過它的話:)一個函數如 main() 調用 yyparse() 函數(Yacc 中 Lex 的 yylex() 等效函數)。 一般來說,Yacc 最好提供 yyerror(char msg) 函數的代碼。 當解析器遇到錯誤時調用 yyerror(char msg)。錯誤消息作為參數來傳遞。 一個簡單的 yyerror( char* ) 可能是這樣的:
? ? ? ?int yyerror(char* msg)
? ? ? ? {
? ? ? ? printf("Error: %s
? ? ? ? encountered at line number:%d\n", msg, yylineno);
? ? ? ? }
yylineno 提供了行數信息。
這一段還包括文件解析例子的主函數:
附加 C 代碼
? ? ? ?void main()
? ? ? ? {
? ? ? ? ? ? yyparse();
? ? ? ? }
? ? ? ? int yyerror(char* msg)
? ? ? ? {
? ? ? ? printf("Error: %s
? ? ? ? encountered \n", msg);
要生成代碼,可能用到以下命令:
? ? ? ?$ yacc _d <filename.y>
這生成了輸出文件 y.tab.h 和 y.tab.c,它們可以用 UNIX 上的任何標準 C 編譯器來編譯(如 gcc)。
命令行的其他常用選項
'-d' ,'--defines' : 編寫額外的輸出文件,它們包含這些宏定義:語法中定義的標記類型名稱,語義的取值類型 YYSTYPE, 以及一些外部變量聲明。如果解析器輸出文件名叫 'name.c', 那么 '-d' 文件就叫做 'name.h'。 如果你想將 yylex 定義放到獨立的源文件中,你需要 'name.h', 因為 yylex 必須能夠引用標記類型代碼和 yylval變量。
'-b file-prefix' ,'--file-prefix=prefix' : 指定一個所有Yacc輸出文件名都可以使用的前綴。選擇一個名字,就如輸入文件名叫 'prefix.c'.
'-o outfile' ,'--output-file=outfile' : 指定解析器文件的輸出文件名。其他輸出文件根據 '-d' 選項描述的輸出文件來命名。
Yacc 庫通常在編譯步驟中自動被包括。但是它也能被顯式的包括,以便在編譯步驟中指定 �ly選項。這種情況下的編譯命令行是:
? ? ? ?$ cc <source file
? ? ? ? names> -ly
將 Lex 與 Yacc 結合起來
到目前為止我們已經分別討論了 Lex 和 Yacc。現在讓我們來看一下他們是怎樣結合使用的。
一個程序通常在每次返回一個標記時都要調用 yylex() 函數。只有在文件結束或者出現錯誤標記時才會終止。
一個由 Yacc 生成的解析器調用 yylex() 函數來獲得標記。 yylex() 可以由 Lex 來生成或完全由自己來編寫。 對于由 Lex 生成的 lexer 來說,要和 Yacc 結合使用,每當 Lex 中匹配一個模式時都必須返回一個標記。 因此 Lex 中匹配模式時的動作一般格式為:
? ? ? ?{pattern} { /* do smthg*/
? ? ? ? return TOKEN_NAME; }
于是 Yacc 就會獲得返回的標記。當 Yacc 編譯一個帶有 _d 標記的 .y文件時,會生成一個頭文件,它對每個標記都有 #define 的定義。 如果 Lex 和 Yacc 一起使用的話,頭文件必須在相應的 Lex 文件 .lex中的 C 聲明段中包括。
讓我們回到名字和年齡的文件解析例子中,看一看 Lex 和 Yacc 文件的代碼。
Name.y - 語法文件
? ? ? ?%
? ? ? ? typedef char* string;
? ? ? ? #define YYSTYPE string
? ? ? ? %}
? ? ? ? %token NAME EQ AGE
? ? ? ? %%
? ? ? ? file : record file
? ? ? ? | record
? ? ? ? ;
? ? ? ? record : NAME EQ AGE {
? ? ? ? printf("%s is %s years old!!!\n", $1, $3); }
? ? ? ? ;
? ? ? ? %%
? ? ? ? int main()
? ? ? ? {
? ? ? ? yyparse();
? ? ? ? return 0;
? ? ? ? }
? ? ? ? int yyerror(char *msg)
? ? ? ? {
? ? ? ? printf("Error
? ? ? ? encountered: %s \n", msg);
? ? ? ? }
Name.lex - Lex 的解析器文件
? ? ? ?%{
? ? ? ? #include "y.tab.h"
? ? ? ??
? ? ? ? #include <stdio.h>
? ? ? ? #include <string.h>
? ? ? ? extern char* yylval;
? ? ? ? %}
? ? ? ? char [A-Za-z]
? ? ? ? num [0-9]
? ? ? ? eq [=]
? ? ? ? name {char}+
? ? ? ? age {num}+
? ? ? ? %%
? ? ? ? {name} { yylval = strdup(yytext);
? ? ? ? return NAME; }
? ? ? ? {eq} { return EQ; }
? ? ? ? {age} { yylval = strdup(yytext);
? ? ? ? return AGE; }
? ? ? ? %%
? ? ? ? int yywrap()
? ? ? ? {
? ? ? ? return 1;
? ? ? ? }
作為一個參考,我們列出了 y.tab.h, Yacc 生成的頭文件。
y.tab.h - Yacc 生成的頭文件
? ? ? ?# define NAME 257
? ? ? ? # define EQ 258
? ? ? ? # define AGE 259
我們對于 Lex 和 Yacc的討論到此為止。今天你想要編譯什么語言呢?
========
利用yacc和lex制作一個小的計算器
http://www.tuicool.com/articles/yqumyyi由于本人是身處弱校,學校的課程沒有編譯原理這一門課,所以就想看這兩章,了解一下編譯原理,增加一下自己的軟實力。免得被別人鄙視。
一、安裝yacc和lex
我是在Windows下使用這兩個軟件的。所以使用bison代替yacc,用flex代替lex。兩者的下載地址是 http://sourceforge.net/projects/winflexbison/ 我的gcc環境是使用以前用過的mingw。我們吧解壓后的flex和bison放到mingw的bin目錄下。這一步就完成了。
二、編譯代碼
先編譯代碼,看一下結果,然后在分析。在這本書中提供的一個網址有書中代碼下載。下載地址 ?http://avnpc.com/pages/devlang ?下載后找到mycalc這個文件夾。然后執行下面進行編譯
1 bison --yacc -dv mycalc.y -o y.tab.c
2 flex mycalc.l
3 gcc -o mycalc y.tab.c lex.yy.c
三、yacc/lex是什么
一般編程語言的語法處理,都會有以下的過程。
1.詞法分析
將源代碼分割成若干個記號的處理。
2.語法分析
即從記號構建分析樹的處理。分析樹也叫作語法樹或抽象語法樹。
3.語義分析
經過語法分析生成的分析樹,并不包含數據類型等語義信息。因此在語義分析階段,會檢查程序中是否含有語法正確但是存在邏輯問題的錯誤。
4.生成代碼
如果是C語言等生成機器碼的編譯器或Java這樣生成字節碼的編譯器,在分析樹構建完畢后會進入代碼生成階段。
例如有下面源代碼
1 if(a==10)
2 {
3 ? ? printf("hoge\n");
4 }
5 else
6 {
7 ? ? printf("piyo\n");
8 }
執行詞法分析后,將被分割為如下的記號(每一塊就是一個記號)
對此進行語法分析后構建的分析樹,如下圖所示
執行詞法分析的程序稱為詞法分析器。lex的工作就是根據詞法規則自動生成詞法分析器。
執行語法分析的程序則稱為解析器。yacc就是根據語法規則自動生成解析器的程序。
四、分析計算器代碼
1.mycalc.l源代碼
?1 %{
?2 #include <stdio.h>
?3 #include "y.tab.h"
?4?
?5 int
?6 yywrap(void)
?7 {
?8 ? ? return 1;
?9 }
10 %}
11 %%
12 "+" ? ? ? ? ? ? return ADD;
13 "-" ? ? ? ? ? ? return SUB;
14 "*" ? ? ? ? ? ? return MUL;
15 "/" ? ? ? ? ? ? return DIV;
16 "\n" ? ? ? ? ? ?return CR;
17 ([1-9][0-9]*)|0|([0-9]+\.[0-9]*) {
18 ? ? double temp;
19 ? ? sscanf(yytext, "%lf", &temp);
20 ? ? yylval.double_value = temp;
21 ? ? return DOUBLE_LITERAL;
22 }
23 [ \t] ;
24 . {
25 ? ? fprintf(stderr, "lexical error.\n");
26 ? ? exit(1);
27 }
28 %%
第一行到第十行是一個定義區塊,lex中用 %{...}%定義,這里面代碼將原樣輸出。
第11行到第28行是一個規則區塊。語法大概就是前面一部分是使用正則表達式后面一部分是返回匹配到后這一部分是類型標記。大括號里面是動作。例如 ([1-9][0-9]*)|0|([0-9]+\.[0-9]*)是匹配小數,然后對這個小數進行sscanf處理后返回一個DOUBLE_LITERAL類型。
2.mycalc.y 源代碼
?1 %{
?2 #include <stdio.h>
?3 #include <stdlib.h>
?4 #define YYDEBUG 1
?5 %}
?6 %union {
?7 ? ? int ? ? ? ? ?int_value;
?8 ? ? double ? ? ? double_value;
?9 }
10 %token <double_value> ? ? ?DOUBLE_LITERAL
11 %token ADD SUB MUL DIV CR
12 %type <double_value> expression term primary_expression
13 %%
14 line_list
15 ? ? : line
16 ? ? | line_list line
17 ? ? ;
18 line
19 ? ? : expression CR
20 ? ? {
21 ? ? ? ? printf(">>%lf\n", $1);
22 ? ? }
23 expression
24 ? ? : term
25 ? ? | expression ADD term
26 ? ? {
27 ? ? ? ? $$ = $1 + $3;
28 ? ? }
29 ? ? | expression SUB term
30 ? ? {
31 ? ? ? ? $$ = $1 - $3;
32 ? ? }
33 ? ? ;
34 term
35 ? ? : primary_expression
36 ? ? | term MUL primary_expression?
37 ? ? {
38 ? ? ? ? $$ = $1 * $3;
39 ? ? }
40 ? ? | term DIV primary_expression
41 ? ? {
42 ? ? ? ? $$ = $1 / $3;
43 ? ? }
44 ? ? ;
45 primary_expression
46 ? ? : DOUBLE_LITERAL
47 ? ? ; ? ? ? ? ? ? ? ??
48 %%
49 int
50 yyerror(char const *str)
51 {
52 ? ? extern char *yytext;
53 ? ? fprintf(stderr, "parser error near %s\n", yytext);
54 ? ? return 0;
55 }
56?
57 int main(void)
58 {
59 ? ? extern int yyparse(void);
60 ? ? extern FILE *yyin;
61?
62 ? ? yyin = stdin;
63 ? ? if (yyparse()) {
64 ? ? ? ? fprintf(stderr, "Error ! Error ! Error !\n");
65 ? ? ? ? exit(1);
66 ? ? }
67 }
上面第13行到第48行,語法規則簡化為下面格式
A
? ? : B C
? ? | D
? ? ;
即A的定義是B與C的組合,或者為D。
上面的過程可以用一個游戲的方式解釋。就是一個數字是定位為DOUBLE_LITERAL類型,通過第45行的規則可以將DOUBLE_LITERAL升級成primary_expression類型,然后通過34行規則可以升級為term類型。又term類型可以升級為expression類型。所以 “2+4” 符合的規則是數字2升級到expression類型,而當數字4升級到term類型時,此時的狀態是 expression ADD term 通過第25行的規則可以得到兩者的結合,得到一個term類型。(PS:這個時候讓我想起了一個動漫,就是數碼寶貝,類型可以進行進化,進化,超進化,究極進化,還可以合體進化。<笑>)上面一個專業的叫法是叫做歸約。
由于歸約情況比較復雜和不好講,我就截書本上的原圖進行講解吧。
至于左結合或右結合是由寫的詞法分析器來決定的。例如給出的代碼,為什么是右結合呢,是因為用到了遞歸,所以會首先和低級的類型進行結合,這就是為什么MUL,DIV是term類型,ADD,SUB是expression類型,就是處理優先級的問題。
對于C或Java有這樣的一個問題
a+++++b;
我們可以分析為a++ + ++b 為什么編譯器還會報錯呢?是因為我們如果定義優先級的話,++優先級大于+。那么在代碼中就是實現為盡量使++在一起,而不是+優先,如果是+優先的話,那么每次都不會結合為++。所以代碼在詞法分析器階段代碼就會被分割成a ++ ++ + b ;這樣幾段。從而錯誤的。由于詞法分析器和解析器是各自獨立的。又因為詞法分析器先于語法分析器運行。
? 上面的過程就是這樣進行語法分析的。上面的過程雖然簡單,但是如果用代碼實現就有點困難了。我們使用yacc生成的執行文件就是對上面模擬的執行代碼,使用yacc自動生成的。如果我們要自制編程語言的話,那么這個過程就要自己寫了。因為有很多細節問題。不過不多說了,我們先了解這個就行。生成后的代碼文件是y.tab.c y.tab.h 。生成的代碼有幾十K呢,我們要了解這個過程還是比較難的。
五、用代碼實現詞法分析器
該代碼在calc/llparser目錄下
lexicalanalyzer.c
? 1 #include <stdio.h>
? 2 #include <stdlib.h>
? 3 #include <ctype.h>
? 4 #include "token.h"
? 5?
? 6 static char *st_line;
? 7 static int st_line_pos;
? 8?
? 9 typedef enum {
?10 ? ? INITIAL_STATUS,
?11 ? ? IN_INT_PART_STATUS,
?12 ? ? DOT_STATUS,
?13 ? ? IN_FRAC_PART_STATUS
?14 } LexerStatus;
?15?
?16 void
?17 get_token(Token *token)
?18 {
?19 ? ? int out_pos = 0;
?20 ? ? LexerStatus status = INITIAL_STATUS;
?21 ? ? char current_char;
?22?
?23 ? ? token->kind = BAD_TOKEN;
?24 ? ? while (st_line[st_line_pos] != '\0') {
?25 ? ? ? ? current_char = st_line[st_line_pos];
?26 ? ? ? ? if ((status == IN_INT_PART_STATUS || status == IN_FRAC_PART_STATUS)
?27 ? ? ? ? ? ? && !isdigit(current_char) && current_char != '.') {
?28 ? ? ? ? ? ? token->kind = NUMBER_TOKEN;
?29 ? ? ? ? ? ? sscanf(token->str, "%lf", &token->value);
?30 ? ? ? ? ? ? return;
?31 ? ? ? ? }
?32 ? ? ? ? if (isspace(current_char)) {
?33 ? ? ? ? ? ? if (current_char == '\n') {
?34 ? ? ? ? ? ? ? ? token->kind = END_OF_LINE_TOKEN;
?35 ? ? ? ? ? ? ? ? return;
?36 ? ? ? ? ? ? }
?37 ? ? ? ? ? ? st_line_pos++;
?38 ? ? ? ? ? ? continue;
?39 ? ? ? ? }
?40?
?41 ? ? ? ? if (out_pos >= MAX_TOKEN_SIZE-1) {
?42 ? ? ? ? ? ? fprintf(stderr, "token too long.\n");
?43 ? ? ? ? ? ? exit(1);
?44 ? ? ? ? }
?45 ? ? ? ? token->str[out_pos] = st_line[st_line_pos];
?46 ? ? ? ? st_line_pos++;
?47 ? ? ? ? out_pos++;
?48 ? ? ? ? token->str[out_pos] = '\0';
?49?
?50 ? ? ? ? if (current_char == '+') {
?51 ? ? ? ? ? ? token->kind = ADD_OPERATOR_TOKEN;
?52 ? ? ? ? ? ? return;
?53 ? ? ? ? } else if (current_char == '-') {
?54 ? ? ? ? ? ? token->kind = SUB_OPERATOR_TOKEN;
?55 ? ? ? ? ? ? return;
?56 ? ? ? ? } else if (current_char == '*') {
?57 ? ? ? ? ? ? token->kind = MUL_OPERATOR_TOKEN;
?58 ? ? ? ? ? ? return;
?59 ? ? ? ? } else if (current_char == '/') {
?60 ? ? ? ? ? ? token->kind = DIV_OPERATOR_TOKEN;
?61 ? ? ? ? ? ? return;
?62 ? ? ? ? } else if (isdigit(current_char)) {
?63 ? ? ? ? ? ? if (status == INITIAL_STATUS) {
?64 ? ? ? ? ? ? ? ? status = IN_INT_PART_STATUS;
?65 ? ? ? ? ? ? } else if (status == DOT_STATUS) {
?66 ? ? ? ? ? ? ? ? status = IN_FRAC_PART_STATUS;
?67 ? ? ? ? ? ? }
?68 ? ? ? ? } else if (current_char == '.') {
?69 ? ? ? ? ? ? if (status == IN_INT_PART_STATUS) {
?70 ? ? ? ? ? ? ? ? status = DOT_STATUS;
?71 ? ? ? ? ? ? } else {
?72 ? ? ? ? ? ? ? ? fprintf(stderr, "syntax error.\n");
?73 ? ? ? ? ? ? ? ? exit(1);
?74 ? ? ? ? ? ? }
?75 ? ? ? ? } else {
?76 ? ? ? ? ? ? fprintf(stderr, "bad character(%c)\n", current_char);
?77 ? ? ? ? ? ? exit(1);
?78 ? ? ? ? }
?79 ? ? }
?80 }
?81?
?82 void
?83 set_line(char *line)
?84 {
?85 ? ? st_line = line;
?86 ? ? st_line_pos = 0;
?87 }
?88?
?89 #if 1
?90 void
?91 parse_line(char *buf)
?92 {
?93 ? ? Token token;
?94?
?95 ? ? set_line(buf);
?96?
?97 ? ? for (;;) {
?98 ? ? ? ? get_token(&token);
?99 ? ? ? ? if (token.kind == END_OF_LINE_TOKEN) {
100 ? ? ? ? ? ? break;
101 ? ? ? ? } else {
102 ? ? ? ? ? ? printf("kind..%d, str..%s\n", token.kind, token.str);
103 ? ? ? ? }
104 ? ? }
105 }
106?
107 int
108 main(int argc, char **argv)
109 {
110 ? ? char buf[1024];
111?
112 ? ? while (fgets(buf, 1024, stdin) != NULL) {
113 ? ? ? ? parse_line(buf);
114 ? ? }
115?
116 ? ? return 0;
117 }
118 #endif
View Code
token.h
?1 #ifndef TOKEN_H_INCLUDED
?2 #define TOKEN_H_INCLUDED
?3?
?4 typedef enum {
?5 ? ? BAD_TOKEN,
?6 ? ? NUMBER_TOKEN,
?7 ? ? ADD_OPERATOR_TOKEN,
?8 ? ? SUB_OPERATOR_TOKEN,
?9 ? ? MUL_OPERATOR_TOKEN,
10 ? ? DIV_OPERATOR_TOKEN,
11 ? ? END_OF_LINE_TOKEN
12 } TokenKind;
13?
14 #define MAX_TOKEN_SIZE (100)
15?
16 typedef struct {
17 ? ? TokenKind kind;
18 ? ? double ? ? ?value;
19 ? ? char ? ? ? ?str[MAX_TOKEN_SIZE];
20 } Token;
21?
22 void set_line(char *line);
23 void get_token(Token *token);
24?
25 #endif /* TOKEN_H_INCLUDED */
View Code
上面使用的方法是DFA(確定有限狀態自動機)
上面的圖有些指向error的箭頭沒有標出,不過這個圖就大概描述了這個過程。可以自己baidu一些狀態機的知識。
有了這兩章的基礎就可以自己寫個分析器了(作用:以后寫應用程序時,要給程序一個配置文件時,可以自己寫個腳本進行解析,方便用戶書寫配置文件。不過現在都使用xml語法了,都還有解析的庫呢。都不知道學了以后還有沒有機會用到實際中呢)。不過循環和判斷就還不能實現。
========
總結
以上是生活随笔為你收集整理的自己写编译器学习总结的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: win32 DLL 学习总结
- 下一篇: C#内存映射文件学习总结