编译器是一个程序,做用是将一门语言翻译成另外一门语言。前端
例如 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 上,有兴趣的话能够看看。