RISC-V 入门 Part2: ABI && Calling Convention

RISC-V 入门 Part2: ABI && Calling Convention

长度单位:

  • b byte
  • h halfword
  • w word

i 的尾坠代表 imm, 立即数, 比如 addi, andi

jump 基于jalr ,这个 r 是 register。


u类指令格式:

  • lui: load upper immediate, 用于构造一个 32-bit constants
  • auipc: add upper immediate to PC. PC + 偏移量写入 register

我们上一个 Part 介绍了 bne 等 conditional branch,此外还有难理解一些的 unconditional branch. 例如伪指令 j, 它基于 jal, 即 jump and link 实现:

1
2
jal rd offset
jalr rd rs (offset)

jal 用来实现函数调用的语义:它 PC 跳转 offset (长度为 20bits),或者对应的寄存器,然后把 PC + 4 (RV32I 指令长度是 32bit, 即 4Byte,表示下一条指令)写到 rd 寄存器。

jal 用来实现函数调用和循环中的 unconditional jump.

C to RISC-V

DC87168C-5240-43EA-9A36-40D0E23563F3

上面这张图 cmu 15-445 有更好玩的版本。

8D576B0E-28AC-450C-9752-9088942AB8C4

这里面写了调用一个函数的6步,即函数调用规范(Calling convention) .

  • 把函数参数放到函数能访问的地方
  • 把控制权给函数(使用 jal 指令)
  • 拿到 memory 中的资源 (获取函数需要的局部存储资源,按需保存寄存器)
  • 运行函数中的指令
  • 把值写到 memory/register 中 (将返回值存储到调用者能够访问到的位置,恢复寄存器,释放局部存储资源)
  • 返回 ( ret 指令)

寄存器有 caller-savedcallee-saved 两种:

  • caller-saved: callee 可以随便搞,从这里读数据,然后操作它们
  • callee-saved: callee 在返回前应该保存的

ABI:调用其它函数时,关于汇编、参数、寄存器等的双方约定。(我觉得有几个答案补充的很好). 实际上 ABI 兼容性是一个和编译器等都有关的话题。ABI 定义了 calling convention,同时 ABI 定义了约束:有些寄存器是不可写的。

同时,你可以在上面的图里面看到很奇妙的事情,这里没有再使用 x0 - x15 这样的记号,而是用了 s0 fp 这样相对来说名字好理解一些的。


1
2
3
4
5
int Leaf(int g, int h, int i, int j) {
int f;
f = (g + h) - (i + j);
return f;
}

riscv64-unknown-elf-gcc 编译

可以看到,为了存放旧的值,需要 stack.sp 寄存器和 stack 有关,同时有 push/pop. 鉴于 stack 是自顶向下生长的,push 会减小 sp, pop 会增大 sp

1
✗ riscv64-unknown-elf-gcc -march=rv32imac -mabi=ilp32 -S leaf.c 

编译一下 leaf:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
	.file	"leaf.c"
.option nopic
.attribute arch, "rv32i2p0_m2p0_a2p0_c2p0"
.attribute unaligned_access, 0
.attribute stack_align, 16
.text
.align 1
.globl Leaf
.type Leaf, @function
Leaf:
addi sp,sp,-48 # 修改 stack, 降低以 push 值
sw s0,44(sp) # 把 s0 写进 44(sp), s0 这里代表 frame pointer
addi s0,sp,48 # s0 = sp + 48, s0 为 frame pointer
sw a0,-36(s0) # 把 a0 - a3 存了。a0-a1 用来存返回值,a0-a7 用来传参
sw a1,-40(s0)
sw a2,-44(s0)
sw a3,-48(s0)

lw a4,-36(s0) # 把原本 a0 a1 加载到 a4 a5
lw a5,-40(s0)
add a4,a4,a5 # a4 = a4 + a5
lw a3,-44(s0) # a3, a5 加载
lw a5,-48(s0)
add a5,a3,a5 # a5 = a3 + a5
sub a5,a4,a5 # a5 = a4 - a5 这两段完成函数主要的计算
sw a5,-20(s0)
lw a5,-20(s0)
mv a0,a5 # a0 = a5, a0 是返回值
lw s0,44(sp) # s0 = sp + 44
addi sp,sp,48 # 修改 sp
jr ra # ra 是 return address, 返回 ra
.size Leaf, .-Leaf
.ident "GCC: (GNU) 9.2.0"
  • 改变 s0 这个 frame pointer 和 sp 这个 stack pointer
  • 把原来的 a0 - a4 放到栈上
  • a4 a5 来运算
  • 改回 sp, s0
  • jr 返回

跳转回来的伪指令如下:

C5B138C9-1928-4101-A199-E972CF81657F

B488D822-AF29-4ACB-82B0-D02416ADCA81

顺便,-O2 编译的时候:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
	.file	"leaf.c"
.option nopic
.attribute arch, "rv32i2p0_m2p0_a2p0_c2p0"
.attribute unaligned_access, 0
.attribute stack_align, 16
.text
.align 1
.globl Leaf
.type Leaf, @function
Leaf:
add a0,a0,a1
add a2,a2,a3
sub a0,a0,a2
ret
.size Leaf, .-Leaf
.ident "GCC: (GNU) 9.2.0"

这里 a0, a1, a2, a3 四个是参数。

下面:

1
2
3
4
5
int mult(int, int);

int sumSquare(int x, int y) {
return mult(x, x) + y;
}

生成汇编:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
	.file	"ss.c"
.option nopic
.attribute arch, "rv32i2p0_m2p0_a2p0_c2p0"
.attribute unaligned_access, 0
.attribute stack_align, 16
.text
.align 1
.globl sumSquare
.type sumSquare, @function
sumSquare:
addi sp,sp,-16 # reserve space on stack
sw ra,12(sp) # save ret addr
sw s0,8(sp) # 存储原来的 s0
mv s0,a1 # s0 存储 y
mv a1,a0 # a1 = a0 (= x)
call mult
add a0,a0,s0
lw ra,12(sp)
lw s0,8(sp)
addi sp,sp,16
jr ra
.size sumSquare, .-sumSquare
.ident "GCC: (GNU) 9.2.0"

Stack Pointer & Frame Pointer & Memory

当然,以上演示的很多都在 stack 上,实际上我们可能需要打理的东西还更多:

QQ20201003-0

堆/栈的分配是 ISA 的一部分(指令集同样是 ISA 的一部分)

6F1CCA2D-7402-4958-92E0-14B74AED1E8E

以上是对 stack 的操作,在 x86 里面我们有原子的 push-pop, 但是这里我们得谨慎的多。

看前面那个 s0 的例子,用 frame pointer(s0) 而不是 sp 取地址相对值. 同时我们还有 fp, 即 frame pointer, 它中文叫“帧指针”。

The calling convention says it doesn’t matter if you use a frame pointer or not!

It is just a callee saved register, so if you use it as a frame pointer…
 It will be preserved just like any other saved register.

But if you just use it as s0, that makes no difference!

栈帧内返回地址是在local variables前还是在它们后面? - RednaxelaFX的回答 - 知乎 https://www.zhihu.com/question/33920941/answer/57597076

实际上 fp sp 关系类似fpsp , 同时 fp 不是必须的,但是对 debug 而言大有裨益。

接着举例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <stdlib.h>

typedef struct list {
void *car;
struct list *cdr;
} List;

List *map(List *src, void *(*f)(void *)) {
List *ret;
if (!src)
return 0;
ret = (List *)malloc(sizeof(List));
ret->car = (*f)(src->car);
ret->car = map(src->cdr, f);
return ret;
}

编译一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
	.file	"rich-list.c"
.option nopic
.attribute arch, "rv32i2p0_m2p0_a2p0_c2p0"
.attribute unaligned_access, 0
.attribute stack_align, 16
.text
.align 1
.globl map
.type map, @function
map:
addi sp,sp,-16
sw ra,12(sp)
sw s0,8(sp) # 存储 s0-s2
sw s1,4(sp)
sw s2,0(sp)
beq a0,zero,.L3 # is-null, a0 是 src
mv s0,a0 # save src
li a0,8 # a0 = 8, call malloc with size 8
mv s2,a1 # s2 = a1
call malloc
mv s1,a0
lw a0,0(s0)
jalr s2 # jalr 调用函数,a1 是一个 function
mv a5,a0
lw a0,4(s0)
sw a5,0(s1)
mv a1,s2
call map
lw ra,12(sp)
lw s0,8(sp)
sw a0,0(s1)
lw s2,0(sp)
mv a0,s1
lw s1,4(sp)
addi sp,sp,16
jr ra
.L3: # is-null, 直接返回了
lw ra,12(sp)
lw s0,8(sp)
li s1,0 # li rd, imm 读取立即数,这里把 s1, 即返回值,置为0
lw s2,0(sp)
mv a0,s1 # a0 = 0
lw s1,4(sp)
addi sp,sp,16
jr ra
.size map, .-map
.ident "GCC: (GNU) 9.2.0"

这里的 requirements 是:我们会调用 malloc, 并在之后使用 srcf 参数