編譯器是一個程序,做用是將一門語言翻譯成另外一門語言。前端
例如 babel 就是一個編譯器,它將 es6 版本的 js 翻譯成 es5 版本的 js。從這個角度來看,將英語翻譯成中文的翻譯軟件也屬於編譯器。java
通常的程序,CPU 是沒法直接執行的,由於 CPU 只能識別機器指令。因此要想執行一個程序,首先要將高級語言編寫的程序翻譯爲彙編代碼(Java 還多了一個步驟,將高級語言翻譯成字節碼),再將彙編代碼翻譯爲機器指令,這樣 CPU 才能識別並執行。git
因爲彙編語言和機器語言一一對應,而且彙編語言更具備可讀性。因此計算機原理的教材在講解機器指令時通常會用匯編語言來代替機器語言講解。程序員
本文所要寫的四則運算編譯器須要將 1 + 1
這樣的四則運算表達式翻譯成機器指令並執行。具體過程請看示例:es6
// CPU 沒法識別 10 + 5 // 翻譯成彙編語言 push 10 push 5 add // 最後翻譯爲機器指令,彙編代碼和機器指令一一對應 // 機器指令由 1 和 0 組成,如下指令非真實指令,只作演示用 0011101001010101 1101010011100101 0010100111100001
四則運算編譯器,雖說功能很簡單,只能編譯四則運算表達式。可是編譯原理的前端部分幾乎都有涉及:詞法分析、語法分析。另外還有編譯原理後端部分的代碼生成。不論是簡單的、複雜的編譯器,編譯步驟是差很少的,只是複雜的編譯器實現上會更困難。github
可能有人會問,學會編譯原理有什麼好處?算法
我認爲對編譯過程內部原理的掌握將會使你成爲更好的高級程序員。另外在這引用一下知乎網友-隨心所往的回答,更加具體:express
好了,下面讓咱們看一下如何寫一個四則運算編譯器。後端
程序其實就是保存在文本文件中的一系列字符,詞法分析的做用是將這一系列字符按照某種規則分解成一個個字元(token,也稱爲終結符),忽略空格和註釋。數組
示例:
// 程序代碼 10 + 5 + 6 // 詞法分析後獲得的 token 10 + 5 + 6
終結符就是語言中用到的基本元素,它不能再被分解。
四則運算中的終結符包括符號和整數常量(暫不支持一元操做符和浮點運算)。
+ - * / ( )
function lexicalAnalysis(expression) { const symbol = ['(', ')', '+', '-', '*', '/'] const re = /\d/ const tokens = [] const chars = expression.trim().split('') let token = '' chars.forEach(c => { if (re.test(c)) { token += c } else if (c == ' ' && token) { tokens.push(token) token = '' } else if (symbol.includes(c)) { if (token) { tokens.push(token) token = '' } tokens.push(c) } }) if (token) { tokens.push(token) } return tokens } console.log(lexicalAnalysis('100 + 23 + 34 * 10 / 2')) // ["100", "+", "23", "+", "34", "*", "10", "/", "2"]
x*
, 表示 x 出現零次或屢次x | y
, 表示 x 或 y 將出現( )
圓括號,用於語言構詞的分組如下規則從左往右看,表示左邊的表達式還能繼續往下細分紅右邊的表達式,一直細分到不可再分爲止。
+ - * /
addExpression
對應 +
-
表達式,mulExpression
對應 *
/
表達式。
若是你看不太懂以上的規則,那就先放下,繼續往下看。看看怎麼用代碼實現語法分析。
對輸入的文本按照語法規則進行分析並肯定其語法結構的一種過程,稱爲語法分析。
通常語法分析的輸出爲抽象語法樹(AST)或語法分析樹(parse tree)。但因爲四則運算比較簡單,因此這裏採起的方案是即時地進行代碼生成和錯誤報告,這樣就不須要在內存中保存整個程序結構。
先來看看怎麼分析一個四則運算表達式 1 + 2 * 3
。
首先匹配的是 expression
,因爲目前 expression
往下分只有一種可能,即 addExpression
,因此分解爲 addExpression
。
依次類推,接下來的順序爲 mulExpression
、term
、1
(integerConstant)、+
(op)、mulExpression
、term
、2
(integerConstant)、*
(op)、mulExpression
、term
、3
(integerConstant)。
以下圖所示:
這裏可能會有人有疑問,爲何一個表達式搞得這麼複雜,expression
下面有 addExpression
,addExpression
下面還有 mulExpression
。
其實這裏是爲了考慮運算符優先級而設的,mulExpr
比 addExpr
表達式運算級要高。
1 + 2 * 3 compileExpression | compileAddExpr | | compileMultExpr | | | compileTerm | | | |_ matches integerConstant push 1 | | |_ | | matches '+' | | compileMultExpr | | | compileTerm | | | |_ matches integerConstant push 2 | | | matches '*' | | | compileTerm | | | |_ matches integerConstant push 3 | | |_ compileOp('*') * | |_ compileOp('+') + |_
有不少算法可用來構建語法分析樹,這裏只講兩種算法。
遞歸降低分析法,也稱爲自頂向下分析法。按照語法規則一步步遞歸地分析 token 流,若是遇到非終結符,則繼續往下分析,直到終結符爲止。
遞歸降低分析法是簡單高效的算法,LL(0)在此基礎上多了一個步驟,當第一個 token 不足以肯定元素類型時,對下一個字元採起「提早查看」,有可能會解決這種不肯定性。
以上是對這兩種算法的簡介,具體實現請看下方的代碼實現。
咱們一般用的四則運算表達式是中綴表達式,可是對於計算機來講中綴表達式不便於計算。因此在代碼生成階段,要將中綴表達式轉換爲後綴表達式。
後綴表達式,又稱逆波蘭式,指的是不包含括號,運算符放在兩個運算對象的後面,全部的計算按運算符出現的順序,嚴格從左向右進行(再也不考慮運算符的優先規則)。
示例:
中綴表達式: 5 + 5
轉換爲後綴表達式:5 5 +
,而後再根據後綴表達式生成代碼。
// 5 + 5 轉換爲 5 5 + 再生成代碼 push 5 push 5 add
編譯原理的理論知識像天書,常常讓人看得雲裏霧裏,但真正動手作起來,你會發現,其實還挺簡單的。
若是上面的理論知識看不太懂,不要緊,先看代碼實現,而後再和理論知識結合起來看。
注意:這裏須要引入剛纔的詞法分析代碼。
// 彙編代碼生成器 function AssemblyWriter() { this.output = '' } AssemblyWriter.prototype = { writePush(digit) { this.output += `push ${digit}\r\n` }, writeOP(op) { this.output += op + '\r\n' }, //輸出彙編代碼 outputStr() { return this.output } } // 語法分析器 function Parser(tokens, writer) { this.writer = writer this.tokens = tokens // tokens 數組索引 this.i = -1 this.opMap1 = { '+': 'add', '-': 'sub', } this.opMap2 = { '/': 'div', '*': 'mul' } this.init() } Parser.prototype = { init() { this.compileExpression() }, compileExpression() { this.compileAddExpr() }, compileAddExpr() { this.compileMultExpr() while (true) { this.getNextToken() if (this.opMap1[this.token]) { let op = this.opMap1[this.token] this.compileMultExpr() this.writer.writeOP(op) } else { // 沒有匹配上相應的操做符 這裏爲沒有匹配上 + - // 將 token 索引後退一位 this.i-- break } } }, compileMultExpr() { this.compileTerm() while (true) { this.getNextToken() if (this.opMap2[this.token]) { let op = this.opMap2[this.token] this.compileTerm() this.writer.writeOP(op) } else { // 沒有匹配上相應的操做符 這裏爲沒有匹配上 * / // 將 token 索引後退一位 this.i-- break } } }, compileTerm() { this.getNextToken() if (this.token == '(') { this.compileExpression() this.getNextToken() if (this.token != ')') { throw '缺乏右括號:)' } } else if (/^\d+$/.test(this.token)) { this.writer.writePush(this.token) } else { throw '錯誤的 token:第 ' + (this.i + 1) + ' 個 token (' + this.token + ')' } }, getNextToken() { this.token = this.tokens[++this.i] }, getInstructions() { return this.writer.outputStr() } } const tokens = lexicalAnalysis('100+10*10') const writer = new AssemblyWriter() const parser = new Parser(tokens, writer) const instructions = parser.getInstructions() console.log(instructions) // 輸出生成的彙編代碼 /* push 100 push 10 push 10 mul add */
如今來模擬一下 CPU 執行機器指令的狀況,因爲彙編代碼和機器指令一一對應,因此咱們能夠建立一個直接執行彙編代碼的模擬器。
在建立模擬器前,先來說解一下相關指令的操做。
在內存中,棧的特色是隻能在同一端進行插入和刪除的操做,即只有 push 和 pop 兩種操做。
push 指令的做用是將一個操做數推入棧中。
pop 指令的做用是將一個操做數彈出棧。
add 指令的做用是執行兩次 pop 操做,彈出兩個操做數 a 和 b,而後執行 a + b,再將結果 push 到棧中。
sub 指令的做用是執行兩次 pop 操做,彈出兩個操做數 a 和 b,而後執行 a - b,再將結果 push 到棧中。
mul 指令的做用是執行兩次 pop 操做,彈出兩個操做數 a 和 b,而後執行 a * b,再將結果 push 到棧中。
sub 指令的做用是執行兩次 pop 操做,彈出兩個操做數 a 和 b,而後執行 a / b,再將結果 push 到棧中。
四則運算的全部指令已經講解完畢了,是否是以爲很簡單?
注意:須要引入詞法分析和語法分析的代碼
function CpuEmulator(instructions) { this.ins = instructions.split('\r\n') this.memory = [] this.re = /^(push)\s\w+/ this.execute() } CpuEmulator.prototype = { execute() { this.ins.forEach(i => { switch (i) { case 'add': this.add() break case 'sub': this.sub() break case 'mul': this.mul() break case 'div': this.div() break default: if (this.re.test(i)) { this.push(i.split(' ')[1]) } } }) }, add() { const b = this.pop() const a = this.pop() this.memory.push(a + b) }, sub() { const b = this.pop() const a = this.pop() this.memory.push(a - b) }, mul() { const b = this.pop() const a = this.pop() this.memory.push(a * b) }, div() { const b = this.pop() const a = this.pop() // 不支持浮點運算,因此在這要取整 this.memory.push(Math.floor(a / b)) }, push(x) { this.memory.push(parseInt(x)) }, pop() { return this.memory.pop() }, getResult() { return this.memory[0] } } const tokens = lexicalAnalysis('(100+ 10)* 10-100/ 10 +8* (4+2)') const writer = new AssemblyWriter() const parser = new Parser(tokens, writer) const instructions = parser.getInstructions() const emulator = new CpuEmulator(instructions) console.log(emulator.getResult()) // 1138
一個簡單的四則運算編譯器已經實現了。咱們再來寫一個測試函數跑一跑,看看運行結果是否和咱們期待的同樣:
function assert(expression, result) { const tokens = lexicalAnalysis(expression) const writer = new AssemblyWriter() const parser = new Parser(tokens, writer) const instructions = parser.getInstructions() const emulator = new CpuEmulator(instructions) return emulator.getResult() == result } console.log(assert('1 + 2 + 3', 6)) // true console.log(assert('1 + 2 * 3', 7)) // true console.log(assert('10 / 2 * 3', 15)) // true console.log(assert('(10 + 10) / 2', 10)) // true
測試所有正確。另外附上完整的源碼,建議沒看懂的同窗再看多兩遍。
對於工業級編譯器來講,這個四則運算編譯器屬於玩具中的玩具。可是人不可能一口吃成個胖子,因此學習編譯原理最好採起按部就班的方式去學習。下面來介紹一個高級一點的編譯器,這個編譯器能夠編譯一個 Jack 語言(類 Java 語言),它的語法大概是這樣的:
class Generate { field String str; static String str1; constructor Generate new(String s) { let str = s; return this; } method String getString() { return str; } } class Main { function void main() { var Generate str; let str = Generate.new("this is a test"); do Output.printString(str.getString()); return; } }
上面代碼的輸出結果爲:this is a test
。
想不想實現這樣的一個編譯器?
這個編譯器出自一本書《計算機系統要素》,它從第 6 章開始,一直到第 11 章講解了彙編編譯器(將彙編語言轉換爲機器語言)、VM 編譯器(將相似於字節碼的 VM 語言翻譯成彙編語言)、Jack 語言編譯器(將高級語言 Jack 翻譯成 VM 語言)。每一章都有詳細的知識點講解和實驗,只要你一步一步跟着作實驗,就能最終實現這樣的一個編譯器。
若是編譯器寫完了,最後機器語言在哪執行呢?
這本書已經爲你考慮好了,它從第 1 章到第 5 章,一共五章的內容。教你從邏輯門開始,逐步組建出算術邏輯單元 ALU、CPU、內存,最終搭建出一個現代計算機。而後讓你用編譯器編譯出來的程序運行在這臺計算機之上。
另外,這本書的第 12 章會教你寫操做系統的各類庫函數,例如 Math 庫(包含各類數學運算)、Keyboard 庫(按下鍵盤是怎麼輸出到屏幕上的)、內存管理等等。
想看一看全書共 12 章的實驗作完以後是怎麼樣的嗎?我這裏提供幾張這臺模擬計算機運行程序的 DEMO GIF,供你們參考參考。
這幾張圖中的右上角是「計算機」的屏幕,其餘部分是「計算機」的堆棧區和指令區。
這本書的全部實驗我都已經作完了(天天花 3 小時,兩個月就能作完),答案放在個人 github 上,有興趣的話能夠看看。