了解RISC-V组件中的非本地跳转(Setjmp/Longjmp)

2020-10-27 23:19:14

本文通过研究C标准库中setjmp和long jmp函数的实现来探索RISC-V汇编。我经常发现,当我有可以反汇编的实际代码时,我会更快地掌握概念,因为它允许我将信息与意图联系起来。我相信RISC-V和类似的努力将从根本上改变计算机的制造和编程方式。我希望分享我的知识会给别人带来和我想象开放硬件未来时一样的快乐。

注意:在这篇文章中,我们将使用RISC-V GNU工具链。如果您想要学习,可以交叉编译RISC-V目标的工具链,也可以下载预先构建的工具链和仿真器。您也可以使用不同的目标工具链编译和运行程序,但是程序集转储将特定于该体系结构。如果您更熟悉不同的ISA,这可能是了解RISC-V的有用方式!

跳转是编程中控制流的基本组件之一。几乎任何指令集体系结构(ISA)都会有一个跳转指令,它允许您修改程序计数器,以便从内存中指定的位置执行下一条指令。我们还可以看到提供类似逻辑的高级语言中的控制流语句。这方面的一个例子是C中的goto语句,由于能够创建极其复杂的代码,许多C程序员不赞成使用goto,但它确实有一些有用的应用程序,如Linux内核编码风格指南中所述的集中执行函数。它允许您定义一个标签,然后“转到”程序中的那个位置并从那里继续执行。例如,下面的程序将在这里反复打印一个换行符。

0000000000010158<;Main>;:10158:1141 addi sp,sp,-16 1015a:e406 SD ra,8(Sp)1015c:e022 SD s0,0(Sp)1015e:0800 addi s0,sp,16 10160:67c9 lui a5,0x12 10162:6c078513 addi a0,a5,1728#126c0<;__errno+0xE>;10166:1fc000ef JAL ra,10362<;put>;1016a:bfdj 10160<;main+0x8>;

如您所见,在程序中使用goto直接转换为jRISC-V指令,该指令跳转到内存地址10160,导致处理器连续执行我们的printf语句。对于我们来说,在这里使用WHILE语句会清楚得多,但实际上我们将通过无限循环获得完全相同的汇编输出:

0000000000010158<;Main>;:10158:1141 addi sp,sp,-16 1015a:e406 SD ra,8(Sp)1015c:e022 SD s0,0(Sp)1015e:0800 addi s0,sp,16 10160:67c9 lui a5,0x12 10162:6c078513 addi a0,a5,1728#126c0<;__errno+0xE>;10166:1fc000ef JAL ra,10362<;put>;1016a:bfdj 10160<;main+0x8>;

但是,GOTO可以提供比循环更多的功能,并且对于打破一组深度嵌套的循环特别有用。前面提到的Linux内核风格指南提供了以下示例,以便正确使用goto:

Int Fun(Int A){int result=0;char*buffer;buffer=kmalloc(size,gfp_kernel);if(!buffer)return-ENOMEM;if(Condition1){While(Loop1){...}result=1;GOTO OUT_FREE_BUFFER;}...OUT_FREE_BUFFER:kfree(Buffer);Return Result;}

在这种情况下,描述性标签用于定义特定的错误路径。虽然这里只包含函数体的一小部分,但您可以想象可能会有多个阶段,在这些阶段中分配的缓冲区可能会变满,您可以通过释放内存并返回结果来处理所有这些阶段。虽然有些人可能仍然主张永远不要使用goto,但这表明这样做有一些好处,这样就不需要在整个函数体中复制冗余代码。

不幸的是(或者幸运的是,如果您是从不使用goto的坚定支持者),它只在本地上下文中有效。不能跳到当前正在执行的函数之外的标签。为此,在C标准库中添加了setjmp和long jmp,以支持非本地跳转。让我们看一个使用这些函数的最小示例。

#include<;stdio.h>;#include<;setjmp.h>;static JMP_buf buf;void b(){printf(";in function b\n";);long jmp(buf,1);}void a(){printf(";in function a\n";);if(setjmp(Buf))printf(";back in function a\n";);Else b();}int main(){a();}。

通过查看setjmp Linux手册页面,我们可以很好地理解这里发生的事情。特别是对于本计划,说明的以下部分非常重要:

Long jmp()函数使用env中保存的信息将控制转移回调用setjmp()的位置,并将堆栈恢复(“回绕”)到setjmp()调用时的状态。

在一个成功的long jmp()之后,继续执行,就像setjmp()第二次返回一样。

最简单地说,这两个函数允许我们保存地址并在稍后执行时返回该地址。在幕后,其他值也被保存到BUF中,我们稍后将看到这一点。首先,让我们看看64位RISC-V目标的实际汇编输出是什么样子。

00000000000103c4<;setjmp>;:103c4:00153023 sd ra,0(A0)103c8:e500 sd s0,8(A0)103ca:e904 sd s1,16(A0)103cc:01253c23 s2,24(A0)103d0:03353023 sd s3,32(A0)103d4:03453423 s4,40(A0)103d8:03553823 sd s5,48(A0)103dc:03653c23 sd s6,56(A0)103e0:05753023 sd s7,64(A0)103e4:05953823 sd s8,72(A0)103e8:05953823 s8,103ec:05a53c23 s10,103d8:07b53023 sd s6,56(A0)103e4:05753023 sd s7,64(A0)103e4:05953823 sd s80(A0)103ec:05a53c23 sd s10,103f0:07b53023 sd s6,64(A0)103e4:05953823 sd s8,72(A0)103e8:05953823 sd s80(A0)103ec:05a53c23 sd s88。104(A0)103f8:B920FSD fs0,112(A0)103fa:bd24 FSD fs1,120(A0)103fc:09253027 FSD fs2,128(A0)10400:09353427 FSD fs3,136(A0)10404:09453827 FSD fs4,144(A0)10408:09553c27 FSD fs5,152(A0)1040C:0b653027 FSD fs6,160(A0)10410:0b753427 FSD fs7,176(A0)10414:0b853827 FSD fs8,168(A0)10418:0b853827 FSD fs8,1041c:0da53027 FSD fs10,192(A0)10420:db053427 fsd 11,200(A0)10424:10424:504ret(A0)10414:0b853827 FSD fs8,168(A0)10418:0b853827 FSD fs8,1041c:0da53027 FSD fs10,192(A0)10420:db053427 fs11,200(A0)10424:504ret

下面是RISC-V汇编中setjmp的实现。在深入研究之前,了解RISC-V架构中的寄存器非常重要。由于我们使用的是64位机器,因此32个通用寄存器中的每个都是64位宽。虽然每个寄存器都被归类为通用寄存器,但是大多数编译器都会遵循一些调用约定。

除了寄存器之外,我们还必须了解setjmp使用的几条伪指令。

SD:存储双字(将第一个操作数指定的寄存器中的值存储到第二个操作数指定的地址中)。

LI:立即加载(将第二个操作数直接加载到第一个操作数指定的寄存器中)。

如果您有兴趣查看编写RISC-V汇编时可用的所有指令,请参阅程序员手册。

那么我们到底在这里做什么呢?Setjmp的行为被指定为将有关调用函数的环境的信息存储到缓冲区中。如果回顾minimal.c的源代码,您将看到我们正在将jmp_buf类型的缓冲区传递给a()中的setjmp函数。如果我们查看a()的转储,您可以看到我们正在跳转并链接(JAL)到setjmp函数的地址:

0000000000010174<;a>;:10174:1141 addi sp,sp,-16 10176:e406 SD ra,8(Sp)10178:e022 SD s0,0(Sp)1017a:0800 addi s0,sp,16 1017c:67c9 lui a5,0x12 1017e:7f078513 addi a0,a5,2032#127f0<;__errno+0x1a>;__errno+0x1a>;10182:238000ef JAL ra,103ba<;put>;10186:70018513 addi a0,103ba<,1792#14830<;UF>;1018a:23a000ef Jra,103c4<;setjmp>;1018e:87a5,103a>;10182:238000ef JAL ra,103ba<;put>;10186:70018513 addi a0,103c4<;1018a:23a000ef jra,103c4<;setjmp>;1018a:87a5,10190:103ba<;put>;10186:70018513 addi a0,103c4<;A+0x2a>;10192:67cd lui a5,0x13 10194:80078513 addi a0,a5,-2048#12800<;__errno+0x2a>;10198:222000ef JAL ra,103ba<;Put>;1019c:a019 j 101a2<;a+0x2e>;a+0x2e>;1019e:fbbff0ef JAL ra,10158<;b>;101a2:0001 NOP 101a4:60a2 ld,8(Sp)101a6:6402 ld s0,0(Sp)101a8:0141 addi sp,sp,16 101aa:8082 ret,10158<;b>;101a2:0001 NOP 101a4:60a2 ld,8(Sp)101a6:6402 ld s0,0(Sp)101a8:0141 addi sp,sp,16 101aa:8082 ret。

跳转和链接只是意味着在我们跳转到完成执行之后,它将返回到它被调用的地方(这是通过将当前地址存储到寄存器x1(Ra),即返回地址寄存器来实现的)。当我们跳到setjmp时,它将把有关环境的信息保存到buf中,稍后调用long jmp时将使用buf来恢复环境。

它看起来很像setjmp,但是我们现在不是存储到buf中,而是重新加载到寄存器中。我们的返回地址将被设置为10198(buf中的第一个条目,0(A0)),这是我们最初调用setjmp的a()中的点。类似地,我们的堆栈指针(Sp)和帧指针(S0)将指向与我们最初调用setjmp时相同的地址。

注意:重置框架和堆栈指针可能会导致令人惊讶的行为。想一想,在返回到内部执行之前,从调用setjmp的函数返回可能会导致堆栈上出现错误。当函数返回时,堆栈指针恢复为帧指针,这意味着堆栈上存储的值可以被覆盖。事实上,我们的最小示例容易受到此行为的影响,但它不会出现,因为我们的两个函数的行为不依赖于堆栈。

最后三条指令再次实现了Linux手册页中的部分规范:

这个“假”返回可以与真正的setjmp()调用区分开来,因为“假”返回返回val中提供的值。如果程序员错误地将值0传递给val,则“假”返回将返回1。

SEQZ:如果等于零则设置(如果第二个操作数等于0,则将第一个操作数的值设置为1,否则设置为0)。

它们有效地协同工作,以确保long jmp要么返回传递给它的int(在寄存器A1中),要么返回1。如果a1等于0,则seqz会将a0设置为1,然后add会将a0(1)和a1(0)相加,并将结果(1)存储在a0中。如果a1不等于0,则seqz会将a0设置为0,然后add会将a0(0)和a1(传递的值)相加,并将结果(传递的值)存储在a0中。然后,我们将返回到ra中指定的地址,该地址是我们从buf恢复的。

非本地跳转不太可能在大多数项目中广泛使用。但是,他们在演示高级编程概念如何转换为机器代码方面做了适当的工作。它们还介绍了如何实现并发的初始概念。事实上,setjmp和long jmp已经用于实现基本的协程。