5.5 Stack

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

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

每一次我们调用一个函数,函数都会为自己创建一个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。这就是一个汇编函数通常的样子。

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

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

而另一个函数sum_then_double就不是一个leaf函数了,这里你可以看到它调用了sum_to。

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

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

之后就是调用sum_to并对结果乘以2。最后是Epilogue,

这里首先将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。

我们可以看到现在的ra寄存器是0x80006392,它指向demo2函数,也就是sum_then_double的调用函数。之后我们执行代码,调用了sum_to。

我们可以看到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代码。

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

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

  • Stack level 0,表明这是调用栈的最底层

  • pc,当前的程序计数器

  • saved pc,demo4的位置,表明当前函数要返回的位置

  • source language c,表明这是C代码

  • Arglist at,表明参数的起始地址。当前的参数都在寄存器中,可以看到argc=3,argv是一个地址

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

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

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

学生提问:为什么有的时候编译器会优化掉argc或者argv?这个以前发生过。

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

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

Last updated