前言
一位优秀的程序员,必须对内存的分布有深刻的理解,在初学编程的时候,往往有诸如以下很多问题困扰着初学者,而通过今天的分享,我们就可以通过自己的观察,将这些问题统统解决掉
- 局部变量是怎么创建的?
- 为什么局部变量的值是随机值?
- 函数是怎么传参的?传参的顺序是怎么样的?
- 形参和实参是什么关系?
- 函数调用是怎么调用的?
- 函数调用后是怎么返回的?
目录
栈与栈帧的概念
栈帧是如何在电脑上运作的
1.c语言代码
2.反汇编代码
主函数:
add函数:
函数栈帧的创建
1.创建 _tmainCRTStartup 的栈帧
2.创建 main 的栈帧
3.main函数数据的初始化
4.add函数传参
5.创建add函数的栈帧
6.add函数数据的初始化
7. add函数的返回
函数栈帧的销毁
1.add函数栈帧的销毁
2.add函数值的返回
3.main函数栈帧的销毁
栈与栈帧的概念
首先,什么是栈?
在数据结构中我们学过 “栈” 这种结构,在数据结构中, 栈是限定仅在表尾进行插入或删除操作的线性表。栈是一种数据结构,它按照后进先出的原则存储数据,先进入的数据被压入栈底,最后的数据在栈顶,需要读数据的时候从栈顶开始弹出数据。
在计算机系统中,栈也可以称之为栈内存是一个具有动态内存区域,存储函数内部(包括 main 函数)的局部变量和方法调用和函数参数值,是由系统自动分配的,一般速度较快;存储地址是连续且存在有限栈容量,会出现溢出现象程序可以将数据压入栈中,也可以将数据从栈顶弹出。压栈操作使得栈增大,而弹出操作使栈减小。 栈用于维护函数调用的上下文,离开了栈函数调用就没法实现。
那什么是栈帧呢?
每一次函数的调用,都会在调用栈(call stack)上维护一个独立的栈帧(stack frame)。每个独立的栈帧一般包括:
- 函数的返回地址和参数
- 临时变量: 包括函数的非静态局部变量以及编译器自动生成的其他临时变量
- 函数调用的上下文
栈是从高地址向低地址延伸,一个函数的栈帧用 ebp 和 esp 这两个寄存器来划定范围.ebp 指向当前的栈帧的底部,esp 始终指向栈帧的顶部;
ebp 指向当前的栈帧的底部
ebp 寄存器又被称为帧指针(Frame Pointer)
esp 始终指向栈帧的顶部
esp 寄存器又被称为栈指针(Stack Pointer)
另外,经过笔者的测试,这也与编译环境有关使用不同的编译器,或者不同的环境下,我们能直观看见的都是不一样的,但是俩者都是寄存器,只是体现不同罢了
- 32位机器(esp,ebp)
- 64位机器(rsp,rbp)
以下是笔者在VS2022上进行的测试:
栈帧是如何在电脑上运作的
要想搞懂这个问题,我们就需要结合编译器给我们提供的反汇编代码,结合上我们写的代码进行分析
我们先实现一个将俩个数相加的函数功能,然后在放进 main 函数中,并且进行调用,完成后输出结果,然后结束 main 函数。整个代码逻辑非常简单,具体实现如下:
1.c语言代码
#define _CRT_SECURE_NO_WARNINGS 1
#include<stdio.h>int add(int x, int y)
{int z = 0;z = x + y;return z;
}int main()
{int a = 10;int b = 20;int c = 0;c = add(a, b);printf("%d", c);return 0;
}
2.反汇编代码
我们完成上述代码后,按 F10 进行调试,然后鼠标右键单击 “转到反汇编”,然后我们就可以看到反汇编代码了
主函数:
int main()
{
001818D0 push ebp
001818D1 mov ebp,esp
001818D3 sub esp,0E4h
001818D9 push ebx
001818DA push esi
001818DB push edi
001818DC lea edi,[ebp-24h]
001818DF mov ecx,9
001818E4 mov eax,0CCCCCCCCh
001818E9 rep stos dword ptr es:[edi]
001818EB mov ecx,18C008h
001818F0 call 0018132F int a = 10;
001818F5 mov dword ptr [ebp-8],0Ah int b = 20;
001818FC mov dword ptr [ebp-14h],14h int c = 0;
00181903 mov dword ptr [ebp-20h],0 c = add(a, b);
0018190A mov eax,dword ptr [ebp-14h]
0018190D push eax
0018190E mov ecx,dword ptr [ebp-8]
00181911 push ecx
00181912 call 00181023
00181917 add esp,8
0018191A mov dword ptr [ebp-20h],eax printf("%d", c);
0018191D mov eax,dword ptr [ebp-20h]
00181920 push eax
00181921 push 187B30h
00181926 call 001810D7
0018192B add esp,8 return 0;
0018192E xor eax,eax
}
00181930 pop edi
00181931 pop esi
00181932 pop ebx
00181933 add esp,0E4h
00181939 cmp ebp,esp
0018193B call 00181253
00181940 mov esp,ebp
00181942 pop ebp
00181943 ret
add函数:
int add(int x, int y)
{
00181870 push ebp
00181871 mov ebp,esp
00181873 sub esp,0CCh
00181879 push ebx
0018187A push esi
0018187B push edi
0018187C lea edi,[ebp-0Ch]
0018187F mov ecx,3
00181884 mov eax,0CCCCCCCCh
00181889 rep stos dword ptr es:[edi]
0018188B mov ecx,18C008h
00181890 call 0018132F int z = 0;
00181895 mov dword ptr [ebp-8],0 z = x + y;
0018189C mov eax,dword ptr [ebp+8]
0018189F add eax,dword ptr [ebp+0Ch]
001818A2 mov dword ptr [ebp-8],eax return z;
001818A5 mov eax,dword ptr [ebp-8]
}
001818A8 pop edi
001818A9 pop esi
001818AA pop ebx
001818AB add esp,0CCh
001818B1 cmp ebp,esp
001818B3 call 00181253
001818B8 mov esp,ebp
001818BA pop ebp
001818BB ret
函数栈帧的创建
我们知道,我要使用某一个函数,就要去调用他,一般常见的情况是在函数里面调用别的函数,就比如上面写的那一段很简单的代码,我们在 main 函数里面调用了 add 函数来实现了将俩个数相加的操作, main 函数是我们人为写的上去的,本身编译器是不会自带 main 函数的,当我们的代码写完了准备编译的时候,编译器得先扫描整个代码,找到 main 函数,然后从 main 函数开始执行代码,换言之 main 函数也是函数,也是需要被调用的。
那么编译器用什么来拿到 main 函数,并且成功的调用他的呢?关于这一点,不同的编译器的实现是不一样的,比如在VS编译器中是使用的 _tmainCRTStartup 这样的内置函数来调用的。
1.创建 _tmainCRTStartup 的栈帧
编译器拿到一段完整的程序后首先会在栈区开辟一块空间,如下图所示:
2.创建 main 的栈帧
从这里开始结合反汇编代码进行观察
首先将 edp 押栈
001818D0 push ebp
然后改变 edp 的指向
001818D1 mov ebp,esp
然后移动 esp 移动 0e4h 个单位
001818D3 sub esp,0E4h
到这里,其实就已经完成了对 main 函数栈区的创建,如图所示:
3.main函数数据的初始化
然后我们再继续结合反汇编代码 进行观察:
在这里连续押了3个元素入栈
001818D9 push ebx 001818DA push esi 001818DB push edi
如图所示:
然后对刚才开辟的空间进行了初始化,并且全部赋值为 cccccccc ,这也解释了为什么平常没有初始化的数据的随机值是 ccccccccc
001818DC lea edi,[ebp-24h] 001818DF mov ecx,9 001818E4 mov eax,0CCCCCCCCh 001818E9 rep stos dword ptr es:[edi]
在完成初始化后,初始化 a=10,在这里一个 word 是 2 个字节,一个 dword 是 4 个字节
int a = 10; 001818F5 mov dword ptr [ebp-8],0Ah
我们可以成功的观察到,在 edp-8 这个位置,已经存放了 a=10,其余位置的 cccccccc 还是保留不变,这也就解释了平常随机值的大小为 cccccccc 的情况
同理的,对 b 和 c 都做初始化
自此我们就完成了对数据的全部初始化,接下来就 add 函数了
4.add函数传参
在这里我们可以注意,传入的地址
- edp-14h 就是之前初始化的 b=20
- edp-8 就是之前初始化的 a=10
也就是进行了函数传参的操作,通过下面的代码,我们更加可以理解函数的形参是实参的一份临时拷贝
c = add(a, b); 0018190A mov eax,dword ptr [ebp-14h] 0018190D push eax 0018190E mov ecx,dword ptr [ebp-8] 00181911 push ecx
5.创建add函数的栈帧
这里的 call 就是调用的意思
00181912 call 00181023 00181917 add esp,8 0018191A mov dword ptr [ebp-20h],eax
按 F11 进入函数观察,我们会发现,这里的操作和上述 main 函数栈帧的操作几乎一模一样,也就是说,这里实际上是在创建 add 函数的栈帧
int add(int x, int y) { 00181870 push ebp 00181871 mov ebp,esp 00181873 sub esp,0CCh 00181879 push ebx 0018187A push esi 0018187B push edi 0018187C lea edi,[ebp-0Ch] 0018187F mov ecx,3 00181884 mov eax,0CCCCCCCCh 00181889 rep stos dword ptr es:[edi] 0018188B mov ecx,18C008h
6.add函数数据的初始化
和上述 main 函数数据的初始化基本上是一样的
int z = 0; 00181895 mov dword ptr [ebp-8],0 z = x + y; 0018189C mov eax,dword ptr [ebp+8] 0018189F add eax,dword ptr [ebp+0Ch] 001818A2 mov dword ptr [ebp-8],eax
这里就不再赘述,结果就是对 edp 附近的字节进行操作,最终达到成功赋值的目的
7. add函数的返回
我们知道,函数使用的空间是临时的,在退出这个函数之后,他使用的这部分空间就被销毁了,那空间都被销毁了,该怎么样把返回值返回呢?
这是返回值 z 的创建位置: edp-8
int z = 0; 00181895 mov dword ptr [ebp-8],0
这是返回时的语句
return z; 001818A5 mov eax,dword ptr [ebp-8]
我们观察发现,编译器是将 edp-8 的值放在了 eax 中,那 eax 是什么呢? eax 其实是寄存器,寄存器不会因为 add 函数的销毁而销毁,他会持续的存在,用来保存 z 的值
函数栈帧的销毁
1.add函数栈帧的销毁
pop 是弹出栈的意思,连续从栈顶弹出三个寄存器,之后继续更改 esp 和 edp 指向的位置,最后,ret 会回到之前 call 指令留下的下一条指令的地址
001818A8 pop edi 001818A9 pop esi 001818AA pop ebx 001818AB add esp,0CCh 001818B1 cmp ebp,esp 001818B3 call 00181253 001818B8 mov esp,ebp 001818BA pop ebp 001818BB ret
如图所示:
此时的栈顶指针,栈底指针就可以做到重新维护 main 函数的栈帧空间,因为之前 call 指令留下的地址,我们就可以做到 “出去又可以回来” 这对于我们管理空间是非常高效稳定的+
2.add函数值的返回
这里实际上是更改栈顶指针的指向,通过这样的操作,我们就可以达到释放形参的目的,值得注意的是这段代码的最后一行
c = add(a, b); 0018190A mov eax,dword ptr [ebp-14h] 0018190D push eax 0018190E mov ecx,dword ptr [ebp-8] 00181911 push ecx 00181912 call 00181023 00181917 add esp,8 0018191A mov dword ptr [ebp-20h],eax
我们会发现,这里的 ebp-20h 和 eax 分别对应了前面对于 c 的初始化和对于 z 的值的保存,也就是说,这里就是将之前 eax 寄存器里放的 z 的值赋给 c,从而达到了
c = add(a, b);
的语句效果
int c = 0; 00181903 mov dword ptr [ebp-20h],0
return z; 001818A5 mov eax,dword ptr [ebp-8]
3.main函数栈帧的销毁
这里也是连续从栈顶弹出三个寄存器,之后继续更改 esp 和 edp 指向的位置,最后 ret 退回上一级调用 main 函数的内置函数中,具体过程同上,这里就不再继续赘述
00181930 pop edi
00181931 pop esi
00181932 pop ebx
00181933 add esp,0E4h
00181939 cmp ebp,esp
0018193B call 00181253
00181940 mov esp,ebp
00181942 pop ebp
00181943 ret
以上就是本次分享的全部内容了,希望对屏幕前的您有所帮助,如有内容上的错误,欢迎指出,也欢迎积极讨论,内容制作不易,给个三连支持一下吧