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 记录的是栈顶的地址。
如图所示:
另外,函数栈帧的创建和销毁过程,在不同的编译器上实现的方法会有部分差异。
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
函数后,我们就可以观察到函数的调用堆栈
(右击勾选【显示外部代码】),如下图:

函数调用堆栈是反馈函数调用逻辑的,那我们可以清晰的观察到,
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;
}

烫烫烫
~

之所以上面的程序输出
“
烫
”
这么一个奇怪的字,是因为
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

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

函数调用过程:
//
跳转调用函数
00BE1858 call 00BE10B4
00BE185D add esp,8
00BE1860 mov dword ptr [ebp-20h],eax
call指令是要执行函数调用逻辑的,在执行
call
指令之前先会把
call
指令的下一条指令的地址进行压栈操作,这个操作是为了解决当函数调用结束后要回到call
指令的下一条指令的地方,继续往后执行。

当我们跳转到
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
寄存器尊准备带回

图片中的
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
指令的下一条指令的地方:

也就是说add函数调用完毕,继续执行mian函数,函数的返回值会存放在寄存器eax中被读取。
4.拓展了解
其实返回对象是内置类型时,一般都是通过寄存器来带回返回值的,返回对象如果是较大的对象时,一 般会在主调函数的栈帧中开辟一块空间,然后把这块空间的地址,隐式传递给被调函数,在被调函数中通过地址找到主调函数中预留的空间,将返回值直接保存到主调函数的。