项目介绍
本章的任务是将jack语言翻译成vm语言。上一章我们已经完成了编译器的tokenizen部分,即获取字元的部分,而且上一章的compilationEngine部分输出了一个xml文档。但是先别的得意,因为这两章的complilationEngine差别很大,基本上无法重复使用上一章的代码,建议重写。否则你会很头大。
https://github.com/Metaphor-pump/ecs/tree/master/projects/11
方法论和一些工具
- 一点一点来
初学者中没有人能够看着规范就把编译器写出来。所以我们唯一的方法就是,从简化的编译器开始写起。什么是简化的编译器呢?project中给了几个样例程序,试着按书中的顺序去翻译他们。顺序由简到难,每次你只要实现编译器的一部分功能,你就可以编译一个小程序。你看最小的程序有这么简单。
- 对照着正确答案来
tools工具包里给了一个完整的编译器,你可以拿它来编译jack程序以得到正确的vm程序。用正确的vm程序对照着jack程序,你就知道,jack程序的每一个部分应该如何翻译了。这个方法,是你写出编译器最基础也最重要的方法。
- 一个小工具
textcomparer,文本文件的对比工具,对于太长的vm文件,用眼睛看就无能为力了。用unix的同学可以直接用diff
jackcompiler和textcomparer都是没有gui的,想要使用请添加路径后在cmd中使用
具体教程在这里http://www.nand2tetris.org/software.php
- 静态分析
vm文件没有正确的被翻译时不要总想着F5,一般来说,在你对如何翻译有思路的情况下,vm文件没有翻译正确。那都是token的行进有问题,token可能过度行进到了未知的位置。检查代码看看哪里产生了不必要的行进。或者看看jack的语法规范,看看规范的内容和自己理解的内容到底一不一样。(这个问题,后面有详细分析)
理论
本章的最难的内容就是代码的翻译,这部分也属于理论问题,应当提前思考,而不是在写代码的时候再思考,写代码只是去实现它。jack语法树的每个节点应该被翻译成什么样的代码,是本章的重点。
首先让我们看看jack的语法规范。熟知这部分规范对写编译器很有帮助。
接下来让我们顺着语法树来递归下降的分析。我以伪代码的形势写出。
compileClass
compileClassVarDec(这里的声明什么都不用翻译)
compileSubroutine
compileSubroutine
compileParameterList(不用翻译)
compileSubroutineBody(kind)
compileSubroutineBody(self,kind):
compileVarDec()
if kind == 'constructor':
n = len(classScope.table)
vm.writePush('constant',n)
vm.writeCall('Memory.alloc',1)
vm.writePop('pointer',0)
elif kind == 'method':
vm.writePush('argument',0)
vm.writePop('pointer',0)
compileStatements()
compileVarDec
vm.writeFun(funName,varCnt)
在compileVarDec过后我们需要用vm语言对应的声明一个jack函数,为什么我们不能在这之前声明,因为function name nArgs中nArgs需要知道一个函数的局部变量个数。而在compileVarDec之前,我们是不知道局部变量的个数的。
compileSubroutineBody子程序的处理根据类型的不同有不同的处理方式,constructor,method,function的翻译各有不同的地方,但是又相近。
constructor和method都需要知道对象所在的内存地址,并将存入vm的虚拟内存段pointer 0中(在jack的vm虚拟机规范中,pointer 0 这个内存专门用于存储当前函数所属的对象的地址,这个地址用来access对象中的变量),而function不需要这个步骤。他们三者最后都需要向下一级compilestatement。
constructor函数运行时需要为对象分配地址。所以我们要事先知道对象需要多长的地址,这需要之前的compileClassVarDec所生成的symboltable来统计classscope有多少变量n。然后push constant n,然后将n作为参数call Memory.alloc 1,这个内存分配函数返回了一个地址push在了堆栈中,我们再将这个地址pop 到pointer 0中。自此,分配地址的工作就完成了,剩余的语句由 compileStatements完成。
对于method函数,它被执行时需要做的就是将第一个参数放到pointer 0中,因为method的第一个参数被规定为对象的地址。这需要do语句的设计的配合。因为do语句遇到一个method类型的函数时,它会首先将函数所属对象的地址入栈,作为method的第一个参数,然后再将这个函数的参数表中的参数入栈。实际上这种将对象作为method的第一个参数的实现方法,python就在使用,我不知道底层细节,但是它看起来是这样的。例如你在类内定义一个函数时,你可能会写def aaa(self,arg1,arg2)。这个self,就是对象的指针。但是python其实没必要这样设计,即使设计成self隐藏起来不写也没关系。而且这样可能更好更容易理解。因为一个类的变量和函数总是写在一个文件当中的,所以我们的大脑会默认为他们在同一个scope当中,所以在同一个scope中再把self这个东西用参数传来传去会显得很蠢。python的这个设计除了把语言的底层实现细节暴露给程序员,给大家的大脑增加负担以外没有任何作用。
compileStatements
while nextToken() in('let','if','do','while','return'):
if nextToken() == 'let':
compileLet()
elif self.nextToken() == 'if':
compileIf()
elif nextToken() == 'do':
compileDo()
elif nextToken() == 'while':
compileWhile()
elif nextToken() == 'return':
compileReturn()
依旧是下降到下一个节点分析
compileLet
If nexttoken == '['
compileExp()
segment,index= self.findVarInScope(name)
vm.writePush(segment,index)
vm.writeOp('+')
compileExp()
vm.writePop('temp',0)
vm.writePop('pointer',1)
vm.writePush('temp',0)
vm.writePop('that',0)
Else:
segment,index= self.findVarInScope(name)
compileExp()
vm.writePop(segment,index)
Let语句做的事是把一个值赋给一个变量。那么分几步,一,计算等号右边的表达式值,二,找到变量的内存段,三,存入内存。jack语句中的变量分两种,一种是数组,其他的是另外一种。
先分析第二种的代码,十分简单,第一步就是计算等号右边的值compileexp就是用来完成这个事的。第二步就是在symboltable中找这个变量的vm内存段。最后把堆栈中计算出的值pop到相应的变量的内存段中。
然后是第一种,第一步首先要计算数组的地址,将基地址和index地址相加。例如a[i]的地址就是a+i。第二步,计算表达式值。第三步,这一步和之前不同,因为我们这次得到的,是一个变量的真实的地址,而不是一个虚拟内存段的地址,所以不能用简单的pop segment n来完成赋值。首先我们要先把地址加载到指针当中vm.writePop('pointer',1),但因为栈顶是计算完的表达式的值,所以在这之前我们得先暂时移开它vm.writePop('temp',0)。再vm.writePop('pointer',1),之后我们在将它移到堆栈中vm.writePush('temp',0)。最后将栈顶的值pop到相应的地址中vm.writePop('that',0)。
compileDo
compileSubroutinecall
vm.writePop('temp',0)
这个很简单,最后把存在栈顶的函数计算得出的值丢弃掉就行了。因为Subroutinecall本质上是一个表达式,所以我们可以改成compileexp
compileIf
if和while是一个重点,它帮助你理解如何用跳转实现它。
它写出来是这样的
Compileexp
If-goto TRUE
Goto FALSE
(TRUE)
CompileStatements
Goto END
(FALSE)
CompileStatements
(END)
只有if没有else的写法有点不一样,大家看我的代码即可,这里不列出。
compileWhile
它写出来是这样的
(EXP)
Compileexp
Not
If-goto END
CompileStatement
Goto EXP
(END)
这两个控制流结构非常的精妙,如果你想不出来,或者无法理解,请多看几遍,直到能够理解并默写为止。
compileReturn
分两种,有返回值的和没有的。
有则计算compileexp,没有则push constant 0
最后写一个vm的return
compileExpList和compileExp暂略,稍后再讲
compileterm是本编译器的一个重点部分。term是exp的一个组成部分,term分为好几种,整数常量,字符串常量,带一元运算符的表达式,关键字常量,小括号(表示优先级),数组元素取值,函数调用,变量取值。一点点来介绍吧
字符串常量
直接push constant n
关键字常量
根据不同的关键字直接pushconstant n
字符串常量
最终会返回一个字符串的指针在栈顶。那么分两步,一个是申请内存,首先要知道字符串长度,push constant lenth,用系统内建的函数申请字符串,call string.new 1,这个函数会返回一个指针。第二步,此时指针中的字符串还是空的,我们需要添加字符进去。这里我直接贴代码,我也不太懂string.appendchar 是怎么运作的,直接抄吧。
带一元运算符的表达式
先用vm语言对表达式求值,然后在写vm运算符,-是neg,~是not
小括号
略过小括号直接,compileexp
函数调用
这是最难的,函数调用写出来其实就一句话,callxxx.yyy() nArgs ,但是分析起来却很难,分两种情况。
一种是method的函数调用,这个对象可能是在类中声明的,也可能是在函数中声明的,首先在两种作用域中找到对象名的类名,然后分析它的参数个数n,然后 callclass.method n+1
另一种是fuction和constructor的调用,顺带一提系统类全都是fuction,没有method。直接分析参数个数后,直接call class.function n。
但是!!!
实际上并不能这么干。因为你不知道调用的函数到底是method还是function还是constuctor。第一你没有表记录他们,第二就算你记录了他们,他们还是有可能在后文中出现。正确的解决方法是onepass建表,twopass翻译程序。这太麻烦了。因为所给的程序里,method都是通过对象调用,function和construtor都是通过类调用(我忘了规范是不是规定必须这样调用了)。所以,我们分别在两个作用域中检查第一个名字到底是类还是对象就行了,如果在作用域中没有检查到对象存在,就说明是类调用,检查到了,就是对象调用。
最后还有另一种情况,就是定义在类中的method被这个类中的其他函数调用了,可以直接以yyy()而不是xxx.yyy()的形式调用,和上面其实没什么区别。直接 call xxx.yyy() n+1即可,但是这要求我们在一个类内的时候,需要一个变量来存储当前类的名字xxx。
tokenizen的行进
tokenize的行进是一大问题。如果用advance行进的话,整个程序会变得臃肿丑陋。因此我设计了两个小函数,帮助tokenizen的行进。第一个函数,让我们先去掉for看,先返回nextToken,然后advance()。
一,永远预读
在compileclass开始前,我启动了第一个预读,确保在真正的compileclass开始前,token已经是'class',确保在compileclass结束后,token是'}'的下一个元素。
对于其他的函数也是如此
二,
只使用returntk_advance不使用advance,把returnTK看作处理了一个token,advance看作行进到下一个token。于是returntk_advance就是有节奏的处理,行进,处理行进。(我的注释后面表示处理过的元素)
另外一个好处就是,用token赋值的时候,不必再写xxx = self.token(),advance()
可以直接将两步结合起来 xxx =self.returntk_advance()。处理-行进产生的值可以直接赋给变量。而不必停下来处理,然后再行进。returntk_advance就是这样有节奏的处理行进,处理行进。而参数表示处理行进的次数。
语法处理的一个小问题
我们再处理语法的时候会遇到这样一个问题,有些元素有可能会出现,有可能不会出现,有些可能会出现一次,有些可能会出现多次。举个例子
参数表是一个典型的这样一个结构,第一它可能有参数也可能没有,第二
这个部分它可能出现多次,也可能不出现。用正则表达式表达就是图中这个样子,最外圈一个?,内圈一个*。
我是这样解决的。出现一次的,用if判断,出现多次的用while判断。表达式里不要用处理-行进结构来返回token值,这样会产生未知的读取下一个token的操作。
数组
对于数组有两种操作方法,这可能让人很疑惑。compilelet中对于等号左边的数组有一种,在compileterm中有另外一种。这其实很简单,let的左边是变量,而另外一种则是表达式。遇到变量需要获取变量的地址,遇到表达式则需要计算表达式的值。