递归斐波那契 MIPS

发布于 2025-01-18 04:42:14 字数 843 浏览 1 评论 0原文

我开始阅读MIPS,以更好地了解我的C ++和C代码在计算机皮肤下的工作方式。我从递归函数(斐波那契函数)开始。 C代码为:

int fib(int n) {
if(n == 0) { return 0; }
if(n == 1) { return 1; }
return (fib(n - 1) + fib(n - 2));
}

MIPS代码:

fib:
addi $sp, $sp, -12 
sw $ra, 8($sp) 
sw $s0, 4($sp)

addi $v0, $zero, $zero
beq $a0, $zero, end
addiu $v0, $zero, 1
addiu $t0, $zero, 1 
beq $a0, $t0, end
addiu $a0, $a0, -1
sw $a0, 0($sp) 
jal fib #fib(n-1)
addi $s0, $v0, $zero 
lw $a0, 0($sp) 
addiu $a0, $a0, -1
jal fib #fib(n-2)
add $v0, $v0, $s0

end:
lw $s0, 4($sp)
lw $ra, 8($sp) 
addi $sp, $sp, 12
jr $ra

n> 1时,直到代码达到第一个JAL指令。接下来会发生什么?它返回到fib标签忽略下面的代码(fib(n-2)呼叫永远不会执行吗?)?如果发生这种情况,则$ sp指针再次减小3个单词,并且周期将进行直到n< = 1。当第一次JAL达到指令时,我无法理解它的工作原理。

I started to read MIPS to understand better how my C++ and C code works under the computer skin. I started with a recursive function, a Fibonacci function.
The C code is:

int fib(int n) {
if(n == 0) { return 0; }
if(n == 1) { return 1; }
return (fib(n - 1) + fib(n - 2));
}

MIPS code:

fib:
addi $sp, $sp, -12 
sw $ra, 8($sp) 
sw $s0, 4($sp)

addi $v0, $zero, $zero
beq $a0, $zero, end
addiu $v0, $zero, 1
addiu $t0, $zero, 1 
beq $a0, $t0, end
addiu $a0, $a0, -1
sw $a0, 0($sp) 
jal fib #fib(n-1)
addi $s0, $v0, $zero 
lw $a0, 0($sp) 
addiu $a0, $a0, -1
jal fib #fib(n-2)
add $v0, $v0, $s0

end:
lw $s0, 4($sp)
lw $ra, 8($sp) 
addi $sp, $sp, 12
jr $ra

When n>1 it goes until the code reaches the first jal instruction. What happens next? it return to fib label ignoring the code below (the fib(n-2) call will never be executed?)? If that happens, the $sp pointer decreases 3 words again and the cycle will go until n<=1. I can't understand how this works when first jal instruction is reached.

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。

评论(1

怪我太投入 2025-01-25 04:42:14

你能理解 C 中的递归是如何工作的吗?

从某种意义上说,递归有两个组成部分:前向部分和后向部分。在前向部分中,递归算法计算递归之前的事物,而在后向部分中,递归算法在递归完成后计算事物。在这两个部分之间,有递归。

请参阅此答案: https://stackoverflow.com/a/71551098/471129

斐波那契的执行稍微复杂一些递归两次,而不是像上面的列表打印示例中那样只递归一次。

但是,原理是相同的:有递归之前完成的工作,也有递归之后完成的工作(两者都可以退化)。之前的部分发生在递归执行之前的代码,并且递归构建堆栈帧,这些堆栈帧是递归尚未完成之后工作的占位符。之后的部分发生在堆栈帧被释放并且递归调用之后的代码被执行时。

在任何给定的调用链中,前向部分一直持续到 n 为 0 或 1,然后算法开始返回到堆栈调用者,对于这些调用者,后向部分将进入展开堆栈帧,直到它返回到原始调用者(可能是 main),而不是某些递归 fib 调用者。同样,由于使用两次递归调用而不是像更简单的示例中的一次,因此变得复杂。

对于 fib,之前完成的工作是倒计数(按 -1 或 -2)直到达到 0 或 1。递归之后所做的工作就是将之前的两个结果相加。递归本身有效地暂停了对当前值的 fib 的调用或激活,以便在递归调用完成时恢复。


MIPS算法中的递归是相同的;但是,函数操作分布在 C 中隐含的多个机器代码指令上。


建议单步执行对 fib(2) 的调用,作为一个非常小的示例,可以帮助您了解那里发生了什么. 建议首先在 C 中执行此操作 - 单步执行,直到外部 fib 调用完全完成并返回到调用测试函数(例如 main)。

为了使 C 版本更容易在调试器中查看,您可以使用此版本:

int fib(int n) {
    if (n == 0) { return 0; }
    if (n == 1) { return 1; }
    int fm1 = fib(n-1);
    int fm2 = fib(n-2);
    int result = fm1 + fm2;
    return result;
}

使用等效的 C 版本,您将能够检查 fm1fm2 ,以及单步执行期间的结果。这将使它更容易遵循。

接下来,在汇编版本中执行相同的操作。调试单步以观察 fib(2) 的执行,并与 C 中的等效项进行比较。


还有另一种思考递归的方法,即忽略递归,假装递归调用是对某些不相关的函数实现恰好产生了递归函数的正确结果;这是这样一个非递归函数:

int fib(int n) {
    if (n == 0) { return 0; }
    if (n == 1) { return 1; }
    int fm1 = fibX(n-1);   // calls something else that computes fib(n-1)
    int fm2 = fibX(n-2);   //   "
    int result = fm1 + fm2;
    return result;
}

使用此代码,并假设 fibX 简单地正常工作以返回正确的结果,您可以严格专注于一个级别的逻辑,即,这个fib的主体,根本不考虑递归。

请注意,我们可以在汇编语言中执行相同的操作 - 尽管错误/拼写错误的机会总是比 C 语言中的大得多,因为您仍然必须操作堆栈帧并保留关键存储以供调用后稍后使用。


您发布的代码存在转录错误,使其与 C 版本不同。它所做的相当于 C 语言:

return fib(n-1) + fib(n-1);

让我们回到这个更明确的版本:

int fib(int n) {                  // n defined upon entry
    if (n == 0) { return 0; }
    if (n == 1) { return 1; }
    int fm1 = fib(n-1);           // fm1 defined
    int fm2 = fib(n-2);           // n used to compute n-2
    int result = fm1 + fm2;       // fm1 used
    return result;
}

对于汇编语言,必须执行变量分析,以便我们使用正确的寄存器和堆栈帧内存。

首先,让我们观察一下 n 在函数调用中是“活动”的,即 fib(n-1) 的函数调用。这是一个重要的属性。函数调用会清除某些寄存器,因此保留在函数调用之前定义并在函数调用之后使用的内容非常重要。这里 n 的值被定义为参数,因此可以认为在输入时就已经定义了。它用于执行 fib(n-1),这并不重要,但它也用于执行 fib(n-2) — 计算 n-2 并且该计算在调用 fib(n-1) 之后进行。因此,n 需要特殊的存储空间,以便在第一次函数调用后仍能保存下来。

函数调用中还有哪些活动?它是 fm1,因为它在调用 fib(n-2) 之前定义并在调用之后使用。因此,fm1 也需要函数存活存储。

还有什么?我们在 C 中看不到它,但在汇编中,返回地址作为附加函数参数向我们公开,并且像 n 一样,它被认为是在函数入口处定义的。该传入参数值(返回地址)必须在两次函数调用中都存在,因为它在最后用于完成返回操作。

无论是使用具有显式变量的版本还是使用 fib(n-1) + fib(n-2) 中隐式内部临时变量的简单版本,这种活性/存储分析都是必要的。

让我们进一步注意,此分析对于 fibX 非递归版本是相同的,因为寄存器保存的所有要求都来自函数调用,而与递归无关。 (如果我们知道调用者和被调用者的实现,则有机会优化调用约定,但我们选择在这些学习场景中不使用这些)。

因此,我们需要做的是创建一些堆栈空间供函数使用,并在该堆栈空间中存储$ra(即返回地址)和$a0 code>,即 n,并且还为变量 fm1(或该版本中的临时变量)提供空间。稍后,fm1 在定义后需要存储到内存中,而 n 需要从内存中重新加载才能计算 n-2 ,用于下一次递归调用。在第二次递归调用之后,可以重新加载fm1,计算返回值,最后重新加载返回地址,释放堆栈帧,函数可以返回到其调用者。

我们还要注意,相比之下,fm2result 相对简单,两者都不需要在函数调用后继续存在,因此它们可以使用调用破坏寄存器或从它们所在的寄存器中使用出现。

Can you follow how the recursion works in C?

In some sense, recursion has two components: the forward part and the backward part.  In the forward part, a recursive algorithm computes things before the recursion, and in the backward part, a recursive algorithm computes things after the recursion completes.  In between the two parts, there is the recursion.

See this answer: https://stackoverflow.com/a/71551098/471129

Fibonacci is just slightly more complicated as it performs recursion twice, not just once as in the above list printing example.

However, the principles are the same:  There is work done before the recursion, and work done after (either of which can be degenerate).  The before part happens as code in front of the recursion executes, and the recursion builds up stack frames that are placeholders for work after the recursion yet to be completed.  The after part happens as the stack frames are released and the code after the recursive call is executed.

In any given call chain, the forward part goes until n is 0 or 1, then the algorithm starts returning back to the stacked callers, for whom the backward part kicks in unwinding stack frames until it returns to the original caller (perhaps main) rather than to some recursive fib caller.  Again, complicated by use of two recursive invocations rather than one as in simpler examples.

With fib, the work done before is to count down (by -1 or -2) until reaching 0 or 1.  The work done after the recursion is to sum the two prior results.  The recursion itself effectively suspends an invocation or activation of fib with current values, to be resumed when a recursive call completes.


Recursion in MIPS algorithm is the same; however, function operations are spread out over several machine code instructions that are implicit in C.


Suggest single stepping over a call to fib(2) as a very small example that may help you see what's going on there.  Suggest first doing this in C — single step until the outer fib call has full completed and returned to the calling test function (e.g. main).

To make the C version just a bit easier to view in the debugger you might use this version:

int fib(int n) {
    if (n == 0) { return 0; }
    if (n == 1) { return 1; }
    int fm1 = fib(n-1);
    int fm2 = fib(n-2);
    int result = fm1 + fm2;
    return result;
}

With that equivalent C version, you'll be able to inspect fm1, fm2, and result during single stepping.  That will make it easier to follow.

Next, do the same in the assembly version.  Debug single step to watch execution of fib(2), and draw parallels with the equivalents in C.


There's another way to think about recursion, which is ignore the recursion, pretending that the recursive call is to some unrelated function implementation that just happens to yield the proper results of the recursive function; here's such a non-recursive function:

int fib(int n) {
    if (n == 0) { return 0; }
    if (n == 1) { return 1; }
    int fm1 = fibX(n-1);   // calls something else that computes fib(n-1)
    int fm2 = fibX(n-2);   //   "
    int result = fm1 + fm2;
    return result;
}

With this code, and the assumption that fibX simply works correctly to return proper results, you can focus strictly on the logic of one level, namely, the body of this fib, without considering the recursion at all.

Note that we can do the same in assembly language — though the opportunities for errors / typos are always much larger than in the C, since you still have to manipulate stack frames and preserve critical storage for later use after the calling.


The code you've posted has a transcription error, making it different from the C version.  It is doing the C equivalent of:

return fib(n-1) + fib(n-1);

Let's go back to this somewhat more explicit version:

int fib(int n) {                  // n defined upon entry
    if (n == 0) { return 0; }
    if (n == 1) { return 1; }
    int fm1 = fib(n-1);           // fm1 defined
    int fm2 = fib(n-2);           // n used to compute n-2
    int result = fm1 + fm2;       // fm1 used
    return result;
}

For assembly language, an analysis of the variables must be performed so that we use the proper registers and stack frame memory.

First, let's observe that n is "live" across a function call, namely the function call of fib(n-1).  This is an important property.  Function calling wipes out certain registers and so it is important to preserve things that are defined before a function call and used after.  Here the value of n is defined as a parameter, so can be thought of as being defined already upon entry.  It is used in doing fib(n-1) and that is not of concern, however it is also used doing fib(n-2) — to compute n-2 and that computation takes place after the call to fib(n-1).  So, n needs special storage that will survive the first function call.

What else is live across a function call?  It is fm1, since it defined before the call to fib(n-2) and used after.  So, fm1 also needs function-surviving storage.

And yet what else?  We don't see it in C but in assembly the return address is exposed to us as an additional function parameter, and like n it is considered defined upon function entry.  That incoming parameter value (the return address) must survive both function calls, as it is used at the end to accomplish the return operation.

This liveness/storage analysis is necessary whether using the version with explicit variables or the simpler version that uses internal temporary variables implicit in fib(n-1) + fib(n-2).

Let's further note that this analysis is identical for the fibX non-recursive version, as all the requirements of register preservation come from function calling regardless of recursion.  (there are opportunities for optimization of the calling convention if we know the implementation of both caller and callee, but we choose not to employ those in these learning scenarios).

So, what we need to do is create some stack space for the function to use, and in that stack space, store $ra, which is the return address, and store $a0, which is n, and also provide room for variable fm1 (or the temporary in that version).  Later, fm1 needs to be stored to memory after it is defined, while n needs to be reloaded from memory in order to compute n-2, for the next recursive call.  After the 2nd recursive call, fm1 can be reloaded, the return value computed, and finally the return address is reloaded, stack frame is released, and the function can return to its caller.

Let's also note that fm2 and result are relatively simple in comparison, neither needs to survive a function call, so these can use call clobbered registers or be used from the register where they appear.

~没有更多了~
我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
原文