北航2021编译原理实验样例编译器-PCODE实现总结

Pcode编译器实验总结

前言

历时一个月实现了满足SysY文法,生成目标代码为Pcode的编译器。下面是笔者按照编译器实验流程:词法分析、语法分析、错误处理、中间代码生成和目标代码生成等五个阶段,总结了途中遇到的问题和坑点,希望能够对读者实现编译器有一定的启发和帮助。

词法分析

词法分析的任务是对文法进行了解和分析,并对出现的单词进行分析和记录,为后续的语法分析铺垫。整个词法分析思路很清晰:读入文件,逐个字符进行处理和判断并生成对应的token,为后面的分析提供获取下一个token的调用接口。

这部分内容比较简单,注意到一些细节即可,比如字符串常量中的转义字符以及词法分析的结束条件。

此外词法分析中涉及到了对于注释的处理。单行注释进行读取到下一个回车停止即可,多行注释一直读到多行注释的标识符即可。其中需要注意的是注释的处理逻辑和空白符的处理是一致的,也即在开始新的token读取之前,如果遇到了空白符或注释,需要将这部分略去,直到读到注释结束且不是空白符的字符再进行token的生成和判断。

语法分析

语法分析是对源程序进行递归下降分析,并对语法成分进行判断和输出。

这部分的实现思路也很简单,按照文法要求,根据词法分析程序得到的token对源程序进行分析和判断。遇到的比较困难的有以下两个部分:

  • 回溯问题。在一些分支较多的文法分析中,往往需要进行偷看下一个,或者更多个单词来进行判断需要使用哪个分支进行分析。在经过偷看一个或多个单词并确定分支之后,有两种做法,一种是将当前单词分析的过程回溯到偷看之前的状态并直接进行正确的分支的分析;一种是不进行回溯状态,在当前确定分支之后,直接进行继续分析。两种做法都可以,在有回溯的代码中,由于每次都会回溯到偷看之前的状态,可以直接进行正确的分支运行,并不会多读或漏读,结构更加清晰,但会面临着回溯导致的多余内存和时间的开销。在无回溯的实现中,由于在偷看之后直接进行选择某些分支进行,那么可能会存在某些语法结构的信息会不在当前分析的函数中,可能会增加后续的三个环节的困难。二者各有利弊,可以根据具体情况择优选择。
  • 将分析过程中出现的递归转换为循环。在进行一些类似{,<xxx>}的语法成分分析时,可以使用递归的方式分析也可以使用循环判断","来进行分析。循环的方式对于后续诸如符号表建立、中间代码的生成都会很方便。递归可能会导致在递归过程中信息在层与层之间进行复杂的传递和保存。当然如果在递归优于循环时,可以选择递归,但总体而言循环会更加方便和简单。

错误处理

错误处理的主要任务是对源程序中出现的语法错误进行处理并对错误进行处理和跳过。由于进行错误处理需要建立符号表结构,因此笔者认为错误处理语法分析和代码生成之间的过渡阶段,通过对错误的处理来帮助建立符号表的基础结构和逻辑,为代码生成做铺垫。

由于在实际的源程序中出现的语法错误千奇百怪,不胜枚举,所以在实现时无需考虑太多,只需完成文档中所给出的几种错误类型的处理即可。其他可能出现的错误忽略,默认不会出现除文档规定的错误之外的错误。

这部分的实现难点在于符号表的建立。符号表的本质是在语法分析的过程中记录变量、函数等信息,然后根据记录的信息进行错误的判断以及后续的代码生成。其中需要注意的有以下几点:

  • 栈式符号表。在SysY文法中是允许每一个block是一个作用域的。也就是说允许类似以下代码的定义:
int main() {
    int a;
    {
        int a;
        {
            int a;
            {
                int a;
            }
        }
    }
}

​ 其中内层的a作用会覆盖外层的a。所以需要考虑符号表的栈式维护,也即每出现一个新的作用域(fun,block)便创建一个新的符号表加入栈中,每结束一个作用域便将符号表栈的栈顶弹出。

  • 数组维度的定义。数组维度是一个个常量表达式,能够在编译期间计算出结果。在符号表建立时,数组维度需要和数组定义一起加入到符号表中,也即在表达式的递归分析中需要对常量表达式和非常量表达式进行区分,并对常量表达式进行计算,并将结果存储到符号表中,非常量表达式不进行计算。由于常量的计算和变量的计算在递归分析时会分析同一个语法成分,所以这部分的计算会涉及到中间代码的生成。笔者在处理时,提前完成了一部分的中间代码的任务,生成了+ - * / %等计算的中间代码,并根据四元式的结果来获取数组维度的定义。
  • 常量数组的初始化值。常量数组初始化值都是常量表达式,在编译时可以求出,需要加入到符号表中,并将初始化值与坐标相对应。在这里笔者并未按照简单的二维数组进行特判,来保存数组下标与值的对应,而是设计了一种通用的下标计算和分析方法,适用于多维数组和各种方式的数组初始化。主要实现思路是将数组元素在线性的空间存储的位置与数组下标之间的转换,根据分析过程中遇到的符号{}对数组的下标值或线性空间的位置进行调整。由于多维数组的维度最多为2,也可以选择特判进行处理。
  • 函数声明。函数声明涉及到了两个作用域的问题。函数声明时需要记录函数的参数的类型,如果是数组还需要记录数组的维度信息,而这些函数参数又是下一个作用域中的变量,也就是需要在函数的参数定义时,既要保存信息到当前符号表中的函数定义,也要保存信息到下一个符号表中的变量定义。

在符号表建立之后,对各种错误进行一一特判和处理即可。其中对于缺失符号的处理,需要考虑对后续的语法分析的影响,在报错之后,需要将缺失字符“补充”然后再进行接下来的分析。

中间代码生成

由于Pcode本身是一种中间代码,所以中间代码的生成是一个可有可无的过程。

笔者在经过思考和分析之后设计了四元式并生成了中间代码。一部分原因在以Pcode为目标代码的编译器中,中间代码的本质是将语法分析和符号表中的信息保存起来,将整个目标代码的生成与语法分析、词法分析分离开来。当我们拿到了中间代码时,我们可以根据中间代码直接生成目标代码而不再依赖于词法和语法分析。这样在生成目标代码时逻辑更加的清晰和简单:仅仅需要考虑如何生成目标代码。

但由于Pcode本身是一种中间代码,我们可以选择在语法分析的过程中生成Pcode代码,这样也可以,不仅减少了很多的工作量,不需要再考虑应该将什么信息抽象和保存到中间代码中,因为这些信息都在语法分析的过程中,我们可以直接使用,而且也确实更加符合Pcode本身的定义。

当选择生成中间代码时,需要注意以下几点:

  • 中间代码的信息一定要足够生成目标代码。中间代码是为了生成目标代码,所以在设计中间代码的四元式表达式时,要考虑到生成目标代码所需要的全部信息,比如函数定义、变量/常量初始化、各种跳转等,需要生成足够的中间代码才可以保证目标代码的生成。
  • 中间代码的生成需要考虑到目标代码的生成和执行。由于Pcode目标代码指令和解释器可以参考PL/0以及Pascal,需要在生成中间代码时考虑到各种指令生成和运行的过程,并据此来保存信息和设计四元式的操作符。
  • 短路逻辑。短路逻辑的设计的关键在于如何设计好跳转逻辑。短路逻辑的跳转是完全靠中间代码的四元式操作符来进行标记,所以设计一个比较合理的跳转逻辑即可。笔者在每生成一个land时进行判断是否为0,如果为0则进行跳转到下一个lor的开始,在生成每一个lor时进行判断是否为1,如果为1则跳转到cond结束,也即开始执行条件之后的内容。

目标代码生成

Pcode的目标代码指令是需要自行设计的,但我们设计时可以参考书上的PL/0和Pascal两种语言的指令和解释器,并在此基础上根据现有的SysY文法进行修改和补充。笔者在实现时参考了PL/0的指令以及解释器逻辑,并对其进行修改和补充。最终的指令集如下:

    // 指令的主要形式为 OP level address,部分指令不需要level(下面简称l)和address(下面简称r),主要运算指令参考PL/0,部分和PL/0重名的OP可能行为和使用不一致
    LIT l r // 同PL/0
    OPR l r // 同PL/0
    LOD l r // 同PL/0
    STO l r // 同PL/0
    CAL 0 0 // 默认l与r均为0,具体解释器运行逻辑参考下文。
    INT 0 r // 同PL/0
    JMP l r // r为目的跳转指令地址,l存在时(即不为"")代表目的指令地址和当前的指令的层次差,l不存在时,进行无条件跳转,不考虑层次差问题
    JTP 0 r // 栈顶值为真则跳转 到r 
    JPC 0 r // 栈顶值为假则跳转 到r
    LODS    // 将栈顶值作为地址,从栈中取出该地址对应的值。注:此地址为绝对地址
    STOS    // 将栈顶值作为地址,将次栈顶的值存储到该地址。注:此地址为绝对地址
    LA      // 取当前基地址寄存器的值放到数据栈顶
    PRT 0 r  // PRT 0 str 输出字符串。
    BLO     // block语句产生的作用域的开始
    BLE     // block语句产生的作用域的结束
    JPCAND 0 l // 代表短路逻辑中的&&跳转与jpc的区别为,此判断不会对数据栈的数据产生影响。
    OPR 0 0	过程调用结束后,返回调用点并退栈
    OPR 0 1	栈顶元素取反
    OPR 0 2	次栈顶与栈顶相加,退两个栈元素,结果值进栈
    OPR 0 3	次栈顶减去栈顶,退两个栈元素,结果值进栈
    OPR 0 4	次栈顶乘以栈顶,退两个栈元素,结果值进栈
    OPR 0 5	次栈顶除以栈顶,退两个栈元素,结果值进栈
    OPR 0 6	次栈顶对栈顶取摸,退两个栈元素,结果值进栈。
    OPR 0 7
    OPR 0 8	次栈顶与栈顶是否相等,退两个栈元素,结果值进栈
    OPR 0 9	次栈顶与栈顶是否不等,退两个栈元素,结果值进栈
    OPR 0 10	次栈顶是否小于栈顶,退两个栈元素,结果值进栈
    OPR 0 11	次栈顶是否大于等于栈顶,退两个栈元素,结果值进栈
    OPR 0 12	次栈顶是否大于栈顶,退两个栈元素,结果值进栈
    OPR 0 13	次栈顶是否小于等于栈顶,退两个栈元素,结果值进栈
    OPR 0 14	栈顶值输出至屏幕
    OPR 0 15	屏幕输出换行
    OPR 0 16	从命令行读入一个输入置于栈顶

在这个过程中遇到了诸多困难:

  • 函数调用。由于PL/0的文法定义中函数调用不传递参数,所以我们需要对此进行修改来满足SysY的有参数的函数调用。函数调用的本质是在栈空间中开辟新的作用域并设置作用域的SLDL以及RA,同时在跳转到目标函数之前需要完成函数参数的值传递(也即将传递的参数的值拷贝到新的作用域上,数组引用是地址值的传递),PL/0本身的CAL指令是在此指令之后直接跳转到目标指令地址,但我们由于需要参数传递,所以CAL指令其实不能够立即跳转,只能在CAL指令时设置SLDLRA需要等到函数调用的值传递结束才可以确定。而在此处还需要注意SL,由于在跳转之后执行函数时,SL本身一定为0,所以在跳转之前SL需要设置当前作用域的基础地址寄存器(下面简称B)的值,在跳转之后SL需要被手动赋值为0。在函数的值传递结束之后,直接执行无条件跳转指令即可。所以CAL指令在解释器的运行时,将新的空间中的DLSL设置为当前B,并将B更新为当前栈顶。
  • 作用域增加。在SysY文法中每一个block会声明一个新的作用域,但并不是函数跳转,所以并不能使用CAL指令,但其行为又很类似CAL指令,笔者索性将其抽出一个单独的指令来表示此处新声明了一个block作用域,除此之外没有别的用处,以及一个新的指令来标志block声明的作用域结束,也即上文中的BLOBLE 两条指令。BLO是增加一个新的作用域,新的SL为当前B,而DLRA均应该为当前函数返回时对应的DLRABLE 指令是作用域结束,只需更新B为当前作用域的SL 即可,并相应的更新栈顶为当前B。
  • 输出。由于PL/0中并没有输出字符串的操作,需要新增指令(PRT)进行字符串的输出。同时由于字符串中的\n需要进行转义的处理,所以需要对输出的字符串进行处理来将’‘‘n’ 转换为’\n’。
  • 数组的引用。由于在函数调用时允许数组的引用,所以在函数调用传参时,需要传递的数组的地址,但由于数组在传递过程中可能出现跨多个作用域进行取值,所以数组的引用传递需要传递数组在当前运行栈中的绝对地址,并在函数传参时,将此地址保存在被传递的函数参数中,在访问时此引用数组时,将此地址取出并作为基地址进行数组的取值。生成代码时,我们可以拿到数组的头地址在其所在作用域中的地址偏移,以及作用域的头地址的绝对地址,二者相加即可得到绝对地址。后者的取值是需要在运行时求得,当作用域和当前数组的层次差为0时,此时的B即为作用域的起始地址的绝对地址(即指令LA),当不为0时,我们可以通过LOD l r 指令在内存中获取。
  • 数组的取值。数组取值的过程是通过下标计算出数组元素相对于数组的起始地址的偏移量,然后加上数组的起始地址即可得到,而数组的起始地址需要通过绝对地址来求得(见上条)。由于数组取值时,地址是通过计算出来放到栈顶的,但由于PL/0的LOD指令只能取出地址已知的变量,所以需要额外增加指令来允许从栈顶获得目的变量的地址进而从栈中取出地址,也即LODS 指令,同理在向数组元素赋值时,也需要运行时求出地址进行赋值,PL/0的STO 不支持此操作,所以新增加了STOS指令。
  • 跳转指令的回填。由于PL/0并不允许进行label的定义和跳转,在生成跳转指令时,就需要在最后进行跳转指令地址的回填。笔者参考MIPS的做法,首先生成了带有label标签的跳转指令,并在目标代码生成的过程中将每一个label标签对应的地址进行记录,在全部的目标代码指令生成结束之后,将所有的label标签替换为跳转指令的目的地址。
  • 短路逻辑的设计。短路逻辑的本质是将&&|| 两个运算符中的表达式分开进行跳转即可。对于&& 当检测到一个表达式为0时,直接跳转到下一个|| 即可,对于|| 当检测到一个表达式为1时,直接跳转到cond 结束即可。为此新增加了JPCANDJTP 两条指令分别对应于&&|| 的跳转。所以只需要设计好跳转的目的指令地址即可。

最后

以上是在各个阶段出现的比较困难的问题,并给出笔者的部分思考和做法,希望读者看了本文之后有一些启发和收获,此外笔者相信一定会有比以上更加简单、优雅、高效的做法,等待读者的发现和探索。

更新

项目代码链接:
SysCompiler

  • 10
    点赞
  • 30
    收藏
    觉得还不错? 一键收藏
  • 11
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 11
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值