stage-5
郭高旭 ggx21@mails.tsinghua.edu.cn 2021010803
实验内容
支持多函数,支持函数的声明和定义(functionDef),支持函数调用(fuctionCall),本次实验基本上完整地走了一遍编译器的前端中端后端过程,真是收获满满呢😄😄
前端:
lex:
增加了comma,
parse:
重新设计AST树结点
-
function:添加
params: ParameterList
属性 -
ParameterList:新增结点,实际上是declaration的列表
-
functionCall:函数调用结点
-
expressionList:函数参数结点
按照规范对新增结点添加parser
namer:
-
visitFunctionDef:这个一方面基本可以参考变量定义的实现(检查是否重定义),另一方面需要注意函数的参数列表和body实际上位于同一个Scope,在依次访问这两处时,要保证ScopeStack里只开了一个Scope,因此我修改了visitBlock的逻辑,在发现上一个Scope是函数参数列表时,不另开scope
-
ParameterList,ExpressionList:依次visit其children即可
-
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 foo
与move 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:
我实际上两种中间表示都尝试了,最终还是选择了一整条函数调用。这主要是一些操作上的问题,我在尝试传参调用分离的时候感觉没有必要,反而会增加传参时寄存器与栈分配的代码逻辑复杂度。
-
一整条函数调用:
优势
-
更贴近源语言,便于生成
-
代码更加简洁直观
-
紧凑性:一整条指令可以将参数传递和函数调用合并在一起,减少了指令的数量,节省了内存和提高了缓存性能。
-
有利于集中计算分配函数调用中函数参数的分配
-
节省指令解码时间:一整条指令可以在解码阶段一次性解析,而不需要分别解析传参和调用指令。
劣势
-
限制性:一整条指令可能会限制编译器的优化机会,因为它需要在单条指令中包含参数传递和调用信息,这可能使得某些优化更加复杂。
-
可维护性:一整条指令的可读性和可维护性可能较低,因为它需要一次性表达多个概念,不容易理解和调试。
-
-
传参和调用分离
优势
-
更贴目标语言(riscv),便于生成目标语言
-
灵活性:将参数传递和函数调用分离可以允许编译器更自由地进行优化。它可以根据具体情况选择不同的策略来传递参数,以获得更好的性能,适应不同的后端
-
可读性:分离的设计可以提高代码的可读性和可维护性,因为每个步骤都以清晰的方式表达,易于理解和调试。
劣势
-
指令数量增加:分离参数传递和函数调用通常需要更多的指令,因此可能会导致更多的代码大小和执行时间开销。
-
增加传参时寄存器与栈分配的代码逻辑复杂度
-
Q2:
为何 RISC-V 标准调用约定中要引入 callee-saved 和 caller-saved 两类寄存器,而不是要求所有寄存器完全由 caller/callee 中的一方保存?为何保存返回地址的 ra 寄存器是 caller-saved 寄存器?
A2:
-
这保证了寄存器分配的灵活性,主要是尽可能减少寄存器压栈出栈的次数。caller-saved和calllee-saved实际上规定了caller和callee对寄存器保护的责任。这样每一subroutine在自己的作用域里总是有一些寄存器可以随意更改,也有另一些寄存器负责保护。
而全部caller/callee-saved,尽管实际上确实可以work但是
-
如果全部callersaved,考虑到一个caller可能有大量callee,那么调用callee前后会大量入栈出栈
-
如果全部calleesaved,参见下一问的回答多个callee都需要存储恢复caller本应保持的寄存器,也会造成浪费。
-
-
一个caller可能有多个callee,反之则不可能。那么多个callee储存/恢复caller的ra,会造成浪费。