17|RISC-V指令精讲(二):算术指令实现与调试

17|RISC-V指令精讲(二):算术指令实现与调试

你好,我是LMOS。

上节课,我们学习了算术指令中的加减指令和比较指令。不过一个CPU只能实现这两类指令还不够。如果你学过C语言,应该对“<<、>>、&、|、!”这些运算符并不陌生,这些运算符都需要CPU提供逻辑和移位指令才可以实现。

今天我们就继续学习逻辑指令(and、or、xor)和移位指令 (sll、srl、sra)。代码你可以从 这里 下载。话不多说,我们开始吧。

逻辑指令

从CPU芯片电路角度来看,其实CPU更擅长执行逻辑操作,如与、或、异或。至于为什么,你可以看看CPU的基础门电路。

RISC-V指令集中包含了三种逻辑指令,这些指令又分为立即数版本和寄存器版本,分别是andi、and、ori、or、xori、xor这六条指令。我们学习这些指令的方法和上节课类似,也涉及到写代码验证调试的部分。

按位与操作:andi、and指令

首先我们来学习一下andi、and指令,它们的形式如下所示:

andi rd,rs1,imm
#andi 立即数按位与指令
#rd 目标寄存器
#rs1 源寄存器1
#imm 立即数
and rd,rs1,rs2
#and 寄存器按位与指令
#rd 目标寄存器
#rs1 源寄存器1
#rs2 源寄存器2

上述代码中rd、rs1、rs2可以是任何通用寄存器,imm是立即数。

andi、and这两个指令完成的操作,我们用伪代码描述如下:

//andi
rd = rs1 & imm
//and
rd = rs1 & rs2

按位与的操作,就是把rs1与imm或者rs1与rs2其中的每个数据位两两相与。两个位都是1,结果为1,否则结果为0。

下面我们在工程目录下建立一个and.S文件,写代码验证一下这两个指令,如下所示:

.globl andi_ins
andi_ins:
    andi a0,a0,0xff       #a0 = a0&0xff,a0是C语言调用者传递的参数,a0也是返回值,这样计算结果就返回了
    jr ra                   #函数返回

.globl and_ins
and_ins:
    and a0,a0,a1          #a0 = a0&a1,a0、a1是C语言调用者传递的参数,a0是返回值,这样计算结果就返回了
    jr ra                   #函数返回

这里我们已经写好了andi_ins与and_ins函数,分别去执行andi和and指令。

andi指令是拿a0寄存器和立即数0xff进行与操作。由于立即数是0xff,所以总是返回a0的低8位数据;and指令则是拿a0和a1寄存器进行与操作,再把结果写入到a0寄存器。

下面我们用VSCode打开工程按下“F5”调试一下,如下所示:

在这里插入图片描述

上图中是执行完andi a0,a0,0xff指令之后,执行jr ra指令之前的状态。可以看到,a0寄存器中的值确实已经变成2了,这说明运算的结果是符合预期的。

andi_ins函数返回后,输出的结果如下图所示:

在这里插入图片描述

因为2的二进制数据是(0b00000000000000000000000000000010)与上0xff的二进制数据是(0b00000000000000000000000011111111)结果确实是2,所以返回2,结果是正确的。

接下来,我们对and_ins函数进行调试。

在这里插入图片描述

上图展示的是执行完and a0,a0,a1指令之后,执行jr ra指令之前的状态。我们看到a0寄存器中的值已经变成了1,这说明运算的结果是正确的。

and_ins函数返回后,输出的结果如下图所示:

在这里插入图片描述

上图中因为1的二进制数据是(0b00000000000000000000000000000001)与上1的二进制数据是(0b00000000000000000000000000000001)确实是1,所以返回1,结果完全正确。

按位或操作:ori、or指令

按位与操作说完了,我们接着来学习一下或指令ori、or,它们的形式如下:

ori rd,rs1,imm
#ori 立即数按位或指令
#rd 目标寄存器
#rs1 源寄存器1
#imm 立即数
or rd,rs1,rs2
#or 寄存器按位或指令
#rd 目标寄存器
#rs1 源寄存器1
#rs2 源寄存器2

同样地,上述代码中rd、rs1、rs2可以是任何通用寄存器,imm表示立即数。

我们还是从伪代码的描述入手,看看ori、or完成的操作。

//ori
rd = rs1 | imm
//or
rd = rs1 | rs2

按位或的操作就是把rs1与imm或者rs1与rs2其中的每个数据位两两相或,两个位有一位为1,结果为1,否则结果为0。

我们在and.S文件中写写代码,做个验证,如下所示:

.globl ori_ins
ori_ins:
    ori a0,a0,0           #a0 = a0|0,a0是C语言调用者传递的参数,a0也是返回值,这样计算结果就返回了
    jr ra                   #函数返回

.globl or_ins
or_ins:
    or a0,a0,a1           #a0 = a0|a1,a0、a1是C语言调用者传递的参数,a0是返回值,这样计算结果就返回了
    jr ra                   #函数返回

上述代码中ori_ins与or_ins函数,分别执行了ori和or指令。

ori指令是拿a0寄存器和立即数0进行或操作,由于立即数是0,所以总是返回a0原本的数据;or指令是拿a0和a1寄存器进行或操作,再把结果写入到a0寄存器。

我们还是到VSCode里,按下“F5”调试一下,如下所示:

在这里插入图片描述

上图中是执行完ori a0,a0,0指令之后,执行jr ra指令之前的状态。如果a0寄存器中的值确实已经变成0xf0f0了,就说明运算的结果正确。

ori_ins函数返回后,输出的结果如下图所示:

在这里插入图片描述

因为0xf0f0的二进制数据是(0b00000000000000001111000011110000)或上0的二进制数据是(0b00000000000000000000000000000000)按位或操作是“有1为1”,所以返回0xf0f0,结果是正确的。

我们再用同样的方法调试一下or_ins函数,如下图所示:

在这里插入图片描述

上图展示的是执行完or a0,a0,a1指令之后,执行jr ra指令之前的状态。如果我们看到a0寄存器中的值确实已经变成0x1111了,就说明运算的结果正确,符合预期。

or_ins函数返回后,输出的结果如下:

在这里插入图片描述

上图中or_ins函数第一个参数为0x1000的二进制数据是(0b00000000000000000001000000000000)第二个参数为0x1111的二进制数据是(0b00000000000000000001000100010001)两个参数相或,而按位或操作是“有1为1”,所以返回0x1111,结果是正确的。

按位异或操作:xori、xor指令

最后,我们再说说逻辑指令中的最后两条指令xori、xor,即异或指令的立即数版本和寄存器版本,它们的形式如下所示:

xori rd,rs1,imm
#xori 立即数按位异或指令
#rd 目标寄存器
#rs1 源寄存器1
#imm 立即数
xor rd,rs1,rs2
#xor 寄存器按位异或指令
#rd 目标寄存器
#rs1 源寄存器1
#rs2 源寄存器2

形式上和前面与操作、或操作差不多,就不过多重复了。

xori、xor完成的操作用伪代码描述如下:

//xori
rd = rs1 ^ imm
//xor
rd = rs1 ^ rs2

按位异或的操作是把rs1与imm或者rs1与rs2其中的每个数据位两两相异或,两个位如果不相同,结果为1。如果两个位相同,结果为0。

在and.S文件中写代码验证一下,如下所示。

.globl xori_ins
xori_ins:
    xori a0,a0,0          #a0 = a0^0,a0是C语言调用者传递的参数,a0也是返回值,这样计算结果就返回了
    jr ra                   #函数返回

.globl xor_ins
xor_ins:
    xor a0,a0,a1          #a0 = a0^a1,a0、a1是C语言调用者传递的参数,a0是返回值,这样计算结果就返回了
    jr ra                   #函数返回

我们已经写好了xori_ins与xor_ins函数,分别是执行xori和xor指令。xori指令是拿a0寄存器和立即数0进行异或操作,由于立即数是0,而且各个数据位相同为0,不同为1,所以同样会返回a0原本的数据 ;而xor指令是拿a0和a1寄存器进行或操作,再把结果写入到a0寄存器。

下面我们按下“F5”调试一下,如下所示:

在这里插入图片描述

上图中是执行完xori a0,a0,0指令之后,执行jr ra指令之前的状态,我们已经看到a0寄存器中的值已经变成0xff了,这说明运算的结果正确。

xori_ins函数返回后,输出的结果如下图所示:

在这里插入图片描述

结合上面这张截图不难发现,我们传递给xori_ins函数的参数是0xff,因为0xff的二进制数据是(0b00000000000000000000000011111111)异或上0的二进制数据是(0b00000000000000000000000000000000)按位异或操作是“相同为0,不同为1”,所以返回0xff,结果是正确的。

我们再来调试一下xor_ins函数。xor a0,a0,a1指令执行完成之后,执行jr ra指令之前的状态如图所示:

在这里插入图片描述

我们看到a0寄存器中的值已经变成0了,这说明运算的结果正确,符合预期。

xor_ins函数返回后,输出的结果如下图所示:

在这里插入图片描述

由于我们给xor_ins函数传递了两个相同的参数都是0xffff。因为0xffff的二进制数据是(0b00000000000000001111111111111111)两者异或,按位异或操作是“相同为0,不同为1”,所以返回0,结果是正确的。

下面我们看一下andi、and、ori、or、xori、xor这六条指令的二进制数据。

我们打开工程目录下的and.bin文件,如下所示:

在这里插入图片描述

上述图中的12个32位数据是12条指令,其中六个0x00008067数据是六个函数的返回指令。

具体的指令形式,还有对应的汇编语句,我用表格帮你做了整理。

在这里插入图片描述

同样地,我带你拆分一下andi、and、ori、or、xori、xor指令的各位段的数据,看看它们是如何编码的。

在这里插入图片描述

从上图中可以发现,立即数版本和寄存器版本的and、or、xor指令通过 操作码 区分,而它们之间的寄存器和立即数版本是靠 功能位段 来区分,立即数位段和源寄存器与目标寄存器位段和之前的指令是相同的。

到这里六条逻辑指令已经拿下了,咱们继续学习移位指令。

移位指令

移位指令和逻辑操作指令一样,都是CPU电路很容易就能实现的。

RISC-V指令集中的移位指令包括逻辑左移、逻辑右移和算术右移,它们分别有立即数和寄存器版本,所以一共有六条。逻辑右移和算术右移是不同的,等我们后面用到时再专门讲解。

逻辑左移指令:slli、sll指令

我们先看看逻辑左移指令,也就是slli、sll指令,它们的形式如下所示:

slli rd,rs1,imm
#slli 立即数逻辑左移指令
#rd 目标寄存器
#rs1 源寄存器1
#imm 立即数,rs1左移的位数,0~31
sll rd,rs1,rs2
#sll 寄存器逻辑左移指令
#rd 目标寄存器
#rs1 源寄存器1
#rs2 源寄存器2,rs1左移的位数

上述代码中rd、rs1、rs2可以是任何通用寄存器。imm是立即数,其实在官方文档中,这里是shamt,表示rs1 左移 shamt 位。这里我为了和之前的形式保持一致,才继续沿用了imm。

在这里插入图片描述

slli、sll它们俩完成的操作,用伪代码描述如下:

//slli
rd = rs1 << imm
//sll
rd = rs1 << rs2

逻辑左移的操作是把rs1中的数据向左移动imm位,或者把rs1中的数据向左移动rs2位,右边多出的空位填 0 并写入 rd 中。

我们用图解来表达这一过程,这样你就能一目了然了。

在这里插入图片描述

接下来我们在工程目录下,建立一个sll.S文件,写代码验证一下,如下所示:

.globl slli_ins
slli_ins:
    slli a0, a0, 4          #a0 = a0<<4,a0是C语言调用者传递的参数,a0也是返回值,这样计算结果就返回了
    jr ra                   #函数返回

.globl sll_ins
sll_ins:
    sll a0, a0, a1          #a0 = a0<<a1,a0、a1是C语言调用者传递的参数,a0是返回值,这样计算结果就返回了
    jr ra                   #函数返回

这里已经写好了slli_ins与sll_ins函数,它们会分别执行slli和sll指令。立即数逻辑左移slli指令是把a0中的数据左移4位。而逻辑左移sll指令是把a0中的数据左移,左移多少位要取决于a1中的数据,完成移动后再把结果写入到a0寄存器。

我们还是用VSCode打开工程,按下“F5”调试,如下所示:

在这里插入图片描述

上图中是进入slli_ins函数,执行完slli a0,a0,4指令之后,执行jr ra指令之前的状态,我们给slli_ins函数传进来的参数是0xffff。现在对照图示就能看到,a0寄存器中的值确实已经变成0xffff0了,这说明运算结果是正确的。

slli_ins函数返回后,输出的结果如下:

在这里插入图片描述

因为0xffff二进制数据是(0b00000000000000001111111111111111),逻辑左移4位后的结果是0xffff0,它的二进制数据是(0b00000000000011111111111111110000),结果正确无误。

下面我们接着对sll_ins函数进行调试,如下所示:

在这里插入图片描述

上图中是进入sll_ins函数,执行完sll a0,a0,a1指令之后,执行jr ra指令之前的状态,我们给sll_ins函数传进来的参数是0xeeeeeeee和31(a1寄存器)。如果看到a0寄存器中的值确实已经变成0了,这说明运算结果是正确的。

sll_ins函数返回后,输出的结果如下图所示:

在这里插入图片描述

第一个参数0xeeeeeeee的二进制数据是(0b11101110111011101110111011101110),逻辑左移31位后的结果是0,因为它把所有的二进制数据位都移出去了,然后空位补0,所以结果正确无误。

逻辑右移指令:srli、srl

有逻辑左移就有逻辑右移。逻辑右移指令srli、srl,分别对应着立即数和寄存器版本,它们的形式如下:

srli rd,rs1,imm
#srli 立即数逻辑右移指令
#rd 目标寄存器
#rs1 源寄存器1
#imm 立即数,rs1右移的位数,0~31
srl rd,rs1,rs2
#srl 寄存器逻辑右移指令
#rd 目标寄存器
#rs1 源寄存器1
#rs2 源寄存器2,rs1右移的位数

上述代码中rd、rs1、rs2可以是任何通用寄存器。imm是立即数。为了和之前的形式保持一致,我们还是沿用imm,而非官方文档中的shamt。

srli、srl完成的操作,可以用后面的伪代码来描述:

//srli
rd = rs1 >> imm
//srl
rd = rs1 >> rs2

逻辑右移的操作是把rs1中的数据向右移动imm位。或者把rs1中的数据向右移动rs2位,左边多出的空位填 0 并写入 rd 中。

你可以对照我画的图示来理解这一过程。

在这里插入图片描述

光看看格式自然不够,我们在sll.S文件中写段代码来验证一下,如下所示:

.globl srli_ins
srli_ins:
    srli a0, a0, 8          #a0 = a0>>8,a0是C语言调用者传递的参数,a0也是返回值,这样计算结果就返回了
    jr ra                   #函数返回

.globl srl_ins
srl_ins:
    srl a0, a0, a1          #a0 = a0>>a1,a0、a1是C语言调用者传递的参数,a0是返回值,这样计算结果就返回了
    jr ra                   #函数返回

逻辑右移的两个函数srli_ins与srl_ins,我已经帮你写好了。代码中立即数逻辑右移srli指令是把a0中的数据右移8位。逻辑右移srl指令,则是把a0中的数据右移,右移多少位要看a1中数据表示的位数是多少,再把结果写入到a0寄存器。

两条右移指令做了哪些事儿咱们说完了,老规矩,打开工程按下“F5”就可以调试了,效果如图:

在这里插入图片描述

上图中是进入srli_ins函数,执行完srli a0,a0,8指令之后,执行jr ra指令之前的状态,我们给srli_ins函数传进来的参数是0xffff。现在,对照截图可以看到a0寄存器中的值确实已经变成0xff了,这说明运算结果正确。

srli_ins函数返回后,输出的结果如下图所示:

在这里插入图片描述

因为调用函数srli_ins的参数0xffff的二进制数据是(0b00000000000000001111111111111111),逻辑右移8位后的结果是0xff,它的二进制数据是(0b00000000000000000000000011111111),结果正确,符合我们的预期。

拿下了srli_ins函数,接下来就是srl_ins函数的调试,如下所示:

在这里插入图片描述

上图中是调用进入srl_ins函数,执行完srl a0,a0,a1指令之后,执行jr ra指令之前的状态,给srl_ins函数传进来的参数是0xaaaaaaaa。可以看到,a0寄存器中的值确实已经变成0xaaaa了,所以运算结果也是正确的。

srl_ins函数返回后,输出的结果如下图所示:

在这里插入图片描述

给srl_ins函数传进来的第一个参数是0xaaaaaaaa的二进制数据是(0b10101010101010101010101010101010),逻辑右移16位后的结果是0xaaaa,其二进制数据为(0b00000000000000001010101010101010 ),因为它把低16位二进制数据位移出去了,然后高16位的空位补0,所以结果正确无误。

算术右移指令:srai、sra

最后还有两个算术右移指令,它们和逻辑右移的最大区别是, 数据在逻辑右移之后左边多出空位用0填充,而数据在算术右移之后左边多出的空位是用数据的符号位填充。 如果数据的符号位为1就填充1,如果为0就填充0。

它们的形式和伪代码与逻辑右移是一样的,只不过指令助记符由srli、srl,变成了srai、sra。

下面我们直接在sll.S文件中,写代码进行验证。

.globl srai_ins
srai_ins:
    srai a0, a0, 8          #a0 = a0>>8,a0是C语言调用者传递的参数,a0也是返回值,这样计算结果就返回了
    jr ra                   #函数返回

.globl sra_ins
sra_ins:
    sra a0, a0, a1          #a0 = a0>>a1,a0、a1是C语言调用者传递的参数,a0是返回值,这样计算结果就返回了
    jr ra                   #函数返回

上述代码中的两个函数srai_ins与sra_ins,可以实现算术右移。先看立即数算术右移srai指令,它把a0中的数据右移了8位。而算术右移srl指令是把a0中的数据右移,右移多少位由a1中的数据表示的位数来决定,之后再把结果写入到a0寄存器。

我们按下“F5”,调试的结果如下:

在这里插入图片描述

上图中是进入立即数算术右移函数srai_ins,执行完srai a0,a0,8指令之后,执行jr ra指令之前的状态。对照图里红框的内容可以看到,给srai_ins函数传进来的参数是0x1111。如果a0寄存器中的值确实已经变成0x11了,就代表运算结果正确。

srai_ins函数返回后,输出的结果如下:

在这里插入图片描述

因为我们给立即数算术右移函数srai_ins的参数0x1111,其二进制数据是(0b00000000000000000001000100010001),符号位为0,所以算术右移8位后的结果是0x11,它的二进制数据是(0b00000000000000000000000000010001),结果非常正确。

我们接着调试一下sra_ins函数,如下所示:

在这里插入图片描述

上图中是进入算术右移函数sra_ins,执行完sra a0,a0,a1指令之后,执行jr ra指令之前的状态。对照图里左侧红框的部分,我们就能知道sra_ins函数传进来的参数是0xaaaaaaaa,你可能判断a0寄存器里输出的结果应该是0x0000aaaa,但调试显示的实际结果却是0xffffaaaa。

出现这个结果,你很奇怪是不是?但这恰恰说明运算结果是正确的。我们先看看sra_ins函数返回后输出的结果是什么,然后再分析原因。

在这里插入图片描述

因为我们给算术右移函数sra_ins的参数是0xaaaaaaaa和16,这表明对0xaaaaaaaa算术右移16,0xaaaaaaaa的二进制数据是(0b10101010101010101010101010101010),注意 其符号位为1,所以算术右移16位后的结果是0xffffaaaa,它的二进制数据是(0b11111111111111111010101010101010),结果是符合预期的。输出的结果也证实了这一点。

下面我们还是要看一下slli、sll、srli、srl、srai、sra这六条指令的二进制数据,我们打开工程目录下的sll.bin文件。

在这里插入图片描述

可以看出,图中的12个32位数据是12条指令,其中六个0x00008067数据是六个函数的返回指令。具体的指令形式,还有对应的汇编语句,你可以参考后面的表格。

在这里插入图片描述

我们拆分一下slli、sll、srli、srl、srai、sra指令的各位段的数据,看看它们是在内存中如何编码的,你可以结合示意图来理解。

在这里插入图片描述

我虽然给你详细展示了这些指令如何编码,但并不需要你把细节全部硬记下来,重点是观察其中的规律。

从上图中我们可以发现,sll、srl、sra指令的立即数版本和寄存器版本要通过操作码区分,而它们之间是靠功能位段来区分的, 源寄存器与目标寄存器所在的位段和之前的指令是相同的。需要注意的是,这些立即数版本的立即数位段在官方文档中叫shamt位段,并且只占5位,而其它指令的立即数占12位,这里为了一致性还是沿用立即数。

到这里,六条移位指令我们就讲完了。

重点回顾

今天我们学习了逻辑指令和移位指令。

逻辑操作的指令包括andi、ori、or、xori、xor,分别能对寄存器与寄存器、寄存器与立即数进行与、或、异或操作。有了这些操作,CPU才能对数据进行逻辑运算,在一些情况下还能提升CPU的执行性能。更多的应用,后面课程里我们还会继续学习。

数据移位指令包括slli、sll、srli、srl、srai、sra,也能分别能对寄存器与寄存器、寄存器与立即数进行逻辑左移、逻辑右移、算术右移操作。这些指令与逻辑指令一起执行数据的位运算时,相当有用,在特定情况下能代替乘除法指令。

在这里插入图片描述

经过漫长的学习,我们用两节课程的篇幅,一鼓作气学习了RISC-V全部的算术指令,分为加减、比较、逻辑、移位四大类别,一共有19条指令。这些指令作用于数据的运算,在应用程序中扮演着重要角色。

但是CPU有了这些算术指令就够了吗?这显然是不行的,起码还需要流程控制指令和数据加载储存指令,我们会在后续课程中继续讨论。

思考题

为什么指令编码中,目标寄存器,源寄存器1,源寄存器2,占用的位宽都是5位呢?

欢迎你在留言区记录自己的疑问或收获,参与越多,你对内容的理解也更深入。如果觉得这节课内容不错,别忘了分享给更多朋友。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

源码头

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值