> For the complete documentation index, see [llms.txt](https://mit-public-courses-cn-translatio.gitbook.io/mit6-s081/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://mit-public-courses-cn-translatio.gitbook.io/mit6-s081/lec05-calling-conventions-and-stack-frames-risc-v/5.5-stack.md).

# 5.5 Stack

接下来我们讨论一下栈，stack。栈之所以很重要的原因是，它使得我们的函数变得有组织，且能够正常返回。

下面是一个非常简单的栈的结构图，其中每一个区域都是一个Stack Frame，每执行一次函数调用就会产生一个Stack Frame。

![](/files/-MM4D2J3t3ajqkngxRPC)

每一次我们调用一个函数，函数都会为自己创建一个Stack Frame，并且只给自己用。函数通过移动Stack Pointer来完成Stack Frame的空间分配。

对于Stack来说，是从高地址开始向低地址使用。所以栈总是向下增长。当我们想要创建一个新的Stack Frame的时候，总是对当前的Stack Pointer做减法。一个函数的Stack Frame包含了保存的寄存器，本地变量，并且，如果函数的参数多于8个，额外的参数会出现在Stack中。所以Stack Frame大小并不总是一样，即使在这个图里面看起来是一样大的。不同的函数有不同数量的本地变量，不同的寄存器，所以Stack Frame的大小是不一样的。但是有关Stack Frame有两件事情是确定的：

* Return address总是会出现在Stack Frame的第一位
* 指向前一个Stack Frame的指针也会出现在栈中的固定位置

有关Stack Frame中有两个重要的寄存器，第一个是SP（Stack Pointer），它指向Stack的底部并代表了当前Stack Frame的位置。第二个是FP（Frame Pointer），它指向当前Stack Frame的顶部。因为Return address和指向前一个Stack Frame的的指针都在当前Stack Frame的固定位置，所以可以通过当前的FP寄存器寻址到这两个数据。

我们保存前一个Stack Frame的指针的原因是为了让我们能跳转回去。所以当前函数返回时，我们可以将前一个Frame Pointer存储到FP寄存器中。所以我们使用Frame Pointer来操纵我们的Stack Frames，并确保我们总是指向正确的函数。

Stack Frame必须要被汇编代码创建，所以是编译器生成了汇编代码，进而创建了Stack Frame。所以通常，在汇编代码中，函数的最开始你们可以看到Function prologue，之后是函数的本体，最后是Epilogue。这就是一个汇编函数通常的样子。

![](/files/-MM51g2yien9HyEOP6mn)

我们从汇编代码中来看一下这里的操作。

![](/files/-MM55NSH1I42_YJ8PUnO)

在我们之前的sum\_to函数中，只有函数主体，并没有Stack Frame的内容。它这里能正常工作的原因是它足够简单，并且它是一个leaf函数。leaf函数是指不调用别的函数的函数，它的特别之处在于它不用担心保存自己的Return address或者任何其他的Caller Saved寄存器，因为它不会调用别的函数。

而另一个函数sum\_then\_double就不是一个leaf函数了，这里你可以看到它调用了sum\_to。

![](/files/-MM57BrzXVm-ij0-dwh-)

所以在这个函数中，需要包含prologue。

![](/files/-MM57e54ANIFdXpVubXH)

这里我们对Stack Pointer减16，这样我们为新的Stack Frame创建了16字节的空间。之后我们将Return address保存在Stack Pointer位置。

之后就是调用sum\_to并对结果乘以2。最后是Epilogue，

![](/files/-MM58TOs_9kQtfl4bVPw)

这里首先将Return address加载回ra寄存器，通过对Stack Pointer加16来删除刚刚创建的Stack Frame，最后ret从函数中退出。

这里我替大家问一个问题，如果我们删除掉Prologue和Epilogue，然后只剩下函数主体会发生什么？有人可以猜一下吗？

> 学生回答：sum\_then\_double将不知道它应该返回的Return address。所以调用sum\_to的时候，Return address被覆盖了，最终sum\_to函数不能返回到它原本的调用位置。

是的，完全正确，我们可以看一下具体会发生什么。先在修改过的sum\_then\_double设置断点，然后执行sum\_then\_double。

![](/files/-MM5FKgM14gj9VrgebcG)

我们可以看到现在的ra寄存器是0x80006392，它指向demo2函数，也就是sum\_then\_double的调用函数。之后我们执行代码，调用了sum\_to。

![](/files/-MM5Fs1G_y-4BlwL3RXf)

我们可以看到ra寄存器的值被sum\_to重写成了0x800065f4，指向sum\_then\_double，这也合理，符合我们的预期。我们在函数sum\_then\_double中调用了sum\_to，那么sum\_to就应该要返回到sum\_then\_double。

之后执行代码直到sum\_then\_double返回。如前面那位同学说的，因为没有恢复sum\_then\_double自己的Return address，现在的Return address仍然是sum\_to对应的值，现在我们就会进入到一个无限循环中。

我认为这是一个很好的例子用来展示为什么跟踪Caller和Callee寄存器是重要的。

> 学生提问，为什在最开始要对sp寄存器减16？
>
> TA：是为了Stack Frame创建空间。减16相当于内存地址向前移16，这样对于我们自己的Stack Frame就有了空间，我们可以在那个空间存数据。我们并不想覆盖原来在Stack Pointer位置的数据。
>
> 学生提问：为什么不减4呢？
>
> TA：我认为我们不需要减16那么多，但是4个也太少了，你至少需要减8，因为接下来要存的ra寄存器是64bit（8字节）。这里的习惯是用16字节，因为我们要存Return address和指向上一个Stack Frame的地址，只不过我们这里没有存指向上一个Stack Frame的地址。如果你看kernel.asm，你可以发现16个字节通常就是编译器的给的值。

接下来我们来看一些C代码。

![](/files/-MM8AM8rqcOaYozyza3b)

demo4函数里面调用了dummymain函数。我们在dummymain函数中设置一个断点，

![](/files/-MM8B3-AbW3Onhnzb77l)

现在我们在dummymain函数中。如果我们在gdb中输入info frame，可以看到有关当前Stack Frame许多有用的信息。

![](/files/-MM8BtVIjSM5xZ3lL4fn)

* Stack level 0，表明这是调用栈的最底层
* pc，当前的程序计数器
* saved pc，demo4的位置，表明当前函数要返回的位置
* source language c，表明这是C代码
* Arglist at，表明参数的起始地址。当前的参数都在寄存器中，可以看到argc=3，argv是一个地址

如果输入backtrace（简写bt）可以看到从当前调用栈开始的所有Stack Frame。

![](/files/-MM8FCpKDwxRqZQDjua0)

如果对某一个Stack Frame感兴趣，可以先定位到那个frame再输入info frame，假设对syscall的Stack Frame感兴趣。

![](/files/-MM8GApvnQ0OqMJGrX_2)

在这个Stack Frame中有更多的信息，有一堆的Saved Registers，有一些本地变量等等。这些信息对于调试代码来说超级重要。

> 学生提问：为什么有的时候编译器会优化掉argc或者argv？这个以前发生过。
>
> TA：这意味着编译器发现了一种更有效的方法，不使用这些变量，而是通过寄存器来完成所有的操作。如果一个变量不是百分百必要的话，这种优化还是很有常见的。我们并没有给你编译器的控制能力，但是在你们的日常使用中，你可以尝试设置编译器的optimization flag为0，不过就算这样，编译器也会做某些程度的优化。

（1:04:08 - 1:09:46 在介绍一些gdb技巧，conditional breakpoint，watchpoint等，与课程内容无关，故跳过）
