learn-tech/专栏/计算机基础实战课/18RISC-V指令精讲(三):跳转指令实现与调试.md
2024-10-16 10:18:29 +08:00

13 KiB
Raw Blame History

                        因收到Google相关通知网站将会择期关闭。相关通知内容
                        
                        
                        18 RISC-V指令精讲跳转指令实现与调试
                        你好我是LMOS。

在[第五节课]我们曾经提到RV32I有两种跳转指令即无条件跳转指令和有条件的跳转指令。

不过,前面我们只是简单了解了跳转指令长什么样,并没有深入讲解。接下来的两节课,我们就好好研究一下跳转指令的原理,挨个指令做调试。

这节课我们从源头说起,弄明白为什么需要有跳转指令存在,然后再熟悉一下无条件跳转指令。至于有条件跳转指令,我们放在下节课继续学习。这节课代码,你可以从这里下载。

为什么要有跳转指令

我们不妨回忆一下C语言中if、for、goto等流程控制语句都是如何实现的还有C语言的函数是如何调用和返回的

通过前面的学习我们了解到CPU执行指令是由PC寄存器指向的。每次执行完指令CPU的PC寄存器就会自动增加一条指令大小的数值使之指向下一条指令如此循环这就导致CPU只能在PC寄存器的引导下顺序地执行指令而C语言函数就是一条条指令组成的。显然只靠这样的机制C语言无法实现流程控制和函数的调用与返回。

如果现在有一种机制它能够修改CPU里PC寄存器的值或者根据特定的条件来修改CPU的PC寄存器的值让PC寄存器能指向特定的内存地址读取里面的指令并运行。这样上述问题就会迎刃而解了。

让我用一段C语言代码为例给你分解一下这个原理如下所示

int add() { int sum = 0; for(int i = 0; i < 100; i++) sum++; return sum; }

上述代码中for函数包含了条件流程控制和循环流程控制。在编译过程中C语言编译器会将它拆分为三段伪代码如下所示

add: int sum = 0; int i = 0; label1: i < 100 = false goto lable2 sum++; i++; goto label1 lable2: return sum;

以上伪代码中的goto用来修改CPU的PC寄存器的值使之指向lable1或者lable2这样CPU才能执行不同的代码段从而实现流程控制。这里的goto语句就对应着后面要讲的跳转指令。

说到这里,如果你能再次想起图灵机的读头在那条无限的纸带上跳来跳去的情景,就说明你已经深刻理解了代码的执行原理。

RISC-V的跳转指令格式

前面我们说了CPU必须依赖某个机制修改PC寄存器的值让程序能够跳转执行达到程序流程控制的目的。

这个机制离不开CPU提供的跳转指令只要执行跳转指令就能修改PC寄存器了。在研究无条件跳转指令之前我们先来看看RISC-V的跳转指令格式它对应的汇编语句格式如下

指令助记符 目标寄存器源操作数1源操作数2

对于无条件跳转指令来说指令助记符可以是jal和jalr目标寄存器可以是任何通用寄存器而源操作数1可以是任何通用寄存器源操作数2可以是任何通用寄存器和立即数。

为什么是目标寄存器而不是PC寄存器呢继续往下看我会带你找到答案。

无条件跳转指令jal指令

我们先来看看jal指令这是一条无条件的跳转并链接的指令。它的汇编代码书写形式如下

jal rdimm #jal 无条件的跳转并链接的指令 #rd 目标寄存器 #imm 立即数

上述代码中rd可以是任何通用寄存器。立即数imm为20位二进制数据。有的文档里会把imm称为偏移为了课程前后文的一致性我们继续沿用立即数的叫法。

jal完成的操作用伪代码描述如下

rd = pc + 4 pc = pc + 符号扩展imm << 1

对照代码不难发现jal指令首先把pc+4即下一条指令地址送给了rd然后把PC寄存器中的32位无符号数据加上imm<并且进行符号位扩展因为指令总是2或者4字节地址对齐的所以最低位永远为0再送给PC寄存器。这样就实现了程序的跳转。

接下来,我们一起写代码验证一下。

为了方便调试我们的代码组织结构是这样的写一个main.c文件在里面写上main函数因为这是链接器所需要的。然后我们需要再写一个jal.S文件用汇编在里面写上jal_ins函数。

类似的操作前面两节课反复试验过,就不过多重复了。代码如下所示:

.text .global jal_ins jal_ins: jal a0imm_l1 #a0=add x0x0x0的地址跳转到imm_l1地址处开始运行 add x0x0x0 #相当于nop什么也不做 add x0x0x0 imm_l1: jal a0imm_l2 #a0=imm_l2的地址跳转到imm_l2地址处开始运行 imm_l2: jr ra #函数返回

我已经把jal_ins函数为你写好了。第一条指令跳转到imm_l1地址处开始运行a0寄存器保存下一条指令的地址即add x0x0x0的地址。

这条指令没什么实际的实现x0是个只读寄存器始终返回0。imm_l1地址处又是一条跳转指令跳转到jr ra指令地址即imm_l2处开始运行。a0等于imm_l2的地址也会作为函数的返回值返回。

你可以用VSCode打开工程目录按下“F5”键调试一下。首先我们把断点停在jal a0imm_l1 指令处,效果如下所示:

上图中的状态是执行jal a0imm_l1指令之前pc寄存器指向0x10174地址这个地址对应的正是这条jal a0 imm_l1指令。

我们一旦单步调试程序代码就会跳到jal a0imm_l2 指令处pc + 12 等于0x10180a0等于0x10178状态如下所示

果不其然a0等于0x10178而pc等于0x10180正是jal a0imm_l2指令。

我们继续做单步调试程序代码会跳到jr ra 指令处pc + 4 等于0x10184a0也会等于0x10184存放jr ra 指令的地址而a0作为函数的返回值进行返回也就是jal_ins函数最后一条指令的地址。

我们再次进行单步调试程序将会回到main函数中并打印出返回值如下所示

可以看到上图中输出的结果确实是符合预期的这说明jal指令的功能确实跟我们前面描述的一致能够无条件跳转并链接。

无条件跳转指令jalr指令

让我们加把劲在jal.S文件中再写一个函数——jalr_ins函数。在这个函数中我们用jalr指令实现函数调用具体就是给jalr_ins函数传递一个函数指针通过这个函数指针调用这个函数。

写代码之前我们先来了解一下jalr指令它同样是一条无条件的跳转并链接的指令。jalr指令与jal指令字面上的不同点无非就是多了一个字母“r”这个“r”表示寄存器相当于jal指令的寄存器版本能够通过寄存器传递跳转地址。

jalr的汇编代码书写形式如下

jalr rdrs1imm #jalr 无条件的跳转并链接的指令 #rd 目标寄存器 #rs 源寄存器1 #imm 立即数

上述代码中rd、rs1可以是任何通用寄存器立即数imm为12位二进制数据。jalr完成的操作用伪代码描述如下

rd = pc + 4 pc = (rs1 + 符号扩展(imm << 1)) & 0xfffffffe

对比之后我们不难发现以上代码中和jal相同的地方是开始第一步由jalr指令把pc+4即下一条指令地址送给rd。

而不同之处是jalr指令的下一步操作会让rs1中的32位无符号数据加上imm<并且进行符号位扩展后与上0xfffffffe这也是为了指令要以2字节或者4字节地址对齐所以最低位必须为0形成一个地址值。完成以上过程后这个地址值会送给pc从而实现程序的跳转。

下面我们一起写代码验证一下。

.global jalr_ins jalr_ins: addi spsp-16 #在栈中分配16字节的空间 sw ra0(sp) #保存ra寄存器到栈中 jalr raa00 #ra = lw ra0(sp)指令的地址跳转到a0+0的地址处开始运行 lw ra0(sp) #从栈中恢复ra寄存器 addi spsp16 #回收栈中分配的16字节的空间 jr ra #函数返回

这段代码34行和67行代码的作用是在栈中分配和回收内存空间的指令用于保存和恢复ra寄存器的内容。

因为在第5行代码中跳转别的代码中运行正是用ra寄存器来保存地址的然而在跳转到jalr_ins函数处运行的时候同样是使用ra寄存器保存返回地址的如果不保存和恢复ra寄存器jalr_ins函数将无法返回。

现在我们调试一下,如下所示:

上图中是执行jalr raa00指令之前的状态a0寄存器中的值是0x101a0这正是testjalr函数的地址。这条指令能完成类似函数调用的功能我们一旦单步调试程序就会跳到testjalr函数内部开始运行状态如下所示

由上图可知jalr_ins函数确实调用了testjalr函数也打印出了testjalr的地址。下一步将要执行testjalr函数的返回语句会返回jalr_ins函数的地址。

我们继续做单步调试看看能不能返回到jalr_ins函数中如下所示

调试结果验证了确实如此代码流程再次回到了jalr_ins函数中。在jalr_ins函数中我们恢复了之前的ra寄存器a0寄存器中保存着testjalr函数的返回值即jalr_ins函数的地址。

继续单步调试代码流程就会回到main函数。

如上图所示main函数中继续打印出了jalr_ins函数的地址这个结果是正确的代码流程也符合预期。

通过调试我们已经了解了jal、jalr指令的功能细节。

下面我们来看看jal_ins函数和jalr_ins函数的二进制数据。其实我们调试bug或者做逆向工程很多时候都需要研究机器码正好借这次研究指令的机会我们一起练习一下怎么分析。

我们打开终端切换到工程目录下输入命令riscv64-unknown-elf-objdump -d ./main.elf > ./main.ins就会得到main.elf的反汇编数据文件main.ins。打开这个文件就会看到jal_ins函数和jalr_ins函数的二进制数据我的操作截图如下所示

上图中的反汇编代码中使用了一些伪指令比如ret的机器码是0x00008067它就是jr ra但是jr ra也是伪指令实际的指令是jalr x0ra0指令伪指令是为了方便汇编编程人员才使用的。

我们再来说说上图中的机器码0x0040056f为jal a0imm_l20x101840x000500e7为jalr raa00图里的jalr a0就是jalr raa00 。

我们继续拆分jal指令和jalr指令的各位段的数据看看它们具体是如何编码的。你不妨结合后面的示意图来理解

jal指令与jalr指令是靠操作码区分的。jal指令的立即数部分编码非常乱这部分跟芯片设计有关就不深入讨论了其数据正常组合起来是0b00000000000000000010这个二进制数据左移1位等于十六进制数据0x4。为什么是这样呢

回到前面看看jal指令的操作你就明白了pc+4正好是imm_l2的地址即0x10184而jalr指令编码非常简单12位立即数为0源寄存器是a0目标寄存器是rax1寄存器的编码就是1。

到这里jal指令与jalr指令我们就讲完了。它们都是无条件跳转指令并且都可以保存跳转指令的下一条指令的地址用于返回。但jal指令与jalr指令跳转的地址大小范围有差别这主要取决于它们地址数据的编码形式和计算方式。jal指令是用当前pc值加上20位立即数jalr指令是通用寄存器加上11位立即数。

重点回顾

说到这里,这节课的内容就告一段落了,我来给你做个总结。

因为不管什么程序都不能永无止境地顺序运行下去,所以需要控制程序流程,对数据进行比较判断,根据结果执行相应的动作。这就需要程序能够跳转,所以,一套指令集里就必须要有跳转指令来支持。

跳转指令又分成有条件跳转指令和无条件跳转指令。我们按照先易后难的顺序这节课重点研究了无条件的跳转指令一共是两条指令即jal指令和jalr指令。它们在跳转的同时还能保存下一条指令的地址这类指令常用来实现高级语言如C语言里的函数调用。

这节课的要点我给你准备了导图,你可以做个参考。调试验证环节,我建议你自己课后动手多多练习,加深印象。

下节课我们继续研究有条件跳转指令,敬请期待。

思考题

既然已经有jal指令了为什么还需要jalr指令呢

期待你在留言区记录收获或疑问,认真思考和主动练习都能让你加深印象。如果感觉这节课还不错,也推荐你把这节课分享给更多朋友。