由于 markdown 对汇编的支持不好,本文代码块部分借用 python 的高亮规则

简介

RISC-V 是一个开源的精简指令集架构(ISA),也是 ZJU 信息安全专业计算机系统课程(2023级)指定使用的 ISA

顺序,分支和循环结构函数

汇编语言和高级语言的很大一个不同就是:在编写汇编程序的时候和硬件层面的关联是很紧密的,所写的每一条指令实际上是对应着 CPU 的某个特定操作(虽然实验课是用 SPIKE 模拟的),写的时候你可能会有真的在操作硬件的感觉

下面通过实例程序给出一些常用的 RISC-V 指令

学习一门语言最好的办法是写代码而不是读文档

由于 C 语言是众多高级语言中较为接近汇编的一种,因此对于习惯了高级语言的学习者来说,先试着从 C 到 RISC-V 是一种不错的学习模式

long long acc(long long a, long long b){
long long i, res;
res = 0;
for(i = a; i <= b; ++i){
res += i;
}
return res
}

在运行在 64 位系统的 C 中,long long 的长度是 8Byte,正好与 64 位系统中地址的长度相同(64bit),免去了一些麻烦

这是一个很简单的程序,作用是求区间 [a, b] 上的整数和,通过命令将其编译成汇编文件(去除了与本文关系不大的代码)

	.globl	acc
.type acc, @function
acc:
addi sp,sp,-48
sd s0,40(sp)
addi s0,sp,48
sd a0,-40(s0)
sd a1,-48(s0)
sd zero,-32(s0)
ld a5,-40(s0)
sd a5,-24(s0)
j .L2
.L3:
ld a4,-32(s0)
ld a5,-24(s0)
add a5,a4,a5
sd a5,-32(s0)
ld a5,-24(s0)
addi a5,a5,1
sd a5,-24(s0)
.L2:
ld a4,-24(s0)
ld a5,-48(s0)
ble a4,a5,.L3
ld a5,-32(s0)
mv a0,a5
ld s0,40(sp)
addi sp,sp,48
jr ra

调用与被调用

acc 是一个函数,在程序中,函数一定有一个调用者 caller,同时自身就是被调用者 callee

sp 寄存器储存的是栈指针,指令addi sp, sp, -48为这个函数分配了 48Byte 大小的栈帧

栈内存是一种储存结构,从高位储存到低位,因此申请栈内存我们会用“减法”
一次函数调用中,指令和数据按照一定结构组织在一起,这样的结构就被称为栈帧

s0 寄存器储存的是帧指针,作用是进行栈回溯,因此我们有以下操作储存-加载帧指针

sd s0, 40(sp)
# ...
ld s0, 40(sp)

除了一些 CPU 关心的栈,还有我们关心的函数参数(例如这里的 a 和 b),RISC-V 提供了 a0-a7 总计 8 个寄存器储存函数的参数(其中 a0-a1 也被用作储存函数的返回值)

当然,假如函数有超过 8 个参数,这时就不可避免的要使用内存了

接受到到参数需要储存在栈内存中,这是因为寄存器是公用的(递归函数也可以操作寄存器)

sd a0, -40(s0)
sd a1, -48(s0)

分支与循环

程序三大结构分别是顺序,分支和循环

在 RISC-V 中,我们通过比较指令和标签组合实现分支结构

比较指令就是常说的 B 型指令

ble a0, a1, .L1
  • beq 相等
  • bne 不相等
  • blt 小于
  • bgt 大于
  • ble 小于等于
  • bge 大于等于

标签就是对特定语句的标记,用点开头

.L1:
# ...

因此,这样的指令对应了这样的 C 代码

if(a0 == a1)    goto L1;
// ...
L1: // ...

通过向前回溯,我们就可以实现 for - while - goto - riscv 之间的改写

int n = 10;
for(int i = 0; i < n; ++i){
// code
}
/**********/
int i, n;
i = 0;
n = 10;
while(i < n){
// code
++i;
}
/**********/
int i, n;
i = 0;
n = 10;
L1: {
// code
++i;
}
if(i < n) goto L1;
fn:
# ...
ld a0, -32(s0) # load n into a0
li a1, 0 # use a1 to store i
.L1:
# code
addi a1, a1, 1
blt a1, a0, .L1

有意思的是,我们在高级语言中排斥的 goto,成为了汇编中不可替代的一部分,这也增加了汇编语言的难维护性

跳转

跳转指令分为以下几种

  • j Lable 无条件跳转到 Lable 处
  • jr rs1 跳转到寄存器 rs1 指令处

    ra 储存的是返回地址
    对于 CPU 来说,无论什么函数,本质上就是指令的集合,既然是指令,就是储存在内存中的某个地址,而 ra 就储存着这个地址

递归函数

下面通过阶乘计算函数介绍递归

long long factor(long long n){
if(n == 0){
return 1;
}
else{
return n * factor(n - 1);
}
}

编译结果如下(同样保留了较为核心的代码)

factor:
addi sp,sp,-32
sd ra,24(sp)
sd s0,16(sp)
addi s0,sp,32
sd a0,-24(s0)
ld a5,-24(s0)
bne a5,zero,.L2
li a5,1
j .L3
.L2:
ld a5,-24(s0)
addi a5,a5,-1
mv a0,a5
call factor
mv a4,a0
ld a5,-24(s0)
mul a5,a4,a5
.L3:
mv a0,a5
ld ra,24(sp)
ld s0,16(sp)
addi sp,sp,32
jr ra

不过我认为编译器生成的代码并没有那么好理解,于是我自己写了一个程序

factor:
addi sp, sp, -32
sd ra, 24(sp)
sd s0, 16(sp)
addi s0, sp, 32
sd a0, -24(s0) # a0 is callee’s parameter n
mv a5, a0 # mv a0 to m5
bne a5, zero, .L1 # if n != 0, then go to .L1
j .L2 # else then go to .L2
.L1: # n != 0, recursively call self
addi a5, a5, -1 # n - 1
mv a0, a5 # IMPORTANT
# the instruction puts parameter n - 1 in a0,
# so that when calling factor recursively,
# it can get its parameter from its caller
call factor
mv a4, a0 # after factor called,
# a0 stores the return value
ld a5, -24(s0)
mul a5, a5, a4
j .L3
.L2: # recursive boundary
li a5, 1
j .L3
.L3: # do return operations
mv a0, a5 # use a0 to exchange return value
ld ra, 24(sp)
ld s0, 16(sp)
addi sp, sp, 32
jr ra

递归函数体现了一个很巧妙的思路:由于栈帧内的数据是独属于自己的,因此一旦涉及到 caller 与 callee 之间的参数传递,就要考虑使用寄存器(或内存)传递参数

在这个例子中,用于传递的寄存器主要有:

  • sp:每次调用 factor,sp 会向下移动 32 字节,调用结束后回上 32 字节,这样的操作使得每次递归操作可以获得一份独立的栈帧(在这里主要是保存自己的 n 以及返回地址 ra)
  • a0:用于传递 n 和 n - 1,每次递归调用时,我们将递归转移函数计算出的 n - 1 移动到 a0 中,恰好 a0 本就是用于保存参数的寄存器,所以顺理成章的完成了递归调用
  • a4:用于传递返回值

实战

冒泡排序

.text
.globl bubble_sort

bubble_sort:
addi sp, sp, -128
sd s0, 120(sp) # s0 store at 8(sp)
addi s0, sp, 128 # s0 mean stack top
sd a0, -112(s0) # arr
sd a1, -102(s0) # len
sd zero, -96(s0) # i
sd zero, -88(s0) # j
addi a1, a1, -2 # change a1 to store len - 2
sd a1, -80(s0) # len - 2
sd a1, -72(s0) # len - 2 - i
j .L2 # begin from loop i
.L5: # the loop body
ld a0, -88(s0) # load j to a0
ld a1, -112(s0) # load arr to a1
addi a6, zero, 8
mul a0, a0, a6 # cal shift value
add a1, a1, a0
ld a3, 0(a1) # load value of arr[j] to a3
ld a4, 8(a1) # load value of arr[j + 1] to a4
# sd zero, 0(a1) ##
ble a3, a4, .L4 # if arr[j] <= arr[j + 1], then skip the swap operations
sd a4, 0(a1)
sd a3, 8(a1) # do swap
.L4:
ld a0, -88(s0) # load j to a0
addi a0, a0, 1 # add j with 1
sd a0, -88(s0) # store j to j + 1
.L3: # compare of loop j
ld a1, -80(s0) # load len - 2 to a1
ld a0, -96(s0) # load i to a0
addi a6, zero, -1
mul a0, a0, a6 # i times -1 to -i store in a0
add a1, a1, a0 # cal a1 = len - 2 - i
ld a2, -88(s0) # load j to a2
ble a2, a1, .L5 # if j <= len - 2 - i, them begin loop i
ld a0, -96(s0) # load i to a0
addi a0, a0, 1 # add i with 1
sd a0, -96(s0) # store i to i + 1
.L2: # compare of loop i
ld a0, -96(s0) # load i to a0
ld a1, -80(s0) # load len - 2 to a1
sd zero, -88(s0) # initial j with 0
ble a0, a1, .L3 # if i <= len - 2, then begin loop j
ld s0, 120(sp) # return s0

没啥好说的,如果看不懂可以试着依次改写出下面的 C 代码

// standard C
void bubble_sort(long long* arr, long long len){
long long i, j, tmp;
i = 0;
while(i < len - 1){
j = 0;
while(j < len - 1 - i){
if(arr[j] > arr[j + 1]){
tmp = a[j];
a[j] = a[j + 1];
a[j + 1] = tmp;
}
++j;
}
++i;
}
}
/**********/
// C with goto
void bubble_sort(long long* arr, long long len){
long long i, j, tmp;
i = 0;
L1: {
j = 0;
L2: {
if(arr[j] > arr[j + 1]) goto L2_1;
else goto L2_2;
L2_1: {
tmp = a[j];
a[j] = a[j + 1];
a[j + 1] = tmp;
}
L2_2: {
;
}
++j;
}
if(j < len - i -1) goto L2;
++i;
}
if(i < len - 1) goto L1;
}

递归计算斐波那契

.text
.globl fibonacci

fibonacci:
addi sp, sp, -64
sd ra, 56(sp)
sd s0, 48(sp)
addi s0, sp, 64
sd a0, -56(s0) # stone n(recuisive variable)
li a1, 1 # a1 = 1
ble a5, a1, .L2 # if n <= 2, then return 1
j .L1 # if n > 2, then do add f(n - 1) + f(n - 2)

.L1:
ld a5, -56(s0) # load n to a5
addi a5, a5, -1 # n - 1
sd a5, -48(s0) # store n - 1
mv a0, a5 # trans the parameter
call fibonacci
mv a3, a0 # store f(n - 1)
sd a3, -36(s0) # store f(n - 1)
ld a5, -56(s0) # load n
addi a5, a5, -2 # n - 2
mv a0, a5 # trans the parameter
call fibonacci
mv a4, a0 # store f(n - 2)
ld a3, -36(s0) # load f(n - 1)
add a5, a3, a4 # do add
j .L3

.L2:
li a5, 1
j .L3

.L3: # return value operations
mv a0, a5
ld ra, 56(sp)
ld s0, 48(sp)
addi sp, sp, 64 # move stack top to next 64
jr ra

需要注意的是我们要把第一个返回值保存在栈帧里面, 防止在计算第二个返回值的时候修改了寄存器导致先前的计算中间值被修改