stage5

stage-5

郭高旭 ggx21@mails.tsinghua.edu.cn 2021010803

实验内容

支持多函数,支持函数的声明和定义(functionDef),支持函数调用(fuctionCall),本次实验基本上完整地走了一遍编译器的前端中端后端过程,真是收获满满呢😄😄

前端:

lex:

增加了comma,

parse:

​ 重新设计AST树结点

  • function:添加params: ParameterList属性

  • ParameterList:新增结点,实际上是declaration的列表

  • functionCall:函数调用结点

  • expressionList:函数参数结点

​ 按照规范对新增结点添加parser

namer:

  1. visitFunctionDef:这个一方面基本可以参考变量定义的实现(检查是否重定义),另一方面需要注意函数的参数列表和body实际上位于同一个Scope,在依次访问这两处时,要保证ScopeStack里只开了一个Scope,因此我修改了visitBlock的逻辑,在发现上一个Scope是函数参数列表时,不另开scope

  2. ParameterList,ExpressionList:依次visit其children即可

  3. visitFunctionCall:

    使用 ctx.lookupGlobalScope 来检查是否已经声明了一个同名的函数。如果没有声明,则引发 DecafUndefinedFuncError。设置 expr.visit 的参数列表的 ‘symbol’ 属性。

    此外还添加了对var shadow function的检查以及对参数不匹配的检查

中端

设计functionCall语句的表示方法,

简单而言就是新建一个temp作为functionCall的目的,遍历访问callee的ExpressionList求得函数调用的srcTemps

还有需要注意的是在Tacgen.transform,开始生成tac树时(每个函数开始生成tac时),需要采用真实的参数计数,以及首先访问函数的参数列表.

后端

这一部分是项目目前为之比较复杂的部分

  • utils/riscv.py:新增了Call指令和Addi指令的翻译

  • backend/riscv/riscvasmemitter.py

    • class RiscvInstrSelector(TACVisitor):

      这个类是将tac码翻译为介于tac和asm码之间的类,指令类似于asm但是操作数是tac的temp,在这里增加了对Call指令的支持,实际上就是将T0 = CALL foo(T1, T2)翻译成call foomove A0 to T0两句(e.g.)

    • class RiscvSubroutineEmitter(SubroutineEmitter):

      这个类是最终输出asm码的类,已经由bruteregalloc将上面类生成的中间代码中的temp转换成了reg.

      • 修改了压栈入栈逻辑,由于框架代码的栈分配比较暴力,而且暂时不需要考虑性能,所以我的实现方法是:将ra,fp存到栈底,将中间变量从栈顶开始向栈底分配(而不是原来的相反实现,这样不会影响ra

      • prologue:新增存ra,fp

      • body:主要是在遇到函数调用时:存储caller-saved寄存器,执行函数调用(函数传参部分在emit之前我放在下面bruteregalloc.py寄存器分配时处理),恢复caller-saved寄存器

      • epilogue:恢复ra,fp,calleesaved寄存器

  • backend/reg/bruteregalloc.py

    • localAlloc:根据数据流对一个 BasicBlock 内的指令进行寄存器分配,

      在这里对callee的参数列表里的temp进行reg的绑定(传参),具体而言就是按照调用约定,按照顺序从寄存器或者栈上取出参数,move到参数列表中声明的temp

    • allocForLoc:每一条指令进行寄存器分配

      在这里如果发现指令为Call,就和上面的localAlloc执行相反操作,把函数srcs中的temp绑定到reg然后再按照调用约定存入寄存器或者栈

思考题

Q1:

你更倾向采纳哪一种中间表示中的函数调用指令的设计(一整条函数调用 vs 传参和调用分离)?写一些你认为两种设计方案各自的优劣之处。

A1:

我实际上两种中间表示都尝试了,最终还是选择了一整条函数调用。这主要是一些操作上的问题,我在尝试传参调用分离的时候感觉没有必要,反而会增加传参时寄存器与栈分配的代码逻辑复杂度。

  1. 一整条函数调用:

    优势

    • 更贴近源语言,便于生成

    • 代码更加简洁直观

    • 紧凑性:一整条指令可以将参数传递和函数调用合并在一起,减少了指令的数量,节省了内存和提高了缓存性能。

    • 有利于集中计算分配函数调用中函数参数的分配

    • 节省指令解码时间:一整条指令可以在解码阶段一次性解析,而不需要分别解析传参和调用指令。

    劣势

    1. 限制性:一整条指令可能会限制编译器的优化机会,因为它需要在单条指令中包含参数传递和调用信息,这可能使得某些优化更加复杂。

    2. 可维护性:一整条指令的可读性和可维护性可能较低,因为它需要一次性表达多个概念,不容易理解和调试。

  2. 传参和调用分离

    优势

    • 更贴目标语言(riscv),便于生成目标语言

    • 灵活性:将参数传递和函数调用分离可以允许编译器更自由地进行优化。它可以根据具体情况选择不同的策略来传递参数,以获得更好的性能,适应不同的后端

    • 可读性:分离的设计可以提高代码的可读性和可维护性,因为每个步骤都以清晰的方式表达,易于理解和调试。

    劣势

    • 指令数量增加:分离参数传递和函数调用通常需要更多的指令,因此可能会导致更多的代码大小和执行时间开销。

    • 增加传参时寄存器与栈分配的代码逻辑复杂度

Q2:

为何 RISC-V 标准调用约定中要引入 callee-saved 和 caller-saved 两类寄存器,而不是要求所有寄存器完全由 caller/callee 中的一方保存?为何保存返回地址的 ra 寄存器是 caller-saved 寄存器?

A2:

  1. 这保证了寄存器分配的灵活性,主要是尽可能减少寄存器压栈出栈的次数。caller-saved和calllee-saved实际上规定了caller和callee对寄存器保护的责任。这样每一subroutine在自己的作用域里总是有一些寄存器可以随意更改,也有另一些寄存器负责保护。

    而全部caller/callee-saved,尽管实际上确实可以work但是

    • 如果全部callersaved,考虑到一个caller可能有大量callee,那么调用callee前后会大量入栈出栈

    • 如果全部calleesaved,参见下一问的回答多个callee都需要存储恢复caller本应保持的寄存器,也会造成浪费。

  2. 一个caller可能有多个callee,反之则不可能。那么多个callee储存/恢复caller的ra,会造成浪费。




本文总阅读量