[toc]
RISC-V 寄存器
在 RISC-V 中,所有操作数(Operanden)都存储在寄存器中。
RISC-V ISA 拥有 32 或 33 个(可见)寄存器,其中x0永远为零,而x1 到 x31 是通用整数寄存器,f1 到 f31 是 浮点寄存器:
Register | ABI Name | Description | Saver |
---|---|---|---|
x0 | zero | Hard-wired zero | — |
x1 | ra | Return address | Caller |
x2 | sp | Stack pointer | Callee |
x3 | gp | Global pointer | — |
x4 | tp | Thread pointer | — |
x5–7 | t0–2 | Temporaries | Caller |
x8 | s0/fp | Saved register/frame pointer | Callee |
x9 | s1 | Saved register | Callee |
x10–11 | a0–1 | Function arguments/return values | Caller |
x12–17 | a2–7 | Function arguments | Caller |
x18–27 | s2–11 | Saved registers | Callee |
x28–31 | t3–6 | Temporaries | Caller |
Register | ABI Name | Description | Saver |
---|---|---|---|
f0–7 | ft0–7 | FP temporaries | Caller |
f8–9 | fs0–1 | FP saved registers | Callee |
f10–11 | fa0–1 | FP arguments/return values | Caller |
f12–17 | fa2–7 | FP arguments | Caller |
f18–27 | fs2–11 | FP saved registers | Callee |
f28–31 | ft8–11 | FP temporaries | Caller |
这里第一列的是每个寄存器的编号,而第二列则是附带语义的名字。一般在汇编代码中使用ABI name,便于理解和维护。
关于Saver部分可以看下面这个表,会更直观一点:
Name | Meaning | Saver | Preserved across calls? |
---|---|---|---|
ra | Return address | Caller | No |
sp | Stack pointer | Callee | Yes |
t0 - t6 | Temporary registers | Caller | No |
s0 - s11 | Callee-saved registers | Callee | Yes |
a0 - a7 | Argument registers | Caller | No |
Caller指的是调用函数的一方(比如说main()),而Callee指的则是被调用的函数。
如果一个register的Saver是Caller,则调用函数的一方(Caller)需要在调用函数前将这些寄存器里的值都存储好,以便被调用函数可以随意使用;
如果一个register的Saver是Callee,则被调用函数需要确保这些寄存器的值在该函数结束后与之前一模一样。
通俗一点来讲,前者是将自己的笔记本备份好之后借出去,借用方(Callee)可以随意使用;后者则是将笔记本借出去,但借用方必须确保拿到的时候是什么样子的还回去的时候也得是什么样子的。
RISC-V 汇编语言
每一个ISA(Instruction Set Architecture)的指令都可以分为以下3大类:
算术和逻辑运算(Arithmetische und logische Operationen)
数据传输(Datentransfer)
程序控制 (Steuerung des Programmablaufs):跳转和子程序调用( Sprünge und Unterprogrammaufrufe)
RISC-V 基础汇编指令一览:
算术 Arithmetik
加法:
a = b + c
1 | add a,b,c |
减法:
a = b - c
1 | sub a,b,c |
组合:
a = b + c - d
1 | add t,b,c # t=b+c |
浮点数的情况:
“F” 拓展用于 Single Precision (32bit) Gleitkomma;
“D” 拓展用于 Double Precision (64bit) Gleitkomma。
1 | fadd a,b,c |
乘法:
注意,乘法以及除法都是需要“M”拓展(M-Erweiterung)的。
乘法相关的操作基本上就是以下几种:
只不过RISC-V 的基本整数指令集中(RV32I 和 RV64I)不包含 mul 指令,乘法运算通常需要 M 扩展(RV32M/RV64M)。如果只允许使用 RV32I/RV64I 指令,则必须通过移位和加法手动实现乘法。
在硬件实现上,移位操作比乘法更快,因为移位仅涉及逻辑电路,而乘法通常需要额外的计算资源。
例子:
假设
$s1 = 0x40000000 = 2^{30}$
$ s2 = 0x80000000 = -2^{31}$
$ s1 * s2 = -2^{61} = 0xE0000000 00000000$
那么
1 | mul s3, s1, s2 |
便会得到
$ s4 = 0xE0000000; s3 = 0x00000000$
除法以及余数:
逻辑运算 Logische Operation
和:
a = b AND c
1 | and a,b,c |
或:
1 | or a,b,c |
异或:
1 | xor a,b,c |
移位(Shifts):
移位操作分为2种:
1. 逻辑移位 logischer Shift
,用 >>, <<表示
用 0 填充空出的高位,无论原数是正数还是负数(Null Bits nachgeführt),适用于 无符号数(unsigned numbers) 计算。
例子1:
假设
1 | t0 = 1111 1111 1111 1111 1111 1111 1110 0111 = -25(补码表示) |
那么经过
1 | srl t0, t0, 4 |
会得到
1 | t0 = 0000 1111 1111 1111 1111 1111 1111 1110 = 268435454(正数) |
例子2:
假设
1 | t0 = 0000 0000 0000 0000 0000 0000 0001 1001 # 25(十进制) |
那么经过
1 | sll t0, t0, 4 |
会得到
1 | t0 = 0000 0000 0000 0000 0000 0001 1001 0000 = 400(正数) |
($400 = 25 * 2^4$)
2. 算术移位 arithmetischen Shift
,用 >>>, <<< 表示
会保持符号位(MSB)不变 (erhält Vorzeichen, 0 oder 1 nachgeführt)
- 如果是正数(MSB = 0),则用 0 填充高位。
- 如果是负数(MSB = 1),则用 1 填充高位。
适用于 有符号数(signed numbers) 计算,保持符号正确。
例子1:
假设
1 | t0 = 1111 1111 1111 1111 1111 1111 1110 0111 = -25(补码表示) |
那么经过
1 | sra t0, t0, 4 |
会得到
1 | t0 = 1111 1111 1111 1111 1111 1111 1111 1110 = -2 (负数) |
左移本质上就是乘法。无论有符号数还是无符号数,左移的行为都是相同的,所以 RISC-V 没有 sla指令。
这里的 uimm 会被限制在 5-bit 的无符号整数。
数据传输(Datentransfer)
读取(Lesen vom Speicher):
ld (load word) 加载内存里的值
Format:
1 | ld destination, offset(base) |
例子:
1 | ld a1, 8(s0) |
Basisadresse (s0) zum Offset (8) addieren,
Adresse = ([Wert in s0] + 8)
a1存储的是地址 (s0里存储的地址+8) 的值
la (load adress) 加载地址
(算伪命令, Pseudoinstruktion)
例子:
假如现在有
1 | .data |
那么最终结果为
1 | a0 = 地址,比如 0x10010000 |
写入(Schreiben in den Speicher):
sd (store word)
Format:
1 | sd source, offset(base) |
例子:
1 | sd a1, 8(s0) |
Basisadresse (s0) zum Offset (8) addieren,
Adresse = ([Wert in s0] + 8)
跳转 Sprung
无条件跳转:
1 | j imm |
第一个会跳转到PC = PC + imm
第一个会跳转到PC = reg + imm
有条件跳转:
格式:
1 | bxx r1,r2,imm |
bxx的可选选项:
branch if equal (beq):$r1 = r2$
branch if not equal (bne):$r1 \neq r2$
branch if less than (blt):$r1 < r2$
branch if greater than or equal (bge):$r1 \leq r2$
例子1(if):
1 | if (i == j) |
跟上面这段c代码等价的RISC-V Assembler(汇编):
1 | # s0 = f, s1 = g, s2 = h |
例子2(if, else):
1 | if (i == j){ |
跟上面这段c代码等价的RISC-V Assembler(汇编):
1 | # s0 = f, s1 = g, s2 = h |
例子2(while 循环):
1 | // Berechne x, so dass |
跟上面这段c代码等价的RISC-V Assembler(汇编):
1 | # s0 = pow, s1 = x |
注意,这样写1
addi t0, 128, zero
是不合法的,即非法指令 。详见后面的“带有立即数(immediate)的命令”。
子程序调用 Unterprogramme
Begriffe:
Caller(调用者):调用函数的代码(如 main())。
Callee(被调用者):被调用的函数(如 simple())。
1 | jal reg, offset #调用 |
例子:
1 | int main() { |
跟上面这段c代码等价的RISC-V Assembler(汇编):
1 | 0x00000300 main: jal simple # call |
其他的伪命令:
1 | mv ra, rb |
跟
1 | add ra,rb,x0 |
等价。
递归(Rekursion)
普通递归
例子1:阶乘
1 | int factorial(int n) { |
对应的RISC-V(n=4的情况示例):
1 | _start: |
由于每次计算 factorial(n) 的值时需要先等 factorial(n-1)的计算结果先出来,所以等待期间需要将所有的 n 都给存在stack里,也就是说:
1 | factorial(4) |
而栈结构如下:
1 | +--------------------+ |
返回的时候则是这样:
当a0=0的时候触发0x20c的beq,跳转到break,break执行完了会跳转到当前的ra(也就是stack最底下一行的0x224),然后进行这部分操作:
1 | 0x224 lw t0, 4(sp) # 从栈中恢复原来的 a0(也就是 n) |
计算a0 = 1*1然后继续跳转到stack倒数第三行的0x224,重复下去知道跳转到0x208,触发断点。
例子2:最大公约数
1 | unsigned ggT(unsigned a, unsigned b) { |
对应的RISC-V:
1 | ggT: |
总结一下:
每个Recursive的函数递归部分会分成2部分:
- 从当前n的情况跳转到n-1,保存当前n的值以及返回地址(即当前跳转命令的下一行的地址)
- 读取返回地址并用当前已经计算出来的n-1的情况的值与n进行函数运算。
相当于递减完了之后还得递增回来。
注意,2个例子里分配的stack的大小是不一样的,这个主要是看ABI的具体要求,这里主要目的是示范而已。但不管大小如何,一定要保证分配的Stack大小和回收的大小是一致的。
尾递归(Tail Rekursiob)
观察上面例子里stack存储的内容不难发现,如果递归的次数较高,则会很容易导致Stack Overflow。
为了解决这个问题,我们可以使用另一种递归方式:尾递归(Tail Rekursiob)
还是拿阶乘举例,我们将代码优化成这样:
1 | int factorial(int n, int acc = 1) { |
对应的RISC-V:
1 | factorial: |
这里的调用过程则会变成:
1 | factorial(3, 1) |
栈结构如下:
1 | +--------------------+ |
不再需要存储所有中间的 n 的值。相当于是直接跳转而不是等待。
带有立即数(immediate)的命令
立即数(Immediate/Konstante)
:由指令本身携带,不需要从寄存器或内容中加载
aadi
a = b + imm(一个立即数)
1 | addi a,b,<imm> |
注意,RISC-V里是没有subi这种操作的,因为它可以被addi代替:
subi a,b,x是等价于addi a,b,-x的(这里的x是一个立即数/常数)。
但sub不可以被add替代,是因为sub a,b,-c这个操作不合规(c是一个register,不存在-c这种操作)。
这里可以使用的立即数的大小会被限制在$[-2^{11},2^{11}) = [-2048,+2047]$ (具体原因会在之后机器码的部分阐述)
生成32-Bit的常数:
需要用到 load upper immediate (lui) 和 addi
lui 的作用是将一个常数加载到目标寄存器的高 20 位(也就是5Bytes),并将低 12 位(4Bytes)填充为 0。 而addi则负责将低 12 位(4Bytes)的数值加到目标寄存器中。 例子: C:1 | int a = 0xFEDC8765; |
对应的汇编:
1 | # s0 = a |
注意:
1 | addi a0, zero, -534 |
这段运行之后会得到 a0 的值为0,因为 lui 会将低12位填充为0。
RISC-V 机器码 Machine Code/Instrunction
将汇编语言翻译成机器码主要依靠下面这2张表:
首先,所有的操作会被分成6个大类:
- Register/register (R)
- Immediate (I)
- Upper immediate (U)
- Store (S)
- Branch (B) 条件跳转
- Jump (J)
然后会通过funct3以及funct7确定具体操作。(funct7更多的是为了扩展的操作/功能。)
rs1, rs2, rd存储的是寄存器的地址,而imm存储的则是立即数(immediate)的数值。其中rd是目标寄存器。
例子:
1 | addi a1, a2,1 |
假设a1(rd),a2(rs1)的地址为00010,01010,
因为addi指令的opcode和funct3为:0010011, 000 ,这行命令会翻译成以下的机器码:
1 | imm[11:0] | rs1 | funct3 | rd | opcode |
Store, Branch, Jump 操作的格式里将imm的值拆成好几部分(比如说imm[11:5]和imm[4:0])是为了其他内容的位置可以对齐,比如说rs2的位置都是第20-24bit。
Branch和Jump都是跳转操作,所以会将imm(跳转地址)的第一位(符号位)imm[12]或者imm[20]放在开头,以确定是向后跳转还是向前。然后因为RISC-V的命令都是32位的,也就是4Byte,所以所有地址都是4的倍数,也就不需要imm[0]的信息了,因为imm[0]始终等于0。
例题
例题1
Register s5 nullen
将寄存器s5的值设为0:
1 | mov s5, 0 |
例题2
Wert in Register a1 nach s3 kopieren
将s3的值复制到s1里:
1 | mv s3, a1 |
例题3
Dasunterste Bit von a0 nach t1 schreiben, restliche Bits sollen 0 sein
将 a0 的最低位写入 t1,其他位清零:
1 | andi t1, a0, 1 |
1在二进制中只有最后一位是1,其他位置都是0,所以a0的其他位置与0进行and操作都会得到0,而最后一位与1进行and操作会保留原本内容。
例题4
Register s1 mit Einsen füllen
将寄存器 s1的值的每一位都设为 1:
1 | li s1, -1 |
因为-1 的二进制表示全 1
例题5
Unterstes Byte von t0 nach a0 schreiben, Rest unverändert
将 t0 的最低字节写入 a0,保持其余部分不变:
1 | andi a0, a0, -256 # 清除 a0 的最低字节 (0xFFFFFF00) |
-256的二进制表示为
1 | 1111 1111 1111 1111 1111 1111 0000 0000 |
最后一个Byte的内容都是0,所以将它与a0进行and操作可以清除a0的最后一个Byte的内容,前面的保留。
然后将a0和t0直接进行or操作,会保留a0的前面部分,以及t0的最后一个Byte的内容。