函数调用中的栈帧

栈帧

我们已经习以为常函数的调用,但越是习以为常的事情越是基础,越是重点。函数调用最关键可谓参数的传递、返回控制及返回值问题,这些问题则都是通过栈来进行管理的,而每次函数调用都会有一个栈单元产生称为栈帧。

当一个函数被调用时,将会创建一个栈帧(stack frame)去支持函数的运行。这个栈帧包含函数的局部变量和调用者传递给它的参数。这个栈帧也包含了允许被调用的函数(callee)安全返回给其调用者的内部事务信息。栈帧的精确内容和结构因处理器架构和函数调用规则而不同。在本文中我们以Intel x86架构和使用C风格的函数调用(cdecl)的栈为例。下图是一个处于栈顶部的一个单个栈帧:
栈帧结构

其中关系到CPU中的三个寄存器:

  • 栈指针esp(extend stack pointer):始终指向到栈的顶部。
  • 栈基址指针ebp(extend base stack pointer):始终指向当前运行函数栈帧内的固定位置,这个固定位置存储了上一个函数栈帧的基址。ebp为参数和局部变量的访问提供一个稳定的参考点(基址)。
  • 通用寄存器eax(extend ax):通常被用来转换大多数C数据类型返回值给调用者。

栈帧中的数据

栈帧中的数据

局部变量local_buffer是一个字节数组,包含一个由null终止的ASCII字符串,这是C程序中的一个基本元素。这个字符串可以读取自任意地方,例如,从键盘输入或者来自一个文件,它只有 7 个字节的长度。因为,local_buffer只能保存8字节,所以还剩下1个未使用的字节。这个字节的内容是未知的,因为栈不断地推入和弹出,除了你写入的之外,你根本不会知道内存中保存了什么。这是因为 C 编译器并不为栈帧初始化内存,所以它的内容是未知的并且是随机的——除非是你自己写入。这使得一些人对此很困惑。【这部分什么作用????】

local1是一个4字节的整数,并且可以看到每个字节的内容。由于X86小端结构(高高低低),而且栈向低地址增长,所以可以看出,里面存储的数据为数字8。

相应的,param1第2个字节位置为数字2,因此,param1所表示的值为2 * 256 = 512(一个字节表示256个数);同时,param2所表示的值为1 * 256 * 256 = 65536

saved ebpreturn address为内部事务所需要的数据,这两者共同保证了函数的正常返回,其中:

  • saved ebp为前一个栈帧的ebp地址,而此时CPU的ebp寄存器指向当前位置。
  • return address为函数退出后所要运行指令的地址(返回地址)。

栈帧示例

一个简单的C程序如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>
// Simple Add Program - add.c
int add(int a, int b)
{
int result = a + b;
return result;
}
int main()
{
int answer;
answer = add(40, 2);
return 0;
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
(gdb) echo # call main\n
# call main
(gdb) disas
Dump of assembler code for function main:
=> 0x00000000004004e7 <+0>: push %rbp
0x00000000004004e8 <+1>: mov %rsp,%rbp
0x00000000004004eb <+4>: sub $0x10,%rsp
0x00000000004004ef <+8>: mov $0x2,%esi
0x00000000004004f4 <+13>: mov $0x28,%edi
0x00000000004004f9 <+18>: callq 0x4004cd <add>
0x00000000004004fe <+23>: mov %eax,-0x4(%rbp)
0x0000000000400501 <+26>: mov $0x0,%eax
0x0000000000400506 <+31>: leaveq
0x0000000000400507 <+32>: retq
End of assembler dump.

和文章中的程序存在差异,先按照文章中的理解吧:

  • ebp改成了rbpesp改成了rsp
  • 变量较少时,所传递的值保存在CPU寄存器esiedi

运行main函数

第 2 步和第 3 步,以及下面的第 4 步,都只是函数的序言prologue,几乎所有的函数都是这样的:ebp的当前值被保存到了栈的顶部,然后将esp的内容拷贝到ebp,以建立一个新的栈帧。main的序言和其它函数一样,但是,不同之处在于,当程序启动时ebp被清零。

如果你去检查栈下方(右边)的整形变量(argc),你将找到更多的数据,包括指向到程序名和命令行参数(传统的C的argv)、以及指向Unix环境变量以及它们真实的内容的指针。但是,在这里这些并不是重点,因此,继续向前调用add()
调用add函数
mainesp减去12之后得到它所需的栈空间,它为ab设置值。在内存中的值展示为十六进制,并且是小端格式,与你从调试器中看到的一样。一旦设置了参数值,main将调用add,并且开始运行:

整合prolog部分

接下来我们就进入了另一个函数序言,但这次可以明确看到栈帧是如何从ebp到栈建立一个链表。这就是调试器和高级语言中的Exception对象如何对它们的栈进行跟踪的。当一个新帧产生时,更多这种典型的从ebpesp的捕获。我们再次从esp中做减法得到更多的栈空间。

ebp寄存器的值拷贝到内存时,这里也有一个稍微有些怪异的字节逆转。在这里发生的奇怪事情是,寄存器其实并没有字节顺序:因为对于内存,没有像寄存器那样的“增长的地址”。因此,惯例上调试器以对人类来说最自然的格式展示了寄存器的值:数位从最重要的到最不重要。因此,这个在小端机器中的副本的结果,与内存中常用的从左到右的标记法正好相反。我想用图去展示你将会看到的东西,因此有了下面的图。

在比较难懂的部分,我们增加了注释:

这是一个临时寄存器,用于帮你做加法,因此没有什么警报或者惊喜。对于加法这样的作业,栈的动作正好相反。

REFERENCE

  1. https://mp.weixin.qq.com/s/E7nBPmwmpP-XO9ern6T_AQ
  2. https://manybutfinite.com/post/journey-to-the-stack/
Brick wechat
扫一扫,用手机看更方便(^ ◕ᴥ◕ ^)