制作以RISC-V为目标代码的编译器
Ianus Inferus
2022-01-25
前言
最近学习RISC-V和RISC-V的ABI,决定做一个编译器来验证理解是否正确。
语言就选择现成的Niveum Expression。这个语言是一个微型纯数值表达式计算语言,主要分成函数声明和函数体两部分。
函数声明示例
GetUpgradeExperience(Level:Int, Initial:Int):Int
函数体示例
ceil(Initial * pow(1.1, Level - 1))
编译目标,为了简便起见,编译成汇编源代码,而非.o文件(ELF格式)。架构使用RV64G,ABI使用LP64D。
编译器本身的语言还是沿用Niveum项目中使用的C#语言。前端是现成的,已有做过结点类型分析的抽象语法树,只需要做后端代码生成。
目的是生成可以运行的程序,验证对于架构和ABI的理解,所以不需要做过多的优化。
寄存器分配
寄存器分配是以实机为目标时无法绕过的问题,所幸的是,RV64G中的寄存器数量极多,有32个整数寄存器和32个浮点寄存器,所以我们只需要选择极为简单的FIFO队列策略即可应对,并不会对乱序执行造成什么影响。当出现极端情况寄存器不够使用时,我们只需要简单地将最早被分配的寄存器保存到栈上即可。
变量上下文
由于我们需要经常将数据在寄存器和栈之间传输(比如说调用函数的时候需要保存一些寄存器),使用一个数据结构来保存数据当前的位置是很重要的,特别是当我们需要模拟它进行传输时在代码其他地方的引用也会自动更新,这使得这样一个抽象的概念使用起来相当方便。
立即数
RISC-V中加载整数立即数通常需要LUI和ADDI指令组合才能完成,但是我们可以使用LI伪指令来让汇编器自动完成需要的操作。
RISC-V中没有浮点立即数,但是我们可以将数据存到一个Int64中,然后使用FMV指令转换成Float64,间接实现加载浮点立即数。另外一种做法是将数据保存到全局数据段,再通过符号引用,数据量多时可以节约空间,但是实现比较复杂,就没有采用。
函数调用和跳转
只需要使用汇编标签,就能让汇编器自动为我们计算函数调用和跳转位置的指令和重定向记录。使用.global定义符号,还能让汇编器自动生成导入导出函数需要的符号表。
一些没有直接对应的运算
比较运算的指令较少,缺少的指令可以通过交换运算数以及使用XORI取反来实现。
整数相等判断可以使用XOR和SLTIU来实现。
浮点寄存器赋值可以通过FSGNJ指令实现。
这些操作也有对应的伪指令可以使用。
栈帧大小的计算
由于用到的需要调用方保存的寄存器列表需要在生成代码的过程中计算,需要使用延迟生成或者多遍生成,以实现在完成栈帧大小的计算之后再生成代码。
成果
前面提到的GetUpgradeExperience函数的编译结果如下
ExprTest_Character_GetUpgradeExperience:
#prolog
addi sp, sp, -48
sd ra, 40(sp)
sd fp, 32(sp)
addi fp, sp, 48
#body
fcvt.d.w ft0, a1
li t0, 4607632778762754458
fmv.d.x ft1, t0
addi t1, zero, 1
subw t2, a0, t1
fcvt.d.w ft2, t2
fsd ft0, -24(fp)
fsd ft1, -32(fp)
fsd ft2, -40(fp)
fld fa1, -40(fp)
fld fa0, -32(fp)
call Niveum_Expression_pow_RR
fld ft3, -24(fp)
fmul.d ft3, ft3, fa0
fsd ft3, -48(fp)
fld fa0, -48(fp)
call Niveum_Expression_ceil_R
#epilog
ld fp, 32(sp)
ld ra, 40(sp)
addi sp, sp, 48
ret