其实接下来的指令介绍我刚开始不太确定要不要写,因为对于 CPU 设计和实现而言略有拖沓,但是熟练掌握指令格式还是对写 CPU 有帮助的。接下来的部分假如学过 RISC-V 指令集的可以跳过,并且也只是简单介绍下指令的特点和作用,并不会细讲,建议拿 RISC-V 指令集尝试实现一下 C 语言程序。(或者体会我们将一百多行 C 编译出来的机器码和汇编码做手动解释的痛苦,不过我当时是用 Java 程序自动完成的,得亏 RISC-V 指令集还算工整)
RISC-V指令为32位等长指令(不考虑其他短指令),其指令分为 R、I、S、B(SB)、U、J(UJ)型,结构较为工整。
rs1
操作寄存器 1,一般是从中取出数据
rs2
操作寄存器 2,一般是从中取出数据
opecode
指令代码,用来代表是哪种指令(一般是大的类型,如 R、I、S、B、U、J型)
funct7
、 funct3
主要用来表明指令的具体功能(一般是小的类型,如有无符号)
rd
目的寄存器,一般是要写入的寄存器
imm
立即数,后面的类似数组的调用即为类似数组的调用 : ),不过要注意的是有一部分类型(B,J型指令的立即数空了一位)
分析各个指令(详见RISC-V的指令说明书),易知 opecode
能够分辨指令的类型和部分同类型指令,同类型指令主要靠 funct3
和 funct7
分辨其操作不同。比如对于有符号操作和无符号操作,其差别仅为 funct7
的一位区别。
但是在实际写代码的时候是否要用这些特点就不好说了,一方面是用了之后可读性下降,而且判断不全面也会出现指令误判的可能,另一方面是用了是否能提升性能有待商榷,毕竟Vivado的综合能力还是蛮强的。
注意:
对于立即数扩展,还需要注意部分指令的位数有略去低位的现象。比如对于 J 型指令,略去最低一位是因为 RISC-V 指令集的指令都是 16 位往上的,按字节寻址的时候并不需要考虑最低一位,这样可以节省空间表示更大的范围。
对于寄存器 x0(alias: zero),它的值恒为 0,即使你对它执行写入操作。
这里不会对RISC-V指令做过多的介绍,对于更多的指令细节,可以查看网站 RISC-V Instruction Set Specifications — riscv-isa-pages documentation (msyksphinz-self.github.io)
R 型指令
31 - 25 | 24 - 20 | 19 - 15 | 14 - 12 | 11 - 7 | 6 - 0 | |
---|---|---|---|---|---|---|
含义 | funct7 | rs2 | rs1 | funct3 | rd | opecode |
对于 R 型指令而言,基本上是用来进行 寄存器操作 的。举个栗子:
add
指令,指令格式:add x2, x0, x1
,就是将 x0
和 x1
的寄存器内容相加,然后写入寄存器 x2
中
在上面的载入指令栗子中,x2
为 rd
,x0
为 rs1
,x1
为 rs2
I 型指令
31 - 20 | 19 - 15 | 14 - 12 | 11 - 7 | 6 - 0 | |
---|---|---|---|---|---|
含义 | imm[11: 0] | rs1 | funct3 | rd | opecode |
对于 I 型指令,一般是用来进行 立即数和寄存器操作 的。举个栗子:
addi
指令,指令格式:addi x2, x0, 20
,就是将 x0
的寄存器内容和立即数 20
相加,然后写入寄存器 x2
中
在上面的栗子中,x2
为 rd
,x0
为 rs1
,20
为 imm
还有载入指令的指令格式略为特殊
lw
指令,指令格式 lw x2, 20(x1)
,首先,x1
寄存器内存放的数据作为一个地址(主存地址或者外设地址 [ 假如统一编址的话 ] ),那 20(x1)
就意味着将 20 + num[x1]
作为取出数据的地址,然后将这个数据存入到 x2
里面。
在上面的载入指令栗子中,x2
为 rd
,x1
为 rs1
,20
为 imm
。
还有 jalr
指令(无条件跳转),指令格式 jalr x2, 20(x1)
,跟载入指令相同,将 20 + num[x1]
作为要跳转的地址,然后将 PC + 4 存入 x2
寄存器里面
在上面的跳转指令栗子中,x2
为 rd
,x1
为 rs1
,20
为 imm
S 型指令
31 - 25 | 24 - 20 | 19 - 15 | 14 - 12 | 11 - 7 | 6 - 0 | |
---|---|---|---|---|---|---|
含义 | imm[11: 5] | rs2 | rs1 | funct3 | imm[4: 0] | opecode |
对于 S 型指令,一般是用来进行 存储操作 的。举个栗子:
sw
指令,指令格式:sw x2, 20(x1)
。首先,x1
寄存器内存放的数据作为一个地址(主存地址或者外设地址 [ 假如统一编址的话 ] ),那 20(x1)
就意味着将 20 + num[x1]
作为存储数据的地址,然后将寄存器 x2
的内容存放在那里。
在上面的栗子中,x2
为 rs2
,x1
为 rs1
,20
为 imm
。
这时候可能有眼尖的同学发现了,为啥存储指令跟加载指令不搁一块儿捏。因为加载指令有目的寄存器
rd
(要写入数据的寄存器),结构跟 I 型很像,就归为 I 型了。但是存储指令,它不用写入寄存器呀,同时,它除了要地址rs1
,它还要数据rs2
呀,所以是这样子滴。
B 型指令
31 - 25 | 24 - 20 | 19 - 15 | 14 - 12 | 11 - 7 | 6 - 0 | |
---|---|---|---|---|---|---|
含义 | imm[12 | 10: 5] | rs2 | rs1 | funct3 | imm[4: 1| 11] | opecode |
对于 B 型指令,一般是用来进行 条件跳转 的。举个例子:
bge
指令,指令格式:bge x1, x2, Label
,这里的 Label
,本质上是一个立即数,只是写汇编代码的时候我们可以用类似 C 语言的写法写成一个标记,编译器会自动将其转化为指令地址(立即数)
if (x1 > x2) PC = Label; else PC = PC + 4;
U 型指令
31 - 12 | 11 - 7 | 6 - 0 | |
---|---|---|---|
含义 | imm[31: 12] | rd | opecode |
对于 U 型指令,一般用于加载长立即数,举个栗子:
lui
指令,指令格式 lui x1, 0xFFFFF
,就是将 0xFFFFF
这 20 位,载入到 x1
寄存器中,当然会覆盖掉原值,这时候 x1
寄存器为 0xFFFFF000
[32 位]
可能有眼尖的同学发现了,要它干嘛捏。
其实,你会发现 I 型指令只能操作 12 位立即数,但是我的寄存器有 32 位呀,这不糟践了吗,要是弄个数老得做个加法然后做个移位,多大罪过捏。
比如我要弄一个 32 位立即数
0xFEDCBA98
,那只需要这样写:lui x1, 0xFEDCC addi x1, x1, -1384 # 0x568可能有脑子尖的同学发现了,怎么这数那么别扭捏,不是
0xFEDCB
咩,怎么就变成这副模样了。因为,对于 I 型指令而言,它的立即数是有符号的,你要加上一个十二位的数字,第十二位就是符号位,所以是个负数,需要用补码转换得到。同时由于是负数,所以高位会减 1,因此要是
0xFEDCC
减 1 才能是0xFEDCB
J 型指令
31 - 12 | 11 - 7 | 6 - 0 | |
---|---|---|---|
含义 | imm[20 | 10: 1 | 11 | 19: 12] | rd | opecode |
对于 J 型指令,一般用于无条件跳转,举个栗子:
jal
指令,指令格式: jal x2, Label
,这里的 Label
跟 B 型指令含义相同,但是到二进制码会略去最低位(当然汇编的数字是正常写的,编译器会帮你处理) ,将 Label
作为要跳转的地址,然后将 PC + 4 存入 x2
寄存器里面
在上面的跳转指令栗子中,x2
为 rd
,Label
为 imm
好了,基础知识就讲那么多,下一篇就要开始单周期CPU设计和实现了