实验4RISC-V目标代码生成
本次实验的目标是从实验3生成的中间表示出发,生成RISC-V32的汇编代码,并在venus模拟器上运行。如果你对RISC-V汇编不那么熟悉,可以参考Crash Course。
目标代码生成
回顾一下,我们已经可以将源代码转换为一种自定义的中间代码表示,它可以在一个自定义虚拟机器上解释执行。和实际机器不同,虚拟机允许你使用无限寄存器,并给你提供了一些方便的关于函数调用的指令。而面对一个实际的机器,如在本实验使用的RISC-V32指令集下,这些假设都不再成立。为了生成最终目标代码,有以下三个问题需要你解决:
指令选择
首先我们注意到中间代码和汇编代码格式是不一样的,我们要对每一种中间代码找到一个特定的模式,翻译成对应的目标代码,这个过程就是指令选择。考虑到在本实验中实现的中间表示是线性的,最直接的方式就是将每一条中间代码翻译成一条或多条目标代码。下表是部分中间代码与RISC-V32指令对应的一个示例,大部分的逻辑都是显然的:
例如,对于下面的代码:
优化的可能
这样直截了当的对应的确能够满足我们的需求,但我们可能会生成很多冗余的指令,会影响程序的运行效率。例如,我们可能会生成下面这样的代码:
addi a0 t1 8
1w a0 0(a0)
但这条指令可以被下面这条指令替代:
lw a0 8(t1)
这个例子启示我们,直接翻译得到的代码很有可能是低效的,我们需要一些方法来优化生成的代码。比如你可以采用滑动窗口的方法,来检查是否有可以合并的指令序列。例如上述的寻址模拟即可简化代码。
除此之外,你可能已经发现了,我们在目标代码中对于某一个中间代码中的变量。使用的是 reg(a),意为储存该变量的寄存器。那么我们怎么知道用哪个寄存器储存变量呢?这也就引出了下一个问题:寄存器分配。
寄存器分配
对于中间代码中的全局变量,我们可以直接映射到目标代码的上,这是比较简单的。但是除此之外,我们还使用了数目不受限的变量和临时变量,但处理器所拥有的寄存器数量是有限的。因此我们需要将中间代码中的变量映射到寄存器上,这个过程就是寄存器分配。
朴素的寄存器分配
最朴素的寄存器分配方法当然是把几乎所有临时变量都存储在内存中,也就是栈上。每翻译一条中间代码之前我们把要用到的变量先加载到寄存器中,得到计算结果后又将结果写回内存。这种方法的确能将中间代码翻译成可以正常运行的目标代码,而且实现和调试都特别容易。
基于图染色的寄存器分配
朴素的寄存器分配方法虽然简单,但是生成的代码效率很低。我们可以考虑使用一些更加高级的寄存器分配算法来优化生成的代码。同学们已经在课程中学习过了基于图染色的寄存器分配方法,在对程序进行活跃变量分析之后,我们可以根据活跃变量的信息构造一个冲突图,然后使用图染色算法来进行寄存器分配。这里不再赘述。
栈的管理
完成了指令选择和寄存器分配,代码生成还剩最后一个问题需要解决。同学们肯定注意到了中间代码中的函数调用返回和参数的传递与使用被抽象为CALL, RETURN, ARG, PARAM等特殊的指令。这(或许)让同学们在中间代码生成的时候少了不少麻烦,但在目标代码中,我们必须使用寄存器和栈来完成这一点。所以我们最后要解决的就是有关函数调用的细节,包括函数调用时参数的传递,寄存器的保存,函数调用时栈的管理,函数返回时寄存器的恢复等。
那么我们具体该怎么做呢?对于ARG 和 PARAM这两个指令,我们可以简单的根据RISCV的调用规范,将实参传递到a0-a7这几个寄存器中,读取参数的时候再从这几个寄存器中取出即可,注意这可能会影响到寄存器分配的结果。当函数的参数个数超过调用规范中规定的个数时,我们则需要将多余的参数保存到栈上,对于CALL和RETURN这两个指令,我们则需要正确的管理整个活动记录(activation record)。同学们在课堂上也已经学过了相关的内容。而在本实验中,我们其实只需要最简单的管理栈指针方式,即在函数调用时将栈指针减去相应栈帧的大小,函数返回时再将栈指针恢复即可。栈帧的大小则来自于函数的局部变量大小加上函数调用时要保存到栈上的值的大小(如ra),具体取决于你的栈帧设计。具体细节和实现方式留给同学们自己探索。
Venus模拟器
我们的实验评测会在 venus,一个 RISCV 模拟器上进行。 Venus 支持 RV32M 指令集,即在基础的 RV321指令集加上乘法,除法和取模指令。对于中间代码中的这些运算,你不需要自己实现。Venus 支持一些基础的环境调用,我们可以方便进行输入输出交互。如果我们想要调用一个环境调用,将ID 加载到寄存器 a0 中,将所需要的参数加载到 a1-a7 中,然后执行 eca11 指令即可。返回值都同样存储在这些寄存器中。Venus 支持下列环境调用:
举例,下面代码将整数42打印到终端上
注意事项
1.对于 main 函数,你不应该使用 RET,而应该使用我们上面介绍的环境调用来退出程序,2.同样,对于 read, write 这两个内置函数,你不应该使用 CALL,而应该使用我们上面介绍的环境调用。
3.对于全局变量,你需要在 .data 段中定义它们。而你的代码应该从 .text 段开始。
4.模拟器在读入数据段后会从代码段的第一条指令开始执行,所以你要确保代码段的第一条指令是 main 函数的入口。
5.sbrk 这一调用和中间代码中的 DEC 并没有什么关系。本实验的所有局部变量都是可以在栈上分配的,所以你其实不需要调用 sbrk.
你可以使用在线的 venus 来调试你的程序。在 VSCode 中你也可以安装相应插件来方便地调试你的程序。但是请注意,一切测试结果都以我们提供的jar 包为准。上述调试工具并不包含read_int 的环境调用。
你的任务
你的任务就是在实验三中间代码的基础上,解决我们上面提到的三个问题,生成能够在 venus 模拟器上运行的目标代码。