跳转至

RISC-V 64 汇编生成

指令选择

指令选择是将中间语言转换成汇编或机器代码的过程.

指令选择需要考虑的问题:

  • 目标平台的寄存器类型,例如 IR 的整数位宽和目标平台整数寄存器的位宽. 32 bit 的整数在 64 bit 平台上需要做 extension,64 bit 的整数在 32 bit 平台上可能需要使用 2 个寄存器来模拟,向量寄存器等需要考虑的问题更多. 总之,你需要合法化目标平台不支持的数据类型.
  • 目标平台的指令集操作码,例如 RISC-V 含有大部分 Accipit IR 中二元运算的操作码,但是并没有 offset 这个比较抽象的指针运算指令,你需要分解成一系列基本的二元运算. 总之,你需要合法化目标平台不支持的操作码.
  • 目标平台的指令集格式,例如在 X86 上可以直接使用 mov 指令完成一个 32 bit 立即数赋值;ARM 平台只有 12 bit 立即数,但是带有 4 bit 的 shift,允许编码一部分较大的立即数,否则要么使用类似 movw + movv 的组合移动指令,要么由编译器在合适的地方 (.text 或 .data) 插入立即数符号,并使用 ldr 访问这个立即数的地址;RISC-V 32 同样有这个问题,指令只支持 12 bit 立即数,较大的立即数根据不同的 code model 使用 lui + addi(高 20 bit 与低 12 bit)或者 auipc + addi. 不过,幸运的是,你总能通过 li 伪指令加载一个立即数,汇编器和链接器会帮你完成剩下的工作.
  • 寻址方式,例如 RISC-V 的 lw 只支持寄存器 + 12 bit 立即数偏移的形式.
  • 调用约定与 ABI 等,例如调用约定规定了哪些是 volatile 哪些是 non-volatile 寄存器,栈帧寄存器,程序计数器 (program counter) 等. 总之,你需要考虑目标平台的硬件特性和 ABI 等因素.
  • 指令选择完之后的 "IR" 是什么形式?由于指令选择一般在寄存器分配之前,所以指令选择完成的 "IR" 可以是目标平台的物理寄存器和IR 的虚拟寄存器并存的形式,你还可以构造一些额外的 “伪指令” 以方便你后续的操作. 此外在优化编译器中还需要根据指令集的延迟、吞吐等信息进行指令重排调度等目标平台相关的优化,这些操作要求这种 "IR" 和中端的 IR 一样能够对指令序列进行快速的变换.

中端目标无关 IR 的设计对指令选择的算法和实现使用的数据结构也有影响,例如在教材中,树形的 IR 就比较适合树上的模式匹配加动态规划的 "tree covering" 实现;对于 SSA 风格的 IR,由于 use-def 链等信息,可以考虑使用先将 IR 转换为 DAG,然后在 DAG 上做模式匹配的 "DAG covering" 实现. 在本次实验中,你只需要手工编码,实现最简单的 "macro expansion" 风格的指令选择即可.

寄存器分配

常见的寄存器分配算法,按照:

  • 不分配,把虚拟寄存器都溢出到栈上.
  • 局部寄存器分配 (local register allocation).
  • 图染色寄存器分配 (graph coloring, 1980, Chaitin),每个变量对应寄存器干涉图上的一个节点,在同一个基本块内同时活跃的 变量对应得到节点之间连边,表示不能被分配同一个寄存器,然后对图进行 k 染色.  对构造出的图进行k着色,k为空闲寄存器的个数
  • 线性扫描寄存器分配 (linear scan, TOPLAS 1999, Poletto),首先计算函数中变量的活跃区间,然后线性扫描所有 活跃区间,并按贪心将寄存器分配给变量. 优点是仅需线性复杂度,虽然活跃区间相比图染色寄存器分配算法的粒度更粗,但是实验表明其生成的代码质量通常仅略劣于图染色寄存器分配.
  • 弦图寄存器分配 (chordal graph, 2005, Pereira).
  • 静态单赋值形式分配 (SSA, 2006, Hack),基于 SSA 形式的中间代码的干涉图是弦图这个事实.
  • 基于约束的分配 (constraint-based, 1996 Goodwin; 2001 Appel; 2002 Scholz),例如整数线性规划.
  • 分区布尔二次编程寄存器分配 (PBQP)

最朴素的寄存器分配方法当然是把几乎所有临时变量都存储在内存中,也就是栈上. 每翻译一条中间代码之前我们把要用到的变量先加载到寄存器中,得到计算结果后又将结果写回内存. 这种方法的确能将中间代码翻译成可以正常运行的目标代码,而且实现和调试都特别容易. 不难发现,我们当前中端 IR 已经是这种形式了,源代码中的局部变量都是位于栈上的取地址变量(address taken variable),所有 IR 中的顶层变量,也即虚拟寄存器,都是计算的中间结果. 这门课的主要目的不在代码优化,而是首先保证功能正确性,因此我们只要求你实现朴素寄存器分配即可.

优化寄存器分配需要考虑的问题

想要写一个“正确”的寄存器分配相对容易,但是写一个足够好的寄存器分配比较难。

栈帧管理

在指令选择和寄存器分配之后,所有的寄存器都应该是物理寄存器了,但是你可能还得回过头考虑一下 ABI 和寄存器分配产生的 spill 对栈帧的影响,以 RISC-V 举例:

  • 函数调用,返回值是 a0,参数是 a0-a7,超出的参数需要存在栈上.
  • 函数的参数是 a0-a7,超出的参数需要从栈上读取.
  • 函数的调用者 (caller) 需要保存自己用到的 volatile 寄存器 (t0-t6),调用完成后需要恢复;函数的被调用者需要保护调用者的 non-volatile 寄存器 (s0-s11),函数进入时需要保存这些寄存器,函数结束前需要恢复这些寄存器.
  • 栈帧的维护,在 prolog 可能需要保护返回地址 ra 、栈指针 sp 和帧指针 fp,而在 epilog 需要恢复他们.
  • 我们在 Accipit IR 中临时变量采取了 alloca 的 trick,它们需要一个栈上的位置.
  • 寄存器分配后 spill 的寄存器,它们需要一个栈上的位置.

平心而论,上面这些问题贯穿了这整个后端,因此我们并不推荐你对于所有栈空间的寻址在指令选择阶段就直接硬编码成栈指针 sp 加上偏移量的形式,这是因为:

  • 估计寄存器分配的 spill 情况可能比较困难,不容易提前确定某个栈上的地址距离栈指针的 sp 的偏移量.
  • 朴素的实现每个函数的栈帧可能很大,以至于大到和 sp 之间的偏移量已经超出了 12 bit 立即数的限制,这个时候你可以考虑使用 fp 加上偏移量的形式.

注意:

上面提到的这些问题你可以“摆烂”,往最坏的情况估计——假设全部寄存器 spill,假设所有立即数都大到必须使用 li 先加载到寄存器等等,这当然是是可以的.

一种可能的解决的方法是,一个抽象的结构代替栈上的地址,例如给需要存在在栈上所有的“物体”编号,这样就可以使用“物体”编号加上单个“物体”内偏移量的二元组编码,等到指令选择结束后再统一把这个编码翻译成底层的 sp/fp 加上偏移量的方式

RISC-V 汇编

Venus 模拟器在伪指令等部分细节层面和 RISC-V 标准存在出入,我们要求你按照 GNU assembler 的语法输出汇编,详细可以参考这本手册 RISC-V Assembly Programmer's Manual

你可以使用 Compiler Explorer 选择 RISC-V 编译器来学习生成的汇编格式,我们为你提供了一个样例

运行

我们将会使用 clang 和 lld 交叉编译生成的 RISC-V 汇编并和我们提供的 runtime 链接得到一个可执行文件,并在 QEMU 上运行:

$ clang  -nostdlib -nostdinc -static -target riscv64-unknown-linux-elf -march=rv64im -mabi=lp64 -fuse-ld=lld <output_asm.S> -o <output_executable> -L<path_to_sysy_runtime_lib> -lsysy
$ qemu-riscv64-static <output_executable> < <test_input>