1.引入(什么是函数栈帧)
我们在写
C
语言代码的时候,经常会把一个独立的功能抽象为函数,而函数的创建与实现,是需要在栈区中获取一处空间的,所以
C
程序是以函数为基本单位的。 那函数是如何调用的?函数的返回值又是如何待会的?函数参数是如何传递的?这些问题都和函数栈帧有关系。
所以我们只要理解了函数栈帧的创建和销毁,以下问题就能够很好地理解了:
1.局部变量是如何创建的?
2.为什么局部变量不初始化内容是随机的?
3.函数调用时参数时如何传递的?传参的顺序是怎样的?
4.函数的形参和实参分别是怎样实例化的?
5.函数的返回值是如何带会的?
让我们一起走进函数栈帧的创建和销毁的过程中
2. 函数栈帧的创建和销毁解析
A.什么是栈?
栈(
stack
)是现代计算机程序里最为重要的概念之一,几乎每一个程序都使用了栈,没有栈就没有函 数,没有局部变量,也就没有我们如今看到的所有的计算机语言。
在经典的计算机科学中,栈被定义为一种特殊的容器,用户可以将数据压入栈中(入栈
push
),也可以将已经压入栈中的数据弹出(出栈,pop
),但是栈这个容器必须遵守一条规则:先入栈的数据后出栈(First In Last Out
,
FIFO
)。就像叠成一叠的术,先叠上去的书在最下面,因此要最后才能取出。
在计算机系统中,栈则是一个具有以上属性的动态内存区域。程序可以将数据压入栈中,也可以将数据 从栈顶弹出。压栈操作使得栈增大,而弹出操作使得栈减小。
在经典的操作系统中,栈总是向下增长(由高地址向低地址)的。
在我们常见的
i386
或者
x86-64
下,栈顶由成为
esp
的寄存器进行定位的。
B.认识相关寄存器和汇编指令
1.相关寄存器
eax
:通用寄存器,保留临时数据,常用于返回值
ebx
:通用寄存器,保留临时数据
ebp
:栈底寄存器
esp
:栈顶寄存器
eip
:指令寄存器,保存当前指令的下一条指令的地址
2.
相关汇编命令
mov
:数据转移指令
push
:数据入栈,同时
esp
栈顶寄存器也要发生改变
pop
:数据弹出至指定位置,同时
esp
栈顶寄存器也要发生改变
sub
:减法命令
add
:加法命令
call
:函数调用,
1
.
压入返回地址
2.
转入目标函数
jump
:通过修改
eip
,转入目标函数,进行调用
ret
:恢复返回地址,压入
eip
,类似
pop eip
命令
C.解析函数栈帧的创建和销毁
首先我们达成一些预备知识才能有效的帮助我们理解,函数栈帧的创建和销毁。
1.
每一次函数调用,都要为本次函数调用开辟空间,就是函数栈帧的空间。
2.
这块空间的维护是使用了
2
个寄存器:
esp
和
ebp
,
ebp
记录的是栈底的地址,
esp
记录的是栈顶的地址。
如图所示:
![](https://img-blog.csdnimg.cn/258759611b7d4e8fa30c6d67465e04cb.png)
函数栈帧的创建和销毁过程,在不同的编译器上实现的方法大同小异
D.函数的调用堆栈
演示代码
#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; }
函数调用堆栈是反馈函数调用逻辑的,那我们可以清晰的观察到,
main
函数调用之前,是由
invoke_main
函数来调用
main
函数。 在 invoke_main
函数之前的函数调用我们就暂时不考虑了。
那我们可以确定,
invoke_main
函数应该会有自己的栈帧,
main
函数和
Add
函数也会维护自己的栈帧,每个函数栈帧都有自己的 ebp
和
esp
来维护栈帧空间。
那接下来我们从
main
函数的栈帧创建开始讲解:
1.转到反汇编
int main()
{
//
函数栈帧的创建
00
BE1820 push ebp
00
BE1821 mov ebp
,
esp
00
BE1823 sub esp
,
0E4
h
00
BE1829 push ebx
00
BE182A push esi
00
BE182B push edi
00
BE182C lea edi
,[
ebp
-
24
h
]
00
BE182F mov ecx
,
9
00
BE1834 mov eax
,
0
CCCCCCCCh
00
BE1839 rep stos dword ptr es
:[
edi
]
//main
函数中的核心代码
int
a
=
3
;
00
BE183B mov dword ptr
[
ebp
-
8
],
3
int
b
=
5
;
00
BE1842 mov dword ptr
[
ebp
-
14
h
],
5
int
ret
=
0
;
00
BE1849 mov dword ptr
[
ebp
-
20
h
],
0
ret
=
Add
(
a
,
b
);
00
BE1850 mov eax
,
dword ptr
[
ebp
-
14
h
]
00
BE1853 push eax
00
BE1854 mov ecx
,
dword ptr
[
ebp
-
8
]
00
BE1857 push ecx
00
BE1858 call
00
BE10B4
00
BE185D add esp
,
8
00
BE1860 mov dword ptr
[
ebp
-
20
h
],
eax
printf
(
"%d\n"
,
ret
);
00
BE1863 mov eax
,
dword ptr
[
ebp
-
20
h
]
00
BE1866 push eax
00
BE1867 push
0
BE7B30h
00
BE186C call
00
BE10D2
00
BE1871 add esp
,
8
return
0
;
00
BE1874 xor eax
,
eax
}
2.函数栈帧的创建
00
BE1820 push ebp
//
把
ebp
寄存器中的值进行压栈,此时的
ebp
中存放的是invoke_main函数栈帧的
ebp
,
esp-4
00
BE1821 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
函数中的局部变量,临时数据已经调试信息等。
00
BE1829 push ebx
//
将寄存器
ebx
的值压栈,
esp-4
00
BE182A 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
00
BE182C lea edi
,[
ebp
-
24
h
]
00
BE182F mov ecx
,
9
00
BE1834 mov eax
,
0
CCCCCCCCh
00
BE1839 rep stos dword ptr es
:[
edi
]
而上面的这段代码最后
4
句,等价于下面的伪代码:
edi
=
ebp
-
0x24
;
ecx
=
9
;
eax
=
0xCCCCCCCC
;
for
(;
ecx
=
0
;
--
ecx
,
edi
+=
4
)
{
*
(
int*
)
edi
=
eax
;
}
![](https://img-blog.csdnimg.cn/0a9d1f2d2945402abe4a8c84a3e7ef84.png)
之所以上面的程序输出
“
烫
”
这么一个奇怪的字,是因为
main
函数调用时,在栈区开辟的空间的其中每一 个字节都被初始化为0xCC
,而
arr
数组是一个未初始化的数组,恰好在这块空间上创建的,
0xCCCC
(两 个连续排列的0xCC
)的汉字编码就是
“
烫
”
,所以
0xCCCC
被当作文本就是
“
烫
”
。
接下来我们再分析
main
函数中的核心代码:
int
a
=
3
;
00
BE183B mov dword ptr
[
ebp
-
8
],
3
//
将
3
存储到
ebp-8
的地址处,
ebp-8
的位置其实就
是
a
变量
int
b
=
5
;
00
BE1842 mov dword ptr
[
ebp
-
14
h
],
5
//
将
5
存储到
ebp-14h
的地址处,
ebp-14h
的位置
其实是
b
变量
int
ret
=
0
;
00
BE1849 mov dword ptr
[
ebp
-
20
h
],
0
//
将
0
存储到
ebp-20h
的地址处,
ebp-20h
的位
置其实是
ret
变量
//
以上汇编代码表示的变量
a,b,ret
的创建和初始化,这就是局部的变量的创建和初始化
//
其实是局部变量的创建时在局部变量所在函数的栈帧空间中创建的
//
调用
Add
函数
ret
=
Add
(
a
,
b
);
//
调用
Add
函数时的传参
//
其实传参就是把参数
push
到栈帧空间中
00
BE1850 mov eax
,
dword ptr
[
ebp
-
14
h
]
//
传递
b
,将
ebp-14h
处放的
5
放在
eax
寄存器
中
00
BE1853 push eax
//
将
eax
的值压栈,
esp-4
00
BE1854 mov ecx
,
dword ptr
[
ebp
-
8
]
//
传递
a
,将
ebp-8
处放的
3
放在
ecx
寄存器中
00
BE1857 push ecx
//
将
ecx
的值压栈,
esp-4
//
跳转调用函数
00
BE1858 call
00
BE10B4
00
BE185D add esp
,
8
00
BE1860 mov dword ptr
[
ebp
-
20
h
],
eax
![](https://img-blog.csdnimg.cn/ee82fd383cbc44a889be4810093dee9a.png)
3.Add函数的传参
//
调用
Add
函数
ret
=
Add
(
a
,
b
);
//
调用
Add
函数时的传参
//
其实传参就是把参数
push
到栈帧空间中,这里就是函数传参
00
BE1850 mov eax
,
dword ptr
[
ebp
-
14
h
]
//
传递
b
,将
ebp-14h
处放的
5
放在
eax
寄存器
中
00
BE1853 push eax
//
将
eax
的值压栈,
esp-4
00
BE1854 mov ecx
,
dword ptr
[
ebp
-
8
]
//
传递
a
,将
ebp-8
处放的
3
放在
ecx
寄存器中
00
BE1857 push ecx
//
将
ecx
的值压栈,
esp-4
//
跳转调用函数
00
BE1858 call
00
BE10B4
00
BE185D add esp
,
8
00BE1860 mov dword ptr [ebp-20h],eax ![](https://img-blog.csdnimg.cn/27bd90d9b25f4ca9b37dc5eb7df02bc1.png)
4.函数调用过程
//
跳转调用函数
00
BE1858 call
00
BE10B4
00
BE185D add esp
,
8
00
BE1860 mov dword ptr
[
ebp
-
20
h
],
eax
call
指令是要执行函数调用逻辑的,在执行
call
指令之前先会把
call
指令的下一条指令的地址进行压栈操作,这个操作是为了解决当函数调用结束后要回到call指令的下一条指令的地方,继续往后执行。
![](https://img-blog.csdnimg.cn/bdbb21668c944cad8c15aea395550022.png)
当我们跳转到
Add
函数,就要开始观察
Add
函数的反汇编代码了。
int
Add
(
int
x
,
int
y
)
{
00
BE1760 push ebp
//
将
main
函数栈帧的
ebp
保存
,esp-4
00
BE1761 mov ebp
,
esp
//
将
main
函数的
esp
赋值给新的
ebp
,
ebp
现在是
Add
函数的
ebp
00
BE1763 sub esp
,
0
CCh
//
给
esp-0xCC
,求出
Add
函数的
esp
00
BE1769 push ebx
//
将
ebx
的值压栈
,esp-4
00
BE176A push esi
//
将
esi
的值压栈
,esp-4
00
BE176B push edi
//
将
edi
的值压栈
,esp-4
int
z
=
0
;
00
BE176C mov dword ptr
[
ebp
-
8
],
0
//
将
0
放在
ebp-8
的地址处,其实就是创建
z
z
=
x
+
y
;
//
接下来计算的是
x+y
,结果保存到
z
中
00
BE1773 mov eax
,
dword ptr
[
ebp
+
8
]
//
将
ebp+8
地址处的数字存储到
eax
中
00
BE1776 add eax
,
dword ptr
[
ebp
+
0
Ch
]
//
将
ebp+12
地址处的数字加到
eax
寄存中
00
BE1779 mov dword ptr
[
ebp
-
8
],
eax
//
将
eax
的结果保存到
ebp-8
的地址处,其实
就是放到
z
中
return
z
;
00
BE177C mov eax
,
dword ptr
[
ebp
-
8
]
//
将
ebp-8
地址处的值放在
eax
中,其实就是
把
z
的值存储到
eax
寄存器中,这里是想通过
eax
寄存器带回计算的结果,做函数的返回值。
}
00
BE177F pop edi
00
BE1780 pop esi
00
BE1781 pop ebx
00
BE1782 mov esp
,
ebp
00
BE1784 pop ebp
00
BE1785 ret
代码执行到
Add
函数的时候,就要开始创建
Add
函数的栈帧空间了。
在
Add
函数中创建栈帧的方法和在
main
函数中是相似的,在栈帧空间的大小上略有差异而已。
1.
将
main
函数的
ebp
压栈
2.
计算新的
ebp
和
esp
3.
将
ebx
,
esi
,
edi
寄存器的值保存
4.
计算求和,在计算求和的时候,我们是通过
ebp
中的地址进行偏移访问到了函数调用前压栈进去的参数,这就是形参访问。
5.
将求出的和放在
eax
寄存器中准备带回
![](https://img-blog.csdnimg.cn/acc6e89cd7714795a490c391cd87e18d.png)
图片中的
a'
和
b'
其实就是
Add
函数的形参
x
,
y
。这里的分析很好的说明了函数的传参过程,以及函数在进行值传递调用的时候,形参其实是实参的一份拷贝。对形参的修改不会影响实参。
5.函数栈帧的销毁
当函数调用要结束返回的时候,前面创建的函数栈帧也开始销毁。
那具体是怎么销毁的呢?我们看一下反汇编代码。
00
BE177F pop edi
//
在栈顶弹出一个值,存放到
edi
中,
esp+4
00
BE1780 pop esi
//
在栈顶弹出一个值,存放到
esi
中,
esp+4
00
BE1781 pop ebx
//
在栈顶弹出一个值,存放到
ebx
中,
esp+4
00
BE1782 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
指令的下一条指令的地方:
![](https://img-blog.csdnimg.cn/ea5c0d2b3f734315ba80bdd842aacb8d.png)
但调用完
Add
函数,回到
main
函数的时候,继续往下执行,可以看到:
00
BE185D add esp
,
8
//esp
直接
+8
,相当于跳过了
main
函数中压栈的
a'
和
b'
00
BE1860 mov dword ptr
[
ebp
-
20
h
],
eax
//
将
eax
中值,存档到
ebp-0x20
的地址处,其实就是存储到main
函数中
ret
变量中,而此时
eax
中就是
Add
函数中计算的
x
和
y
的和,可以看出来,本次函数的返回值是由eax
寄存器带回来的。程序是在函数调用返回之后,在
eax
中去读取返回值的。
3.总结
其实返回对象时内置类型时,一般都是通过寄存器来带回返回值的,返回对象如果时较大的对象时,一般会在主调函数的栈帧中开辟一块空间,然后把这块空间的地址,隐式传递给被调函数,在被调函数中通过地址找到主调函数中预留的空间,将返回值直接保存到主调函数的。