第十一章 编译器:代码生成

本文详细介绍了一款将Jack语言翻译成VM语言的编译器的实现过程,包括方法论、工具选择、难点解析及核心代码片段。文章还探讨了递归下降解析器的设计原理,以及如何处理各种复杂的语法结构。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

项目介绍

本章的任务是将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的左边是变量,而另外一种则是表达式。遇到变量需要获取变量的地址,遇到表达式则需要计算表达式的值。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值