汇编编程¶
系统学习 RISC-V 汇编¶
你可以参考以下教程学习 RISC-V 编程
An Introduction to Assembly Programming with RISC-V :较为完整的教程
RISC-V Assembly Tutorial :更简短的教程
RISC-V 手册中文书籍 :RISC-V 手册(中文版书籍)
在完成实验的过程中,你也可以通过以下两个文档快速查阅 RISC-V 指令集以及汇编语言的相关知识:
如果你希望全方位了解 RISC-V 这个 ISA,我们建议你阅读 RISC-V Specifications 中提供的文档。
其中 The RISC-V Instruction Set Manual 里面可以找到 RISC-V 的所有指令集信息,在编译器的实现中,你可能需要 反复查阅 这个文档来了解某个指令的具体用法。
提示
相比于 x86_64 和 ARM,RISC-V 的 Specification 可以说是非常简单了,如果你此前有阅读 ISA 文档(如 ARM 或 MIPS)的经验,那么你应该可以通过阅读 RISC-V Assembly Programmer's Manual 以及 Spec 快速上手 RISC-V 汇编编程。
小技巧
就编译课程的要求而言,学习汇编编程最快的方法就是搞明白从 C 编译到 LLVM IR 再到汇编代码翻译到过程,然后自己尝试编写简单的汇编代码。
在上一节我们提到了 Compiler Explorer 这个工具,你可以利用这个工具来查看 C 代码到汇编代码的转换过程。
RISC-V 汇编简易入门¶
备注
以下部分与 C++ 版本的实验文档基本一致,可能未针对 Rust 进行特别修改,如果发现问题请及时联系助教。
提示
此处介绍的 RISC-V 汇编的基础知识并不足以让你完成完整的编译器,我们建议你在需要时参考上面提到的教程进行深入学习。
你可以通过查阅 RISC-V 指令手册 来获取完整的 RISC-V 指令集的知识。
在本学期的所有实验中,我们只需要了解 RISC-V 的基础指令集,以及 M 扩展 (乘除法),F 和 D 扩展 (浮点数指令集) 即可,所以你只需要阅读手册的一部分即可。
备注
我们本学期实验中,RISC-V 的编译器框架使用的均为 64 位 RISC-V 指令集,而不是 32 位的 (不过 32 位和 64 位差别很小,你可以通过先学习 32 位再转 64 位的方式来学习)。
我们用下面 C 代码为例来介绍 RISC-V 汇编编程
#include<stdio.h>
int a = 0;
int b = 0;
int max(int a, int b) {
if(a >= b) {
return a;
} else {
return b;
}
}
int main() {
scanf("%d %d", &a, &b);
printf("max is: %d\n", max(a, b));
return 0;
}
使用 gcc -O3 编译后,我们可以得到以下汇编代码:
.file "test.c"
.option nopic
.attribute arch, "rv64i2p1_m2p0_a2p1_f2p2_d2p2_c2p0_zicsr2p0"
.attribute unaligned_access, 0
.attribute stack_align, 16
.text
.align 1
.globl max
.type max, @function
max:
mv a5,a0
bge a0,a1,.L2
mv a5,a1
.L2:
sext.w a0,a5
ret
.size max, .-max
.section .rodata.str1.8,"aMS",@progbits,1
.align 3
.LC0:
.string "%d %d"
.align 3
.LC1:
.string "max is: %d\n"
.section .text.startup,"ax",@progbits
.align 1
.globl main
.type main, @function
main:
addi sp,sp,-32
sd s0,16(sp)
sd s1,8(sp)
lui s0,%hi(a)
lui s1,%hi(b)
lui a0,%hi(.LC0)
addi a2,s1,%lo(b)
addi a1,s0,%lo(a)
addi a0,a0,%lo(.LC0)
sd ra,24(sp)
call __isoc99_scanf
lw a1,%lo(b)(s1)
lw a0,%lo(a)(s0)
call max
mv a1,a0
lui a0,%hi(.LC1)
addi a0,a0,%lo(.LC1)
call printf
ld ra,24(sp)
ld s0,16(sp)
ld s1,8(sp)
li a0,0
addi sp,sp,32
jr ra
.size main, .-main
.globl b
.globl a
.section .sbss,"aw",@nobits
.align 2
.type b, @object
.size b, 4
b:
.zero 4
.type a, @object
.size a, 4
a:
.zero 4
.ident "GCC: () 12.2.0"
.section .note.GNU-stack,"",@progbits
下面对一些关键代码进行解释:
.option nopic表示不使用位置无关的代码。此时,汇编器生成的代码将假定它会被加载到固定的内存地址,对于生成静态链接的二进制文件比较重要,有时可以提高代码的执行效率。.attribute arch, "rv64i2p1_m2p0"是用于指定汇编代码所遵循的架构特性和扩展的指令。rv64i2p1表示代码遵循 RISC-V 64 位基础指令集,版本为 2.1。m2p0表示支持乘法和除法指令,版本为 2.0。其余的指令大家可以通过搜索查找相关意义,此处不再赘述。
.attribute stack_align, 16表示栈空间需要 16 字节对齐,在我们 RISC-V 版本的编译实验作业中,同样需要遵循这一规定。.text表示接下来是代码区。接下来我们介绍 max 函数中出现的汇编指令:
首先我们需要了解一点,RISC-V 函数调用约定中参数的传递首先使用
a0-a7寄存器,如果寄存器不够用,则使用栈进行传递。所以在函数入口处,a0存放着变量a的值,a1存放着变量b的值。mv a5 a0的含义为a5 = a0,现在a5中也存放着变量a的值。bge a0,a1,.L2的含义为如果a0 ≥ a1,则跳转至.L2,对应源代码的if(a>=b)。mv a5 a1的含义为a5 = a1,这一条语句在a >= b为假的分支执行,表示将最大值a1``(存放着变量 ``b的值,此时有b > a)赋值给a5。sext.w a0,a5表示将 32 位的a5符号扩展到 64 位,值存放到a0中。ret表示函数返回,即源代码中的return。根据 RISC-V 函数调用约定,使用a0寄存器存放函数的返回值。max 函数最后的
.size和.section在我们的编译实验作业中并不需要生成,大家如果感兴趣可以自行查阅资料了解含义。.align 3表示之前有一段未对齐的代码或数据,汇编器会在这段内容前插入适当数量的填充字节,以确保接下来的数据或代码从一个 8 字节对齐的地址开始。这样做可以提高内存访问效率和程序的运行性能。
.LC0和.LC1这些标签定义了字符串常量。接下来介绍 main 函数中出现的汇编指令:
addi sp,sp,-32首先我们需要知道sp寄存器指向栈顶,该条指令的含义为开辟 32 字节的栈空间。sd s0,16(sp)和sd s1,8(sp)首先我们需要知道 RISC-V 的函数调用约定中,被调用者需要保存sp,s0-s11寄存器,该函数中使用到了s0和s1寄存器,所以我们在函数开头需要进行保存。sd s0,16(sp)表示将s0寄存器存储到地址为16+sp的内存中。lui s0,%hi(a)表示将a的标签(即全局变量a的地址)的高 20 位放到s0寄存器中,后两条指令同理。addu a2,s1,%lo(b)表示a2 = s1 + b的标签(即全局变量b的地址)的低 12 位,后两条指令同理,现在a2寄存器中存放着全局变量b的地址,同理,a1寄存器中存放着全局变量a的地址,a0寄存器中存放着字符串常量的地址。sd ra,24(sp)表示保存ra寄存器的值,原因是 RISC-V 函数调用约定需要调用者保存ra寄存器的值。call __isoc99_scanf表示scanf函数的调用,此时我们回忆一下刚才a0,a1,a2寄存器中变量的含义以及 RISC-V 函数调用约定,你大概就明白了此时call的含义。(call指令实际上是一条伪指令,具体做了什么就交给同学们自己探索了)。lw a1,%lo(b)(s1)含义为读取内存地址为s1 + %lo(b)的值(实际上,即为全局变量b的地址),并放到a1寄存器中,现在a1寄存器中为全局变量b的值。下一条指令同理,a0寄存器中为全局变量a的值。call max表示调用max函数,此时推荐再回忆一下a0,a1寄存器分别表示什么。mv a1,a0在函数调用完毕后,a0存放着函数返回值,此时我们将a0赋值给a1,即a1保存着max(a,b)的结果,也许你会问为什么这里要多此一举将a0赋值给a1,当你在下面看到printf函数的调用时你会明白一切(这也是 GCCO3优化效果的体现)。lui a0,%hi(.LC1)和addi a0,a0,%lo(.LC1)的含义为将.LC1中字符串常量的地址赋值给a0。call printf表示调用printf函数,不妨再回忆一下此时a0,a1寄存器的含义。ld ra 24(sp)表示恢复之前保存的ra寄存器。ld s0,16(sp)和ld s1,8(sp)表示将s0和s1寄存器恢复至调用前的状态。li a0,0表示将a0赋值为 0,表示main函数的返回值。addi sp,sp,32表示恢复栈空间。jr ra表示函数返回,指令含义为跳转到ra寄存器中的地址(ra寄存器会保存函数的返回地址,此时即使你不知道call这一条伪指令的含义,应该也能猜到call做了什么了)。
最后还有一些全局变量的定义,从文本上很容易理解其含义,这里就不赘述了。
上述的 RISC-V 汇编由 GCC
O3优化选项编译而成。如果你想了解编译器不做任何优化生成的汇编是什么样的,可以自己使用在环境配置环节安装的 RISC-V 交叉编译 GCC,并使用编译选项O0进行编译。(添加编译选项-S表示生成汇编代码)。
对编写的 RISC-V 汇编进行测试¶
假设你编写了一个 RISC-V 汇编文件,命名为 test.s,使用下面的命令即可进行测试。
riscv64-unknown-linux-gnu-gcc test.s -o test -static
# 如果你好奇为什么要加 -static,可以取消 -static 后使用 qemu 运行试试,
# 看看会报什么错误
# 你可以再根据错误信息去搜索引擎上搜索或者询问 ChatGPT 来了解原因。
qemu-riscv64 test