函数栈帧的创建和销毁讲解

简介: 函数栈帧的创建和销毁讲解

1.什么是函数栈帧?


函数栈帧就是函数调用过程中在程序的调用栈里开辟的空间,这些空间是用来存放:

(1).函数参数和函数返回值。

(2).临时变量(包括函数的非静态的局部变量以及编译器自动生产的其他临时变量)。

(3).保存上下文信息(包括在函数调用前后需要保持不变的寄存器)。


2.理解函数栈帧能够解决什么问题?


理解函数栈帧有什么用呢?

(1).局部变量是如何创建的?

(2).为什么局部变量不初始化值是随机的?

(3).函数调用时参数是如何传递的?传参的顺序是怎么样的?

(4).函数的实参和形参是什么关系?

(5).函数的返回值是如何带回的?


3.函数栈帧的创建和销毁解析


3.1.   什么是栈

栈(stack)是现代计算机程序里最为重要的概念之一,几乎每一个程序都使用了栈,没有栈就没有函 数,没有局部变量,也就没有我们如今看到的所有的计算机语言。

在经典的计算机科学中,栈被定义为一种特殊的容器,用户可以将数据压入栈中(入栈push),也可以将已经压入栈中的数据弹出(出栈,pop),但是栈这个容器必须遵守一条规则:  先入栈的数据后出栈(First In Last Out, FIFO)。就像叠成一叠的术,先叠上去的书在最下面,因此要最后才能取出。 在计算机系统中,栈则是一个具有以上属性的动态内存区域。程序可以将数据压入栈中,也可以将数据 从栈顶弹出。压栈操作使得栈增大,而弹出操作使得栈减小。 在经典的操作系统中,栈总是向下增长(由高地址向低地址)的。 在我们常见的i386或者 x86-64 下,栈顶由成为 esp 的寄存器进行定位的。


3.2  认识相关寄存器和汇编指令

相关寄存器

eax :通用寄存器,保留临时数据,常用于返回值

ebx :通用寄存器,保留临时数据

ebp :栈底寄存器

esp :栈顶寄存器

eip :指令寄存器,保存当前指令的下一条指令的地址

相关汇编语言

mov  :数据转移指令

push :数据入栈,同时 esp 栈顶寄存器也要发生改变

pop   :数据弹出至指定位置,同时 esp 栈顶寄存器也要发生改变

sub   :减法命令

add   :加法命令

call    :函数调用, 1 . 压入返回地址 2. 转入目标函数

jump :通过修改 eip ,转入目标函数,进行调用

ret     :恢复返回地址,压入 eip ,类似 pop eip 命令


3.3  解析函数栈帧的创建和销毁

3.3.1 预备知识

首先我们要先知道以下两点知识有助于我们理解函数栈帧:

(1).每一次函数调用,都要为本次函数调用开辟空间,就是函数栈帧的空间。

 (2)   .这块空间的维护是使用了2个寄存器: esp ebp ebp 记录的是栈底的地址, esp 记录的是栈顶的地址。

如图所示:

9a6071397dbe4557b36db54c8869b065.png

另外,函数栈帧的创建和销毁过程,在不同的编译器上实现的方法会有部分差异。


3.3.2 函数的调用堆栈

代码:

#include <stdio.h>
int Add(int x, int y)
{
int z = 0;
z = x + y;
return z;
}
int main()
{
int a = 3;
int b = 5;
int ret = 0;
ret = Add(a, b);
printf("%d\n", ret);
return 0;
}


这段代码,如果我们在 VS2019 编译器上调试,调试进入 Add 函数后,我们就可以观察到函数的调用堆栈

(右击勾选【显示外部代码】),如下图:

9aa66d06490541a2a68736d675115e8d.png

函数调用堆栈是反馈函数调用逻辑的,那我们可以清晰的观察到, main 函数调用之前,是由

invoke_main 函数来调用 main 函数。

在 invoke_main 函数之前的函数调用我们就暂时不考虑了。

那我们可以确定, invoke_main 函数应该会有自己的栈帧, main 函数和 Add 函数也会维护自己的栈 帧,每个函数栈帧都有自己的 ebp 和 esp 来维护栈帧空间。

那接下来我们从 main 函数的栈帧创建开始讲解:


3.3.3 转到反汇编

调试到 main 函数开始执行的第一行,右击鼠标转到反汇编

int main()
{
//函数栈帧的创建
00BE1820 push ebp
00BE1821 mov ebp,esp
00BE1823 sub esp,0E4h
00BE1829 push ebx
00BE182A push esi
00BE182B push edi
00BE182C lea edi,[ebp-24h]
00BE182F mov ecx,9
00BE1834 mov eax,0CCCCCCCCh
00BE1839 rep stos dword ptr es:[edi]
//main函数中的核心代码
int a = 3;
00BE183B mov dword ptr [ebp-8],3
int b = 5;
00BE1842 mov dword ptr [ebp-14h],5
int ret = 0;
00BE1849 mov dword ptr [ebp-20h],0
ret = Add(a, b);
00BE1850 mov eax,dword ptr [ebp-14h]
00BE1853 push eax
00BE1854 mov ecx,dword ptr [ebp-8]
00BE1857 push ecx
00BE1858 call 00BE10B4
00BE185D add esp,8
00BE1860 mov dword ptr [ebp-20h],eax
printf("%d\n", ret);
00BE1863 mov eax,dword ptr [ebp-20h]
00BE1866 push eax
00BE1867 push 0BE7B30h
00BE186C call 00BE10D2
00BE1871 add esp,8
return 0;
00BE1874 xor eax,eax
}


3.3.4 函数栈帧的创建

上图就是我们的main函数转化来的汇编代码

下面就让我们来拆解:

00 BE1820 push ebp // 把 ebp 寄存器中的值进行压栈,此时的 ebp 中存放的是

invoke_main 函数栈帧的 ebp , esp-4

00BE1821 mov ebp, esp //move 指令会把 esp 的值存放到 ebp 中,相当于产生了 main 函数的

ebp ,这个值就是 invoke_main 函数栈帧的 esp 00 BE1823 sub esp , 0E4 h //sub 会让 esp 中的地址减去一个 16 进制数字 0xe4, 产生新的

esp ,此时的 esp 是 main 函数栈帧的 esp ,此时结合上一条指令的 ebp 和当前的 esp , ebp 和 esp 之间维护了一

个块栈空间,这块栈空间就是为 main 函数开辟的,就是 main 函数的栈帧空间,这一段空间中将存储 main 函数

中的局部变量,临时数据已经调试信息等。

00BE1829 push ebx // 将寄存器 ebx 的值压栈, esp-4

00BE182A push esi // 将寄存器 esi 的值压栈, esp-4

00 BE182B push edi // 将寄存器 edi 的值压栈, esp-4

// 上面 3 条指令保存了 3 个寄存器的值在栈区,这 3 个寄存器的在函数随后执行中可能会被修改,所以先保存寄

存器原来的值,以便在退出函数时恢复。

// 下面的代码是在初始化 main 函数的栈帧空间。

//1. 先把 ebp-24h 的地址,放在 edi 中

//2. 把 9 放在 ecx 中

//3. 把 0xCCCCCCCC 放在 eax 中

//4. 将从 edp-0x2h 到 ebp 这一段的内存的每个字节都初始化为 0xCC

00BE182C lea edi,[ebp-24h]

00BE182F mov ecx,9

00BE1834 mov eax,0CCCCCCCCh

00BE1839 rep stos dword ptr es:[edi]

上面的这段代码最后 4 句,等价于下面的伪代码:

edi = ebp-0x24;

ecx = 9;

eax = 0xCCCCCCCC;

for(; ecx = 0; --ecx,edi+=4)

{

*(int*)edi = eax;

}

5032626a638542ac925e57f7a06e1d4d.png

烫烫烫 ~

4f485be9519f4938b2e2b6b324e99b76.png

之所以上面的程序输出 “ 烫 ” 这么一个奇怪的字,是因为 main 函数调用时,在栈区开辟的空间的其中每一 个字节都被初始化为0xCC ,而 arr 数组是一个未初始化的数组,恰好在这块空间上创建的, 0xCCCC (两 个连续排列的0xCC )的汉字编码就是 “ 烫 ” ,所以 0xCCCC 被当作文本就是 “ 烫 ” 。

接下来我们再分析 main 函数中的核心代码:


int a = 3;

00BE183B mov dword ptr [ebp-8],3 // 将 3 存储到 ebp-8 的地址处, ebp-8 的位置其实就

是 a 变量

int b = 5;

00BE1842 mov dword ptr [ebp-14h], 5 // 将 5 存储到 ebp-14h 的地址处, ebp-14h 的位置

其实是 b 变量

int ret = 0;

00BE1849 mov dword ptr [ebp-20h], 0 // 将 0 存储到 ebp-20h 的地址处, ebp-20h 的位

置其实是 ret 变量

// 以上汇编代码表示的变量 a,b,ret 的创建和初始化,这就是局部的变量的创建和初始化

// 其实是局部变量的创建时在局部变量所在函数的栈帧空间中创建的

// 调用 Add 函数

ret = Add(a, b);

// 调用 Add 函数时的传参

// 其实传参就是把参数 push 到栈帧空间中

00BE1850 mov eax,dword ptr [ebp-14h ] // 传递 b ,将 ebp-14h 处放的 5 放在 eax 寄存器

00BE1853 push eax // 将 eax 的值压栈, esp-4

00BE1854 mov ecx,dword ptr [ebp-8 ] // 传递 a ,将 ebp-8 处放的 3 放在 ecx 寄存器中

00BE1857 push ecx // 将 ecx 的值压栈, esp-4

// 跳转调用函数

00BE1858 call 00BE10B4

00BE185D add esp,8

00BE1860 mov dword ptr [ebp-20h], eax

7c6dcb09d37c474c8fe4600785654988.png

Add 函数的传参:

// 调用 Add 函数

ret = Add(a, b);

// 调用 Add 函数时的传参

// 其实传参就是把参数 push 到栈帧空间中,这里就是函数传参

00BE1850 mov eax,dword ptr [ebp-14h] // 传递 b ,将 ebp-14h 处放的 5 放在 eax 寄存器

00BE1853 push eax // 将 eax 的值压栈, esp-4

00BE1854 mov ecx,dword ptr [ebp-8 ] // 传递 a ,将 ebp-8 处放的 3 放在 ecx 寄存器中

00 BE1857 push ecx // 将 ecx 的值压栈, esp-4

// 跳转调用函数

00BE1858 call 00BE10B4

00BE185D add esp,8

00BE1860 mov dword ptr [ebp-20h],eax

39283a086a1f4b38a93a99cf66a8854a.png

函数调用过程:

// 跳转调用函数

00BE1858 call 00BE10B4

00BE185D add esp,8

00BE1860 mov dword ptr [ebp-20h],eax

call指令是要执行函数调用逻辑的,在执行 call 指令之前先会把 call 指令的下一条指令的地址进行压栈操作,这个操作是为了解决当函数调用结束后要回到call 指令的下一条指令的地方,继续往后执行。

e6a8f01e0df7488a9cae12a68bbdee62.png

当我们跳转到 Add 函数,就要开始观察 Add 函数的反汇编代码了。

int Add(int x, int y)

{

00 BE1760 push ebp // 将 main 函数栈帧的 ebp 保存 ,esp-4

00BE1761 mov ebp, esp // 将 main 函数的 esp 赋值给新的 ebp , ebp 现在是 Add 函数的 ebp

00BE1763 sub esp,0 CCh // 给 esp-0xCC ,求出 Add 函数的 esp

00BE1769 push ebx // 将 ebx 的值压栈 ,esp-4

00 BE176A push esi // 将 esi 的值压栈 ,esp-4

00 BE176B push edi // 将 edi 的值压栈 ,esp-4

int z = 0;

00BE176C mov dword ptr [ebp-8], 0 // 将 0 放在 ebp-8 的地址处,其实就是创建 z

z = x + y;

// 接下来计算的是 x+y ,结果保存到 z 中

00BE1773 mov eax,dword ptr [ebp+8 ] // 将 ebp+8 地址处的数字存储到 eax 中

00BE1776 add eax,dword ptr [ebp+0Ch ] // 将 ebp+12 地址处的数字加到 eax 寄存中

00BE1779 mov dword ptr [ebp-8], eax // 将 eax 的结果保存到 ebp-8 的地址处,其实

就是放到 z 中

return z;

00BE177C mov eax,dword ptr [ebp-8 ] // 将 ebp-8 地址处的值放在 eax 中,其实就是

把 z 的值存储到 eax 寄存器中,这里是想通过 eax 寄存器带回计算的结果,做函数的返回值。

}

00BE177F pop edi

00BE1780 pop esi

00BE1781 pop ebx

00BE1782 mov esp,ebp

00BE1784 pop ebp

00BE1785 ret

代码执行到 Add 函数的时候,就要开始创建 Add 函数的栈帧空间了。

在 Add 函数中创建栈帧的方法和在 main 函数中是相似的,在栈帧空间的大小上略有差异而已。

1. 将 main 函数的 ebp 压栈

2. 计算新的 ebp 和 esp

3. 将 ebx , esi , edi 寄存器的值保存

4. 计算求和,在计算求和的时候,我们是通过 ebp 中的地址进行偏移访问到了函数调用前压栈进去的参数,这就是形参访问。

5. 将求出的和放在 eax 寄存器尊准备带回

bca2e45cda3047758ad779141c802eb7.png

图片中的 a' 和 b' 其实就是 Add 函数的形参 x , y 。这里的分析很好的说明了函数的传参过程,以及函数在进行值传递调用的时候,形参其实是实参的一份拷贝。对形参的修改不会影响实参。

3.3.5 函数栈帧的销毁

当函数调用要结束返回的时候,前面创建的函数栈帧也开始销毁。

那具体是怎么销毁的呢?我们看一下反汇编代码。

00 BE177F pop edi // 在栈顶弹出一个值,存放到 edi 中, esp+4

00 BE1780 pop esi // 在栈顶弹出一个值,存放到 esi 中, esp+4

00 BE1781 pop ebx // 在栈顶弹出一个值,存放到 ebx 中, esp+4

00BE1782 mov esp, ebp // 再将 Add 函数的 ebp 的值赋值给 esp ,相当于回收了 Add 函数的栈

帧空间

00 BE1784 pop ebp // 弹出栈顶的值存放到 ebp ,栈顶此时的值恰好就是 main 函数的 ebp ,

esp+4 ,此时恢复了 main 函数的栈帧维护, esp 指向 main 函数栈帧的栈顶, ebp 指向了 main 函数栈帧的栈底。

00 BE1785 ret //ret 指令的执行,首先是从栈顶弹出一个值,此时栈顶的值就是 call 指

令下一条指令的地址,此时 esp+4 ,然后直接跳转到 call 指令下一条指令的地址处,继续往下执行。

回到了 call 指令的下一条指令的地方:

a24615fa3e4d428db3620564778cc832.png

也就是说add函数调用完毕,继续执行mian函数,函数的返回值会存放在寄存器eax中被读取。


4.拓展了解


其实返回对象是内置类型时,一般都是通过寄存器来带回返回值的,返回对象如果是较大的对象时,一 般会在主调函数的栈帧中开辟一块空间,然后把这块空间的地址,隐式传递给被调函数,在被调函数中通过地址找到主调函数中预留的空间,将返回值直接保存到主调函数的。

目录
相关文章
|
5月前
|
编译器
函数栈帧的创建和销毁
函数栈帧的创建和销毁
30 0
|
6月前
|
编译器 容器
关于函数栈帧的创建和销毁
关于函数栈帧的创建和销毁
|
存储
函数栈帧的创建和销毁(下)
函数栈帧的创建和销毁(下)
52 0
|
6月前
|
容器
函数栈帧的创建和销毁介绍
函数栈帧的创建和销毁介绍
41 0
|
6月前
|
存储 编译器
初识函数栈帧的创建与销毁(笔记)
初识函数栈帧的创建与销毁(笔记)
|
11月前
|
编译器 程序员 C语言
函数栈帧的创建与销毁(超详解)
函数栈帧的创建与销毁(超详解)
104 0
|
存储 缓存 编译器
函数栈帧的创建与销毁
函数栈帧的创建与销毁
38 0
|
存储 C语言 C++
你知道函数栈帧的创建和销毁吗?
你知道函数栈帧的创建和销毁吗?
76 0
|
存储 编译器 C++
深入理解内存 —— 函数栈帧的创建与销毁
深入理解内存 —— 函数栈帧的创建与销毁
123 0
|
编译器 C语言 容器
函数栈帧的创建和销毁(一)
函数栈帧的创建和销毁
115 1