🌹作者:云小逸
📝个人主页:云小逸的主页
📝Github:云小逸的Github
🤟motto:要敢于一个人默默的面对自己,强大自己才是核心。不要等到什么都没有了,才下定决心去做。种一颗树,最好的时间是十年前,其次就是现在!学会自己和解,与过去和解,努力爱自己。==希望春天来之前,我们一起面朝大海,春暖花开!==🤟
👏专栏:C++👏 👏专栏:Java语言👏👏专栏:Linux学习👏
👏专栏:C语言初阶👏👏专栏:数据结构👏👏专栏:备战蓝桥杯👏
文章目录
- 前言
-
- 1. 什么是函数栈帧
- 2. 理解函数栈帧能解决什么问题呢?
- 3. 函数栈帧的创建和销毁解析
- 3.1 什么是栈?
- 3.2 认识相关寄存器和汇编指令
- 3.3 解析函数栈帧的创建和销毁
- 3.3.1 预备知识
- 3.3.2 函数的调用堆栈
- 演示代码:
- 深度剖析:
-
- 总结
- 1.局部变量是怎么创建的?
- 2.为什么局部变量的值是随机值?
- 3.函数是怎么传参的?传参的顺序是怎样的?
- 4.形参和实参是什么关系?
- 5.函数调用是怎么做的?
- 6. 函数调用是结束后怎么返回的?
- 最后
-
-
前言
在前面学习学习C++引用的时候,有不少同学私信我,说对于函数栈帧那一块不是很理解,那么今天我们就来系统地学习一下函数栈帧的创建和销毁,码字不易,希望多多支持!!!
1. 什么是函数栈帧
在编写 C 语言代码时,我们通常会将一个独立的功能抽象为函数,因此 C 程序是以函数为基本单位的。
那么函数是如何调用的?函数的返回值又是如何返回的?函数参数是如何传递的?这些问题都与函数栈帧有关。
函数栈帧(stack frame)是指在程序的调用栈(call stack)中,函数调用过程中开辟的空间,用于存放以下内容:
- 函数参数和返回值
- 临时变量(包括函数的非静态的局部变量以及编译器自动生产的其他临时变量)
- 保存上下文信息(包括在函数调用前后需要保持不变的寄存器)。
2. 理解函数栈帧能解决什么问题呢?
理解函数栈帧可以解决以下问题:
- 局部变量是如何创建的?
- 为什么局部变量的内容未初始化时是随机的?
- 函数调用时参数是如何传递的?传参的顺序是怎样的?
- 函数的形参和实参分别是如何实例化的?
- 函数的返回值是如何返回的?
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 预备知识
首先我们需要了解一些预备知识,才能有效地帮助我们理解函数栈帧的创建和销毁。
- 每一次函数调用,都要为本次函数调用开辟空间,就是函数栈帧的空间。
- 这块空间的维护是使用了2个寄存器:esp和ebp,ebp记录的是栈底的地址,esp记录的是栈顶的地址。
- 函数栈帧的创建和销毁过程,在不同的编译器上实现的方法大同小异,本次演示以VS2019为例。
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;
}
在我们平时写的代码中一般都是在main函数调用其他函数,但是我们是否知道其实main函数也是被调用的!!!
我们可以通过vs中的调试来看到这个过程
调用顺序是下面的调用上面的
深度剖析:
从上面的知识点,我们可以知道:是通过__tmainCRTStartup函数来调用main函数,因此先建立__tmainCRTStartup函数的栈帧:
在这里设定下面是高地址,下面是低地址,从高地址想低地址入栈。
好的,我们接下来来看汇编指令:
int main()
{
007C1940 push ebp
这一步是将ebp入栈,然后将esp向低地址移动一位(4个字节)到指向push进来的ebp,
如下图所示:
007C1941 mov ebp,esp
mov是将后者的值给前者,即将esp赋值给ebp
007C1943 sub esp,0E4h
是指将esp减去0E4h(十进制为228),即esp指向的地址变小,指向了上面的某块区域了:
此时ebp和esp之间存在一块空间就是为main函数预开辟的空间
push ebx
将 ebx 的值压入堆栈,堆栈指针 esp 减少 4 个字节。push esi
将 esi 的值压入堆栈,堆栈指针 esp 再减少 4 个字节。push edi
将 edi 的值压入堆栈,堆栈指针 esp 再减少 4 个字节。 这样,ebx、esi 和 edi 的值就被保存在堆栈中,可以在函数执行过程中随时使用,不会被修改。在函数返回时,再通过pop
指令将这三个寄存器的值恢复到原来的状态。
lea edi, [ebp-24h]
将 [ebp-24h] 的地址加载到 edi 中,此时 edi 指向分配的内存空间(main函数)的起始地址。mov ecx, 9
将 ecx 设置为 9,表示需要将 9 个 dword(4 字节)内存单元设置为 0xCCCCCCCC。mov eax, 0CCCCCCCCh
将 eax 设置为 0xCCCCCCCC,即将要填充的值。rep stos dword ptr es:[edi]
重复执行将 eax 中的值写入 edi 指向的内存位置,直到 ecx 个内存单元都被填充为止。这样就完成了堆栈内存空间的初始化。
这个也是为什么如果你不定义一个变量,不初始化,打印的时候会是随机值!!!
到这里为mian函数栈帧的开辟准备工作才完成
mov ecx,0AEC003h
将值 0AEC003h 存储到 ECX 中,即将函数 Add 的地址作为参数传递给下一条指令。call 00AE131B
调用函数 Add。此指令将当前指令的下一条指令的地址(即返回地址)压入堆栈,并跳转到函数 Add 的入口地址开始执行函数体。在函数执行完后,再通过ret
指令返回到调用该函数的指令处继续执行。
为调用add函数做准备
10: int a = 3;
mov dword ptr [ebp-8], 3
将值 3 存储到 [ebp-8] 中,即将变量 a 的值设置为 3。
没有赋值默认就是0CCCCCCCCh(随机值,烫烫烫那种)
11: int b = 5;
mov dword ptr [ebp-14h], 5
将值 5 存储到 [ebp-14h] 中,即将变量 b 的值设置为 5。
与a隔8个字节
12: int ret = 0;
mov dword ptr [ebp-20h],0
将值 0 存储到 [ebp-20h] 中,即将变量 ret 的值初始化为 0。
13: ret = Add(a, b);
mov eax,dword ptr [ebp-14h]
将 [ebp-14h] 中的值(即变量 b 的值)存储到 EAX 中。push eax
将 EAX 中的值压入堆栈,作为第二个参数传递给函数 Add。
mov ecx,dword ptr [ebp-8]
将 [ebp-8] 中的值(即变量 a 的值)存储到 ECX 中。push ecx
将 ECX 中的值压入堆栈,作为第一个参数传递给函数 Add。
call 00AE11BD
call
指令会将当前代码的下一条指令的地址(即call
指令的下一条指令)压入堆栈,并将程序的执行控制权转移到函数的入口地址。函数执行完毕后,会返回到call
指令的下一条指令继续执行。在函数调用过程中,参数会被压入堆栈中,函数执行完毕后返回值也会被存储在指定的寄存器中,通常是 EAX 寄存器。因此,在执行call 00AE11BD
指令之前,需要确保函数的入口地址是有效的,并且函数参数和返回值的处理符合函数定义的要求。
call指令在执行的过程中,还会把call指令的下一个指令压到栈中:
这里会执行jmp指令,进行跳转:
00AE131B jmp 00AE19E0
是一条汇编指令,它的作用是将程序的执行控制权无条件地转移到内存地址00AE19E0
所在的代码处,从而实现代码的跳转。具体来说,该指令会直接将程序的执行控制权转移到指定的地址,不会对跳转前的指令进行任何处理,也不会保留跳转前的任何状态。因此,在执行该指令之前,需要确保跳转的目标地址是有效的,否则可能会导致程序出错或崩溃。
下面这几步和main函数建立的类似:
push ebp
:把当前函数的基址指针(EBP)压入堆栈中,为后续的指令执行做准备。mov ebp, esp
:将堆栈指针(ESP)的值赋给基址指针(EBP),这样就可以在函数内部通过基址指针来访问函数参数和局部变量了。sub esp, 0CCh
:为函数的局部变量分配空间,这里是分配了 204(即 0xCC)字节的空间。 这些指令一般出现在函数的开头部分,用于初始化函数的堆栈和局部变量。第三条指令中的0xCC
可能是根据具体的函数需要来确定的,一般是根据函数内部需要使用的局部变量的大小来决定的。需要注意的是,在函数执行完毕后,需要通过恢复堆栈指针(通过指令mov esp, ebp
和pop ebp
)来释放堆栈空间,以免出现内存泄漏等问题。
push ebx
:将 EBX 寄存器的值压入堆栈中,为后续的指令执行做准备。push esi
:将 ESI 寄存器的值压入堆栈中,为后续的指令执行做准备。push edi
:将 EDI 寄存器的值压入堆栈中,为后续的指令执行做准备。lea edi,[ebp-0Ch]
:使用 LEA 指令计算出一个地址,该地址是基址指针减去 12(即[ebp-0Ch]
),并将其存储在 EDI 寄存器中。mov ecx,3
:将计数器 ECX 的值设置为 3,以便用于重复执行指令。mov eax,0CCCCCCCCh
:将一个特殊的值(0xCCCCCCCC)存储在 EAX 寄存器中。rep stos dword ptr es:[edi]
:重复执行 STOS 指令,将 EAX 的值(即 0xCCCCCCCC)存储到 EDI 指向的内存地址中,直到计数器 ECX 的值变为 0。 这些指令一般出现在函数的开头部分,用于初始化函数的堆栈和局部变量。其中,第 4 条指令计算出的地址一般用于存储函数中一些需要初始化的变量。第 6 条指令将一个特殊的值存储在 EAX 寄存器中,该值通常用于调试目的,在程序运行时可以检测到是否访问了未初始化的内存地址。第 7 条指令则是重复执行 STOS 指令,将特定的值存储到指定的内存地址中,以便初始化变量。需要注意的是,在函数执行完毕后,需要通过恢复堆栈指针和寄存器的值(通过指令pop edi
、pop esi
、pop ebx
和mov esp, ebp
、pop ebp
)来释放堆栈空间和还原寄存器的值,以免出现内存泄漏等问题。
4: int z = 0;
-
00AE1795 mov dword ptr [ebp-8],0
:将常数值 0 存储到 EBP 减去 8 的地址处,即变量 z 的内存地址,以初始化 z 的值为 0。
5: z = x + y; -
00AE179C mov eax,dword ptr [ebp+8]
从 EBP 加上 8 的地址处取出 4 字节的值,即变量 x 的值,并存储到 EAX 寄存器中。 -
00AE179F add eax,dword ptr [ebp+0Ch]
将从 EBP 加上 12 的地址处取出的 4 字节值(即变量 y 的值)加到 EAX 寄存器中,得到结果并存储到 EAX 寄存器中。 -
00AE17A2 mov dword ptr [ebp-8],eax
将 EAX 寄存器中的值(即 x + y 的结果)存储到 EBP 减去 8 的地址处,即变量 z 的内存地址中,完成变量 z 的赋值。6: return z;
-
00AE17A5 mov eax,dword ptr [ebp-8]
从 EBP 减去 8 的地址处取出 4 字节的值,即变量 z 的值,并存储到 EAX 寄存器中。7: }
这段代码的作用是计算变量 x 和 y 的和,并将结果存储到变量 z 中,最后返回变量 z 的值。需要注意的是,变量 x、y、z 分别存储在 EBP 加上 8、12、8 的地址处,这是因为在函数调用时,参数和局部变量的值都存储在堆栈中,并通过 EBP 寄存器来访问。
00AE17A5 mov eax,dword ptr [ebp-8]
因为z是局部变量,出了add函数就会被销毁,因此将其赋值为一个全局变量(就是之前我在C++引用那篇文章中提到的【临时变量】)
解释:
这条汇编指令的作用是从 EBP 减去 8 的地址处取出 4 字节的值(即变量 z 的值),并将其存储到 EAX 寄存器中。这是因为在该函数的最后一行代码中,函数需要返回变量 z 的值,而该变量的值已经存储在 EBP 减去 8 的地址处,因此需要将其取出并存储到 EAX 寄存器中,以便作为函数的返回值。需要注意的是,这里的 dword ptr
是用来指定要取出的内存单元大小的关键词,它表示要取出 4 字节的值。
pop edi
:将堆栈顶部的值弹出并存储到 EDI 寄存器中,以恢复被保存的 EDI 寄存器值。pop esi
:将堆栈顶部的值弹出并存储到 ESI 寄存器中,以恢复被保存的 ESI 寄存器值。pop ebx
:将堆栈顶部的值弹出并存储到 EBX 寄存器中,以恢复被保存的 EBX 寄存器值。 这些寄存器值在函数调用时被保存在堆栈中,以便在函数执行过程中可以使用堆栈来保存临时变量和函数调用的返回地址等信息。在函数执行结束时,需要将这些被保存的寄存器值恢复到原始状态,以确保程序的正确性和稳定性。需要注意的是,恢复寄存器的顺序应该与保存寄存器的顺序相反。
pop出栈弹出:
add esp, 0CCh
:将堆栈指针增加 0xCC(204)个字节的大小,以清空堆栈中的函数参数和局部变量等信息。这是因为在函数执行过程中,函数所使用的堆栈空间需要被释放,以便其他函数可以使用该空间。cmp ebp, esp
:比较 EBP 寄存器中的值(即堆栈底部的地址)和 ESP 寄存器中的值(即当前堆栈指针的地址),以确保堆栈指针在函数执行结束后正确地回到了调用函数之前的位置。如果堆栈指针没有正确回退,可能会导致程序崩溃或出现未定义的行为。call 00AE1244
:调用位于地址 0x00AE1244 处的子程序(或函数)。该函数的具体作用需要根据函数地址和函数实现来确定。在函数执行结束后,程序将从函数调用的下一条指令继续执行。
销毁add函数:
mov esp, ebp
:将 EBP 寄存器中的值(即堆栈底部的地址)赋值给 ESP 寄存器,以恢复堆栈指针到调用该函数之前的位置。这是因为在函数执行过程中,EBP 寄存器被用作堆栈帧指针,指向当前函数的栈帧。而在函数调用结束后,需要将堆栈指针恢复到调用该函数之前的位置,以便程序可以正确地返回到调用该函数的位置继续执行。pop ebp
:从堆栈中弹出一个值,并存储到 EBP 寄存器中,以恢复被保存的 EBP 寄存器值。在函数执行过程中,EBP 寄存器被用作堆栈帧指针,指向当前函数的栈帧。在函数执行结束后,需要将 EBP 寄存器的值恢复到调用该函数之前的值,以确保程序的正确性和稳定性。
通过移动ebp,esp进行销毁并返回到main函数
00AE17BB ret 作用:这条汇编指令的作用是从当前函数中返回,并将控制权交还给调用该函数的代码。具体来说,ret
指令会从堆栈中弹出一个值,该值被认为是函数的返回地址,然后将程序计数器(PC)设置为该地址,从而使程序跳转到该地址并继续执行。在函数执行结束时,需要使用 ret
指令来返回到调用该函数的代码处,以便程序可以继续执行。需要注意的是,函数的返回值通常存储在 EAX 寄存器中,并在调用该函数的代码中使用。
add esp,8
将堆栈指针 esp 加上 8,即弹出堆栈中的两个参数。mov dword ptr [ebp-20h],eax
将 EAX 中的值(即 Add 的返回值)存储到 [ebp-20h] 中,即将变量 ret 的值设置为 Add 的返回值。
14: printf("%d\n", ret);
mov eax,dword ptr [ebp-20h]
:将位于 EBP 寄存器减去 0x20(即堆栈中函数参数和局部变量的偏移量)处的 4 个字节的值(也就是变量 ret 的值)加载到 EAX 寄存器中,以便将其打印出来。push eax
:将 EAX 寄存器中的值(即变量 ret 的值)复制一份,并将其压入堆栈中,以便作为printf
函数的第一个参数。push 0AE7B30h
:将地址 0AE7B30h 压入堆栈中,以便作为printf
函数的第二个参数,该地址指向格式化字符串 “%d\n”。 然后,call 00AE10CD
指令调用printf
函数来打印 ret 的值。最后,add esp,8
指令将堆栈指针增加 8 个字节的大小,以清除堆栈中的两个参数,以便程序可以正常返回。在函数返回之前,xor eax,eax
指令将 EAX 寄存器清零,以便将其作为返回值返回给调用该函数的代码,并结束该函数的执行。
call 00AE10CD
:调用位于地址 0x00AE10CD 处的子程序(或函数),该函数为printf
函数。在调用该函数之前,push
指令将两个参数(即变量ret
的值和格式化字符串%d\n
的地址)压入堆栈中。函数调用完成后,将会返回到下一条指令(即add esp,8
)继续执行。add esp,8
:将堆栈指针增加 8 个字节的大小,以清除堆栈中的两个参数。这是因为在函数调用中,参数需要被压入堆栈中,并在函数执行完成后被清除,否则可能会导致堆栈溢出或其他未定义行为。在该函数调用完成后,程序将从调用该函数的下一条指令继续执行。
15: return 0;
00AE190E xor eax,eax
这条汇编指令的作用是将 EAX 寄存器中的值清零,即将其设置为 0。这通常用于将 EAX 寄存器作为函数的返回值,并且希望返回值为 0 的情况。在这个例子中,该指令用于将 EAX 寄存器清零,以便将其作为函数的返回值,并返回给调用该函数的代码。
16: }
总结
1.局部变量是怎么创建的?
局部变量一般是在函数的栈帧中创建的,即在函数执行期间分配在堆栈上的内存空间。在函数开始执行时,会为所有的局部变量分配内存空间,并将其地址存储在堆栈上。在函数执行结束后,这些变量占用的内存空间会被释放,以便其他函数可以使用。
2.为什么局部变量的值是随机值?
局部变量的值是随机的,是因为在声明变量时没有对其进行初始化。在栈帧中分配给局部变量的内存空间可能包含任意值,这些值是由之前使用这些内存空间的代码留下的,与当前函数的代码逻辑无关。因此,如果不对局部变量进行初始化,它们的值就会是随机的。
随机值就是我们刚开始设定的ccccccccc(打印可能会出现烫烫烫等)
3.函数是怎么传参的?传参的顺序是怎样的?
函数传参通常是通过堆栈来完成的,在调用函数之前,参数会被压入堆栈中。通常,参数的传递顺序是从右向左,即最后一个参数会被先压入堆栈中,第一个参数会被最后压入堆栈中。
4.形参和实参是什么关系?
形参和实参是函数传递参数的两个概念。形参是在函数定义中声明的变量,用于接收函数调用时传递的实参值。实参是在函数调用中传递给函数的值。在函数调用时,实参的值会被传递给形参,并在函数内部使用。
两者是独立的空间
5.函数调用是怎么做的?
函数调用是通过 call
汇编指令完成的。该指令将函数的返回地址压入堆栈,并将程序计数器设置为函数的入口地址,以便跳转到函数的起始位置。在函数执行结束后,使用 ret
指令返回到调用该函数的代码处。
6. 函数调用是结束后怎么返回的?
函数调用结束后,返回值通常存储在 EAX 寄存器中,并通过 ret
指令返回给调用该函数的代码。在返回之前,堆栈指针会被恢复,以便清除函数调用期间压入堆栈的局部变量和其他参数。如果需要返回多个值,可以将它们打包成一个数据结构(如结构体)并将该结构体的指针返回。
最后
十分感谢你可以耐着性子把它读完和我可以坚持写到这里,送几句话,对你,也对我:
1. 人生最大的难度不是走出舒适区,而是保持冷静和自信面对不确定的未来。
2. 人生就是一次次的选择,每个选择都会影响到未来的自己。
3. 没有比当下更好的时刻,因为未来和过去都只是自己的想象。
4. 人生中最重要的是学会珍惜当下,因为当下就是一切。
5.划清和别人的界限。别人怎么看你,跟你毫无关系,你要怎么活,也跟别人没有任何关系,撇清别人,才能精力旺盛。
最后如果觉得我写的还不错,请不要忘记点赞✌,收藏✌,加关注✌哦(。・ω・。)
愿我们一起加油,奔向更美好的未来,愿我们从懵懵懂懂的一枚菜鸟逐渐成为大佬。加油,为自己点赞!