文章目录
1.为什么要了解函数栈帧的创建和销毁
- 函数是如何创建的
- 局部变量是如何创建的
- 为什么局部变量不初始化的值是随机的
- 函数参数是如何传递的
- 参数有多个时,传参的顺序是怎样的
- 函数返回值是如何带回的
- 为什么函数形参是实参的一份临时拷贝
等等这些问题,在我们学完函数栈帧的创建和销毁后就可以迎刃而解了。
2.什么是函数栈帧
函数栈帧(stack frame)就是函数调用过程中在程序的调用栈(call stack)所开辟的空间。就是函数在栈中所占空间。
3. 函数栈帧的作用
函数栈帧可以用来存放:
- 函数参数和函数返回值
- 临时变量(包括函数的非静态的局部变量以及编译器自动生产的其他临时变量)
- 保存上下文信息(包括在函数调用前后需要保持不变的寄存器)
4.函数栈帧的创建和销毁过程解析
本文例子
#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;
}
4.1什么是栈?
定义
栈是一个动态内存区域。它是一个特殊容器,可以容纳局部变量,函数和函数参数。它是基础,我们对函数进行的操作都是在栈上完成。
栈的特点
- 先入栈的数据后出栈,后入栈的数据先出栈。就像我们把薯片放入圆柱桶,但我们吃的时候总是先吃到后放入的,最后吃到先放入的。
- 数据入栈和出栈是通过push(压栈/入栈)和pop(出栈)来完成,程序可以将数据压入栈中,也可以将数据从栈顶弹出。压栈操作使得栈增大,而弹出操作使得栈减小。
- 在经典的操作系统中,栈总是向下增长(由高地址向低地址)的。
4.2寄存器和汇编指令的类型和作用
相关寄存器
- eax:通用寄存器,保留临时数据,常用于返回值
- ebx:通用寄存器,保留临时数据
- ebp:栈底寄存器
- esp:栈顶寄存器
- eip:指令寄存器,保存当前指令的下一条指令的地址
函数栈帧就是当程序调用函数时,为这个函数开辟的空间。其中ebp和esp是维护函数栈帧的寄存器,ebp记录的是栈底的地址,esp记录的是栈顶的地址。
相关汇编指令
- mov:数据转移指令
01111821 mov ebp,esp //将esp中的值移(赋)给ebp
- push:数据入栈,同时esp栈顶寄存器也要发生改变
01111829 push ebx
0111182A push esi
0111182B push edi
- pop:数据弹出至指定位置,同时esp栈顶寄存器也要发生改变
当我们调用其它函数时,我们把ebp这个记录上一个函数栈底的寄存器压入栈中,当函数调用结束,我们要回到原来函数,需要ebp回到原来函数的栈底,这时pop起作用,它将记录原来函数栈底的ebp弹出,使其回到原来的位置。
黑色是pop前的操作,红色是pop后的操作。pop后,ebp原来那块空间不属于函数,esp向下移动四个字节,一个内存单位等于四个字节。
- sub:减法命令
- add:加法命令
00E618B3 sub esp,0E4h//表示esp减少0E4h个字节,
也就是向上移动57个内存单位。其中0E4h是
16进制数字,一共228个字节。
……
00E618F7 add esp,8//表示esp增加8个字节,
向下移动两个内存单位。
- call:函数调用,1. 压入返回地址 2. 转入目标函数
- jump:通过修改eip,转入目标函数,进行调用
- ret:恢复返回地址,压入eip,类似pop eip命令
这几个后边讲
4.3函数的调用堆栈
我们在分析函数栈帧的创建之前,需要知道函数的调用过程。
我们可以看到main函数是invoke_main调用的,而invoke_main又是由栈区下面的函数调用。已知函数由esp和ebp维护,既然如此,那么调用main的函数也有自己的esp和ebp,否则它的函数栈帧就会被销毁。同理在我们的例子中Add函数也有自己的esp和ebp维护自己的栈帧。
4.4环境准备
我使用的VS2019,在转到反汇编后希望没有编译器附加的代码干扰,我们可以这么做
4.5转到反汇编
我们通过汇编语言来了解函数栈帧,从而解决开头提到的问题。
注
VS每次调试都会为代码重新分配内存空间,所以我们每次的调试都略有差异。
接下来,我们就看到这段代码的汇编语言。
4.6函数栈帧的创建
- 我们发现在变量创建之前有一段汇编代码,它的意义是创建main函数的函数栈帧。
注
我们主要研究main和Add函数的函数栈帧。
int main()
{
00821820 push ebp //把ebp压入栈中,它记录的是调用main的函数的栈底的地址,esp-4
00821821 mov ebp,esp //将esp的记录的地址赋给新的ebp,此时的ebp记录的是main函数的栈底的地址
00821823 sub esp,0E4h//esp往上(低地址)移动0E4h(228)个字节 ,此时esp形成main函数的栈顶,在esp和ebp之间创建的这块空间就是函数栈帧
00821829 push ebx //压入ebx,esp-4
0082182A push esi //压入esi,esp-4
0082182B push edi //压入edi,esp-4
//上面3条指令保存了3个寄存器的值在栈区,这3个寄存器的在函数随后执行中可能会被修改,所以先保存寄存器原来的值,以便在退出函数时恢复
0082182C lea edi,[ebp-24h] //将ebp-24h的地址放在edi中
0082182F mov ecx,9 //将9赋给ecx,9就是9块内存单位
00821834 mov eax,0CCCCCCCCh //将0xCCCCCCCCh赋给eax
00821839 rep stos dword ptr es:[edi] //将edi记录地址往下的每块(9块)空间都初始化为0CCCCCCCC,刚好到函数的栈底。
注意
每次压入栈区中,esp都会将它纳入函数的栈帧,就像别人来投靠你,你收纳他了。
问题1
为什么局部变量不初始化的值是随机的?
当我们创建一个局部变量时,我们完全有可能申请到这些放有0xCCCCCCCC的空间,所以当你未初始化而使用它时,比如打印,就会出现有趣的一幕。这个数组刚好创建在这部分内存。
所以一定要初始化,即使这个值你暂时用不到。
问题2
这段代码同时也解释了函数是如何创建的。
2.接下来就是局部变量的创建。
int a = 3;
0082183B mov dword ptr [ebp-8],3 //将3赋给ebp-8地址的变量a
int b = 5;
00821842 mov dword ptr [ebp-14],5 //将5赋给ebp-12的地址的变量b
int ret = 0;
00821849 mov dword ptr [ebp-20h],0 //将0赋给ebp-20的地址的变量ret
注
这里的8、14、20都是十六进制数。
问题3
局部变量是如何创建的
局部变量在函数栈帧内创建的。创建过程如上。
3. 函数调用以及传参
ret = Add(a, b);
//传参
00821850 mov eax,dword ptr [ebp-14h] //将b的值赋给eax,我们把这个值叫做a1
00821853 push eax //将eax压入栈中,esp-4
00821854 mov ecx,dword ptr [ebp-8] //将a的值赋给ecx,我们把这个值叫做b1
00821857 push ecx //将ecx压入栈中,esp-4
//调用函数
00821858 call _Add (08213B6h) //将下一条指令的地址压入栈中,同时进行被调函数
0082185D add esp,8
00821860 mov dword ptr [ebp-20h],eax
问题4
参数有多个时,传参的顺序是怎样的?
当参数有多个时,从右往左传参。如本例中,先b,再传a。
4. 进入函数
int Add(int x, int y)
{
//为Add函数创建函数栈帧,过程和main函数类似
00821890 push ebp //此时压入栈中的是记录main栈底的ebp,esp-4
00821891 mov ebp,esp //将记录main函数栈顶的esp赋给ebp,维护Add函数的栈底
00821893 sub esp,0CCh //将esp往上移动0CCh个字节,esp-0cch
00821899 push ebx //将ebx压栈
0082189A push esi //将esi压栈
0082189B push edi //将edi压栈
int z = 0;
0082189C mov dword ptr [ebp-8],0 //在ebp-8的位置创建一个变量z
z = x + y;
008218A3 mov eax,dword ptr [ebp+8] //ebp+8是a1的地址,将a1放在eax寄存器
008218A6 add eax,dword ptr [ebp+0ch] //ebp+0ch是b1的地址,将b1放在寄存器中
008218A9 mov dword ptr [ebp-8],eax //将寄存器中的值放到z中,z就是a和b的值
return z;
008218AC mov eax,dword ptr [ebp-8] //将z的值放在eax,让eax带回返回值
}
问题5
函数参数是如何传递的?
函数参数并不是在被调函数的栈帧中创建的,而是在main函数的栈帧中创建的,详细请看上图。
问题6
为什么函数形参是实参的一份临时拷贝?
我们发现被调函数中并没有创建x和y,而是直接将a1和b1相加赋给z。而a1和b1是我们创建的变量,它们是a和b的拷贝,对它们进行修改,不影响a和b的值。
4.7 函数栈帧的销毁
当函数结束时,函数栈帧就要销毁。
008218AF pop edi//在栈顶弹出一个值,把值放在edi中,esp+4
008218B0 pop esi//在栈顶弹出一个值,把值放在esi中,esp+4
008218B1 pop ebx//在栈顶弹出一个值,把值放在ebx中,esp+4
008218B2 mov esp,ebp//将ebp的值赋给esp,相当于回收了Add函数的栈帧空间
008218B4 pop ebp//将记录main函数栈底的ebp从栈中弹出,使ebp指向main函数的栈底,esp+4
008218B5 ret//弹出call留下的地址,返回call的下一条指令,继续执行,esp+4
问题7
函数返回值是如何带回的?
函数的返回值是放在eax寄存器中带回的,详细请看上面代码。
0082185D add esp,8 //将8赋给esp,直接跳过a1和b1,esp+8
00821860 mov dword ptr [ebp-20h],eax //eax寄存器中的值放在ret中
printf("%d\n", ret);
00821863 mov eax,dword ptr [ebp-20h] //将ret的值赋给eax
00821866 push eax //将eax压栈,esp-4
00821867 push offset string "%d\n" (0827BD8h)
0082186C call _printf (08210D2h)
00821871 add esp,8
接下来三个代码就是把ret打印出来,这里就不讲了,有兴趣的大家可以去了解下
return 0;
00821874 xor eax,eax
}
5.总结
到这里,我们了解函数栈帧的创建,解决了局部变量的创建、传值调用、传参顺序、返回值等问题,也了解了部分汇编指令和部分寄存器的作用。
写这章时写的浑浑噩噩,写到这,忘了那,所以大家如果发现有什么问题,请一定要提出,我一定会尽快改正。