您的位置:首页 > 编程语言 > C语言/C++

小结 | 函数的调用过程(栈帧)

2017-08-10 22:04 260 查看

引子

有一个关于两数值交换的题目:

#define _CRT_SECURE_NO_WARNINGS 1

#include <stdio.h>
#include <stdlib.h>

void swap1(int x, int y)
{
int tmp = 0;

tmp = x;
x = y;
y = tmp;
}

void swap2(int *x, int *y)
{
int tmp = 0;

tmp = *x;
*x = *y;
*y = tmp;
}

int main()
{
int a = 10;
int b = 20;

printf("a1 = %d, b1 = %d\n", a, b);
swap1(a, b); //值传递
printf("a1 = %d, b1 = %d\n", a, b);

printf("a2 = %d, b2 = %d\n", a, b);
swap2(&a, &b); //址传递
printf("a2 = %d, b2 = %d\n", a, b);

system("pause");
return 0;
}


函数 swap1 并没有实现交换的功能,函数 swap2 实现了交换的内容:



函数 swap1 的内存窗口:



函数 swap2 的内存窗口:



通过两者的内存对比可发现, swap1 对传进的参数进行交换的时候,并没有修改到 a b 地址上的值,只是对 x y 地址的值进行了修改。而 swap2 通过指针,找到了 a b 的地址,当我们交换 *x *y 的值,其实就是对a b 的值进行了交换。

我们常说形参是实参的一份临时拷贝,在这个例子中已经很好的表示出来一部分。下面就结合一个简单的例子,通过汇编代码看看在进行函数的调用时,各个变量是怎么存储、怎么获取、在内存块中是怎么分配的。



(来自C专家编程)

铺垫

1、先认识两个寄存器:我们知道,每一次函数的调用都会在栈空间开辟一块空间,就是上图的堆栈段,这块空间需要维护。维护栈顶的寄存器esp,维护栈底的寄存器ebp。



2、栈空间的内存使用是从高地址向低地址增长的,栈底维护在高地址处,栈顶维护在低地址处。为了方便观察,我将栈顶和栈底的位置进行了倒置。

3、程序开始执行时,main()函数不是第一个被调用的函数。现在我们通过调堆栈可以看到main()函数之前有两个函数被调用。见下图:



main()被 __tmainCRTStartup() 调用

__tmainCRTStartup() 被 mainCRTSartup() 调用



4、esp和ebp两个寄存器始终维护最新开辟的栈空间,现在图上没有每次开辟都移动,但实际上是始终跟随新开辟的空间的。

执行

现在开始执行函数,分析main()函数中内存的操作,测试代码如下:

(测试函数中,所有的步骤都分开,尽量的分开)

#include <stdio.h>
#include <stdlib.h>

int Add(int x, int y)
{
int ret = 0;
ret = x + y;
return ret;
}

int main()
{
int a = 10;
int b = 20;
int ret = 0;
ret = Add(a, b);
printf("ret = %d\n", ret);

system("pause");
return 0;
}


main()函数开始

F10开始调试,然后转到汇编代码:



1、(push ebp) 将 ebp 压栈到栈顶,即是将 ebp 记录起来,之后执行完还需要回来。同时维护栈顶的 esp 移动维护新栈顶。

2、(mov ebp,esp) 将 esp 给 ebp ,即 ebp 得到了 esp ,此时 esp 和 ebp 同时指向了栈顶。

esp 地址:0x006ff8b8

ebp 地址:0x006ff908



esp 地址:0x006ff8b8

ebp 地址:0x006ff8b8





3、(sub esp,0E4h) esp 减去了 0E4h 的大小,即 esp 向低地址移动了 0E4h,这时,ebp 和 esp 开辟了一块空间,这块空间大小是 0E4h,这个空间属于 main 函数。

esp 地址:0x006ff7d4

ebp 地址:0x006ff8b8

相差 0E4h



4、(push ebx、esi、edi ) 向栈顶压栈了三个寄存器,ebx、esi、edi。



5、(lea edi,[ebp-0E4h]) 将 ebp - 0E4h 的值加载到 edi 中,即 edi 指向了相应的位置,此时记住了esp 减去 0E4h 之后的位置,如上图中粉色箭头处,此处的地址记录在了 edi 中。

esp 地址:0x006ff7c8

edi 地址:0x006ff7d4 等于之前 esp 的地址,刚好是现在 esp 向高地址移动 12 个字节处



6、(mov ecx,39h ) 将 39h 放到 ecx 中。此时关注一下 0E4h ,这是一个十六进制的数值,E4 的十进制是 228。巧的是 39h 是十
e547
六进制,39 的十进制是57,57*4=228。

7、(mov eax,0CCCCCCCCh) 将 0CCCCCCCCh 放到 eax 中。CCCCCCCC 就是我们平常看到的随机值

8、(rep stos dword ptr es:[edi]) rep stos 重复赋值;dword,双字,即 4 个字节;从 edi 开始,向低地址处重复赋值,每次赋值四个字节,正好是 eax 中的 0CCCCCCCCh,一共赋值 ecx 中的 39h 次。

edi 从 0x006ff7d4 到 0x006ff8b8

edi 的位置刚好与栈底 ebp 重合,这就将之前 esp 和 ebp 维护的空间 228 个字节全部赋值为 CC。







形参的拷贝

到现在为止,我们真正希望执行的语句还没有开始,之前进行的就是所谓的调用函数的系统开销。现在开始进入形参的拷贝阶段:



9、(int a = 10; mov dword ptr [ebp-8],0Ah)

10、(int b = 20; mov dword ptr [ebp-14h],14h)

11、(int ret = 0; mov dword ptr [ebp-20h],0)

ebp 地址:0x006ff8b8

9、10、11命令依次将 0Ah、14h、0 赋值到 ebp 向低地址 8字节处、20字节处、36字节处,分别对应
int a = 10; int b = 20; int ret = 0;
,符合先利用高地址,再使用低地址的规则。





12、(mov eax,dword ptr [ebp-14h]) 在 eax 中放入[ebp-14h]的值(b的值:20)

13、(push eax) 将 eax 压栈

esp 地址:0x006ff7c8 变成 0x006ff7c4,此地址的值为 20



14、(mov ecx,dword ptr [ebp-8]) 在 ecx 中放入[ebp-8]的值(a的值:10)

15、(push ecx) 将 ecx 压栈

esp 地址:0x006ff7c4 变成 0x006ff7c0,此地址的值为 10





16、(call 011711EF) call 开始调用函数 Add ,此时使用 F11 进入函数内部。call 指令还有一个功能就是将 call 指令的下一条指令的地址压栈记录下来。

add 地址:0x011732C0



esp 地址:0x006ff7bc 这里保存了 add 的地址:0x011732C0



记录下来,就可以调用完 Add 函数后回来



进入Add()函数



显示符号名,这样 lea 指令才看得懂



17、(push ebp) 将 ebp 压栈,这个 ebp 之前是维护 main() 函数栈底的,现在将它在这里压栈,可以记录下之前 main() 函数的地址,等调用完 Add() 函数后就可以通过 pop 此处的 ebp 回到 main() 的栈底。

18、(move ebp,esp) 将 esp 给 ebp ,此时 ebp 跟 esp 位于同一个位置。



在 (rep stos dword ptr es:[edi])之前,所有的指令都是跟在 main()函数一样的,这里不再解释。请默默看图:



19、(int ret = 0; mov dword ptr [ebp-8],0) 在 ebp - 8 的位置放入 0





20、(mov eax,dword ptr [ebp+8] ) 把 ebp+8 (10 a的值)的值赋给 eax



21、(add eax,dword ptr [ebp+0Ch] ) 对 eax 的值 加上 ebp + 0Ch (20 b的值)的值,此处进行了加法运算



第一个灰色箭头是 ebp+8 ,第二个是 ebp + 0Ch



22、(mov dword ptr [ebp-8],eax ) 将计算出来的30,从 eax 中赋值给 ebp-8 ,这里是 ret 的地址,eax 代表的是 ret 也是接下来的返回值 30。

23、(return ret; mov eax,dword ptr [ebp-8]) 把ebp-8 的值赋给 eax,里面存着 30



24、(pop edi) 将 edi 弹出,edi 的值给栈顶 esp ,同时 esp 向高地址移动

25、(pop esi) 将 esi 弹出,esi 的值给栈顶 esp ,同时 esp 向高地址移动

26、(pop ebx) 将 ebx 弹出,ebx 的值给栈顶 esp ,同时 esp 向高地址移动

pop 之后,维护栈顶的寄存器 esp 已经不维护之前开辟的空间,所以 edi、esi、ebx 的空间不再使用



esp 的地址:由 0x006ff6e0 变成 0x006ff6ec



27、(mov esp,ebp) 将 ebp 的值给 esp,此时 esp 移到与 ebp 维护同一个地址

此时,之前开辟的 Add() 函数的栈空间已经没有寄存器维护,这里的空间不再使用,这里就是我们说的自动调用和自动销毁。所谓销毁其实就是不再使用。等到下次再次利用这块空间到时候,这些值都会被初始化为随机值(0xCCCCCCCC)。



ebp 和 esp 的地址一样



28、(pop ebp) 将ebp – main 弹出到ebp中,ebp返回到main的栈底,同时 esp 向高地址移动 4 字节



ebp 回到 main() 函数的栈底



esp 维护 call 指令调用时存储的下一条指令地址的空间



退出Add()函数

29、(ret) ret指令有两个作用:首先直接跳回到call指令下一条指令,因为之前已经将其地址记录下来了,所以退回去是完全可以的。其次,pop esp。

esp 的地址:由 0x006ff7bc 变到 0x006ff7c0





30、(add esp,8) esp + 8字节,esp向高地址移动 8 字节。

esp 的地址:由 0x006ff7c0 变到 0x006ff7c8



esp 回到了一开始开辟的空间,在这地址之下的内容都不在被维护,即销毁。



Add()函数返回值进行赋值

31、(mov dword ptr [ebp-20h],eax) 将 eax 的值(从 Add() 函数中返回的 ret 的值,30)赋给 ebp-20h 这个地址,这个地址恰好是一开始设定的 main() 函数中 ret 的位置。



通过 eax 在跳出 Add() 函数之前将返回值记录起来了,现在将它赋给接收值 ret



我们的分析就到这里结束,剩下的是调用 printf() 函数,这里不再继续分析。

附上全局图:



小结

通过这个分析,我们可以得到:

1、函数传参的时候,的的确确是有一份拷贝的。

2、通过维护栈顶的 esp 寄存器和维护栈底 ebp 寄存器,我们可以获得一份栈空间或者销毁栈空间。正是我们常说的局部变量只用于函数体内部,其自动创建和自动销毁。

3、通过指针访问,我们可以得到不同值,只要明白了数据在内存中的存储,就可以灵活的获取。

4、熟悉内存的存储,在我们分析函数调用的时候会有很大的帮助。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息