簡單的說 編譯器
就是語言翻譯器,它通常將高級語言翻譯成更低級的語言,如 GCC 可將 C/C++ 語言翻譯成可執行機器語言,Java 編譯器能夠將 Java 源代碼翻譯成 Java 虛擬機能夠執行的字節碼。html
編譯器如此神奇,那麼它究竟是如何工做的呢?本文將簡單介紹編譯器的原理,並實現一個簡單的編譯器,使它能編譯咱們自定義語法格式的源代碼。(文中使用的源碼都已上傳至 GitHub 以方便查看)。c++
爲了簡潔易懂,咱們的編譯器將只支持如下簡單功能:git
數據類型只支持整型,這樣不須要數據類型符;github
支持 加(+)
,減(-)
,乘(*)
, 除(/)
運算正則表達式
支持函數調用ubuntu
支持 extern
(爲了調用 printf
打印計算結果)bash
如下是咱們要支持的源碼實例 demo.xy:框架
extern printi(val) sum(a, b) { return a + b } mult(a, b) { return a * b } printi(mult(4, 5) - sum(4, 5))
通常編譯器有如下工做步驟:ide
詞法分析(Lexical analysis): 此階段的任務是從左到右一個字符一個字符地讀入源程序,對構成源程序的字符流進行掃描而後根據構詞規則識別 單詞(Token)
,完成這個任務的組件是 詞法分析器(Lexical analyzer,簡稱Lexer)
,也叫 掃描器(Scanner)
;函數
語法分析(Syntactic analysis,也叫 Parsing): 此階段的主要任務是由 詞法分析器
生成的單詞構建 抽象語法樹(Abstract Syntax Tree ,AST)
,完成此任務的組件是 語法分析器(Parser)
;
目標碼生成: 此階段編譯器會遍歷上一步生成的抽象語法樹,而後爲每一個節點生成 機器 / 字節碼
。
編譯器完成編譯後,由 連接器(Linker)
將生成的目標文件連接成可執行文件,這一步並非必須的,一些依賴於虛擬機運行的語言(如 Java,Erlang)就不須要連接。
對應編譯器工做步驟咱們將使用如下工具,括號裏標明瞭所使用的版本號:
Flex(2.6.0): Flex 是 Lex 開源替代品,他們都是 詞法分析器
製做工具,它能夠根據咱們定義的規則生成 詞法分析器
的代碼;
Bison(3.0.4): Bison 是 語法分析器
的製做工具,一樣它能夠根據咱們定義的規則生成 語法分析器
的代碼;
LLVM(3.8.0): LLVM 是構架編譯器的框架系統,咱們會利用他來完成從 抽象語法樹
生成目標碼的過程。
在 ubuntu 上能夠經過如下命令安裝這些工具:
sudo apt-get install flex sudo apt-get install bison sudo apt-get install llvm-3.8*
介紹完工具,如今咱們能夠開始實現咱們的編譯器了。
前面提到 詞法分析器
要將源程序分解成 單詞
,咱們的語法格式很簡單,只包括:標識符,數字,數學運算符,括號和大括號等,咱們將經過 Flex 來生成 詞法分析器
的源碼,給 Flex 使用的規則文件 lexical.l 以下:
%{ #include <string> #include "ast.h" #include "syntactic.hpp" #define SAVE_TOKEN yylval.string = new std::string(yytext, yyleng) #define TOKEN(t) (yylval.token = t) %} %option noyywrap %% [ \t\n] ; "extern" return TOKEN(TEXTERN); "return" return TOKEN(TRETURN); [a-zA-Z_][a-zA-Z0-9_]* SAVE_TOKEN; return TIDENTIFIER; [0-9]+ SAVE_TOKEN; return TINTEGER; "=" return TOKEN(TEQUAL); "==" return TOKEN(TCEQ); "!=" return TOKEN(TCNE); "(" return TOKEN(TLPAREN); ")" return TOKEN(TRPAREN); "{" return TOKEN(TLBRACE); "}" return TOKEN(TRBRACE); "," return TOKEN(TCOMMA); "+" return TOKEN(TPLUS); "-" return TOKEN(TMINUS); "*" return TOKEN(TMUL); "/" return TOKEN(TDIV); . printf("Unknown token!\n"); yyterminate(); %%
咱們來解釋一下,這個文件被 2 個 %%
分紅 3 部分,第 1 部分用 %{
與 %}
包括的是一些 C++ 代碼,會被原樣複製到 Flex 生成的源碼文件中,還能夠在指定一些選項,如咱們使用了 %option noyywrap
,也能夠在這定義宏供後面使用;第 2 部分用來定義構成單詞的規則,能夠看到每條規都是一個 正則表達式
和 動做
,很直白,就是 詞法分析器
發現了匹配的 單詞
後執行相應的 動做
代碼,大部分只要返回 單詞
給調用者就能夠了;第 3 部分能夠定義一些函數,也會原樣複製到生成的源碼中去,這裏咱們留空沒有使用。
如今咱們能夠經過調用 Flex 生成 詞法分析器
的源碼:
flex -o lexical.cpp lexical.l
生成的 lexical.cpp 裏會有一個 yylex()
函數供 語法分析器
調用;你可能發現了,有些宏和變量並無被定義(如 TEXTERN
,yylval
,yytext
等),其實有些是 Flex 會自動定義的內置變量(如 yytext
),有些是後面 語法分析器
生成工具裏定義的變量(如 yylval
),咱們後面會看到。
語法分析器
的做用是構建 抽象語法樹
,通俗的說 抽象語法樹
就是將源碼用樹狀結構來表示,每一個節點都表明源碼中的一種結構;對於咱們要實現的語法,其語法樹是很簡單的,以下:
如今咱們使用 Bison 生成 語法分析器
代碼,一樣 Bison 須要一個規則文件,咱們的規則文件 syntactic.y 以下,限於篇幅,省略了某些部分,能夠經過連接查看完整內容:
%{ #include "ast.h" #include <cstdio> ... extern int yylex(); void yyerror(const char *s) { std::printf("Error: %s\n", s);std::exit(1); } %} ... %token <token> TLPAREN TRPAREN TLBRACE TRBRACE TCOMMA ... %% program: stmts { programBlock = $1; } ; ... func_decl: ident TLPAREN func_decl_args TRPAREN block { $$ = new NFunctionDeclaration(*$1, *$3, *$5); delete $3; } ; ... %%
是否是發現和 Flex 的規則文件很像呢?確實是這樣,它也是分 3 個部分組成,一樣,第一部分的 C++ 代碼會被複制到生成的源文件中,還能夠看到這裏經過如下這樣的語法定義前面了 Flex 使用的宏:
%token <token> TLPAREN TRPAREN TLBRACE TRBRACE TCOMMA
比較不一樣的是第 2 部分,不像 Flex 經過 正則表達式
經過定義規則,這裏使用的是 巴科斯範式(BNF: Backus-Naur Form)
的形式定義了咱們識別的語法結構。以下的語法表示函數:
func_decl: ident TLPAREN func_decl_args TRPAREN block { $$ = new NFunctionDeclaration(*$1, *$3, *$5); delete $3; } ;
能夠看到後面大括號中間的也是 動做
代碼,上例的動做是在 抽象語法樹
中生成一個函數的節點,其實這部分的其餘規則也是生成相應類型的節點到語法樹中。像 NFunctionDeclaration
這是一個咱們本身定義的節點類,咱們在 ast.h 中定義了咱們所要用到的節點,一樣的,咱們摘取一段代碼以下:
... class NFunctionDeclaration : public NStatement { public: const NIdentifier& id; VariableList arguments; NBlock& block; NFunctionDeclaration(const NIdentifier& id, const VariableList& arguments, NBlock& block) : id(id), arguments(arguments), block(block) { } virtual llvm::Value* codeGen(CodeGenContext& context); }; ...
能夠看到,它有 標識符(id)
,參數列表(arguments)
,函數體(block)
這些成員,在語法分析階段會設置好這些成員的內容供後面的 目標碼生成
階段使用。還能夠看到有一個 codeGen()
虛函數,你可能猜到了,後面就是經過調用它來生成相應的目標代碼。
咱們能夠經過如下命令調用 Bison 生成 語法分析器
的源碼文件,這裏咱們使用 -d
使頭文件和源文件分開,由於前面 詞法分析器
的源碼使用了這裏定義的一些宏,因此須要使用這個頭文件,這裏將會生成 syntactic.cpp
和 syntactic.hpp
:
bison -d -o syntactic.cpp syntactic.y
這是最後一步了,這一步的主角是前面提到 LLVM,LLVM 是一個構建編譯器的框架系統,咱們使用他遍歷 語法分析
階段生成的 抽象語法樹
,而後爲每一個節點生成相應的 目標碼
。固然,沒法避免的是咱們須要使用 LLVM 提供的函數來編寫生成目標碼的源碼,就是實現前面提到的虛函數 codeGen()
,是否是有點拗口?不過確實是這樣。咱們在 gen.cpp 中編寫了不一樣節點的生成代碼,咱們摘取一段看一下:
... Value *NMethodCall::codeGen(CodeGenContext &context) { Function *function = context.module->getFunction(id.name.c_str()); if (function == NULL) { std::cerr << "no such function " << id.name << endl; } std::vector<Value *> args; ExpressionList::const_iterator it; for (it = arguments.begin(); it != arguments.end(); it++) { args.push_back((**it).codeGen(context)); } CallInst *call = CallInst::Create(function, makeArrayRef(args), "", context.currentBlock()); std::cout << "Creating method call: " << id.name << endl; return call; } ...
看起來有點複雜,簡單來講就是經過 LLVM 提供的接口來生成 目標碼
,須要瞭解更多的話能夠去 LLVM 的官網學習一下。
至此,咱們全部的工做基本都作完了。簡單回顧一下:咱們先經過 Flex 生成 詞法分析器
源碼文件 lexical.cpp
,而後經過 Bison 生成 語法分析器
源碼文件 syntactic.cpp
和頭文件 syntactic.hpp
,咱們本身編寫了 抽象語法樹
節點定義文件 ast.h 和 目標碼
生成文件 ast.cpp,還有一個 gen.h 包含一點 LLVM 環境相關的代碼,爲了輸出咱們程序的結果,還在 printi.cpp 裏簡單的經過調用 C 語言庫函數實現了輸出一個整數。
對了,咱們還須要一個 main
函數做爲編譯器的入口函數,它在 main.cpp 裏:
... int main(int argc, char **argv) { yyparse(); InitializeNativeTarget(); InitializeNativeTargetAsmPrinter(); InitializeNativeTargetAsmParser(); CodeGenContext context; context.generateCode(*programBlock); context.runCode(); return 0; }
咱們能夠看到其調用了 yyparse()
作 語法分析
,(yyparse()
內部會先調用 yylex()
作 詞法分析
);而後是一系列的 LLVM 初始化代碼,context.generateCode(*programBlock)
是開始生成 目標碼
;最後是 context.runCode()
來運行代碼,這裏使用了 LLVM 的 JIT(Just In Time)
來直接運行代碼,沒有連接的過程。
如今咱們能夠用這些文件生成咱們的編譯器了,須要說明一下,由於 詞法分析器
的源碼使用了一些 語法分析器
頭文件中的宏,因此正確的生成順序是這樣的:
bison -d -o syntactic.cpp syntactic.y flex -o lexical.cpp lexical.l syntactic.hpp g++ -c `llvm-config --cppflags` -std=c++11 syntactic.cpp gen.cpp lexical.cpp printi.cpp main.cpp g++ -o xy-complier syntactic.o gen.o main.o lexical.o printi.o `llvm-config --libs` `llvm-config --ldflags` -lpthread -ldl -lz -lncurses -rdynamic
若是你下載了 GitHub 的源碼,那麼直接:
cd src make
就能夠完成以上過程了,正常會生成一個二進制文件 xy-complier
,它就是咱們的編譯器了。
咱們使用以前提到實例 demo.xy 來測試,將其內容傳給 xy-complier
的標準輸入就能夠看到運行結果了:
cat demo.xy | ./xy-complier
也能夠直接經過
make test
來測試,輸出以下:
... define internal i64 @mult(i64 %a1, i64 %b2) { entry: %a = alloca i64 %0 = load i64, i64* %a store i64 %a1, i64* %a %b = alloca i64 %1 = load i64, i64* %b store i64 %b2, i64* %b %2 = load i64, i64* %b %3 = load i64, i64* %a %4 = mul i64 %3, %2 ret i64 %4 } Running code: 11 Exiting...
能夠看到最後正確輸出了指望的結果,至此咱們簡單的編譯器就完成了。