汇编编程¶
系统学习 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