问题引入
- 局部变量是如何创建的?
- 为什么局部变量不初始化内容是随机的?
- 函数调用时参数如何传递?传参的顺序是怎样的?
- 函数的形参和实参分别是怎样实例化的?
- 函数的返回值是如何带回的?
我们带着这几个问题来进入今天的正题。
一、什么是函数栈帧
函数栈帧(stack frame):函数调用过程中在程序的调用栈(call stack)所开辟的空间。
这些空间是用来存放:
- 函数参数和函数返回值
- 临时变量(包括函数的非静态的局部变量以及编译器自动生产的其他临时变量)
- 保存上下文信息(包括在函数调用前后需要保持不变的寄存器)
下图是程序执行的简单步骤:
二、函数栈帧的创建和销毁解析
1.何为栈?
栈被定义为一种特殊的容器。
用户可以将数据压入栈中(入栈,push),也可以将已经压入栈中的数据弹出(出栈,pop)
但是栈这个容器必须遵守一条规则:先入栈的数据后出栈(First In Last Out, FIFO)大家可以想象弹夹,先装进去的子弹在最内层,后装进去的子弹在外层,后装的子弹先被设计出膛。
在计算机系统中,栈则是一个具有以上属性的动态内存区域。可以将数据压入栈中,也可以将数据从栈顶弹出。压栈操作使得栈增大,而弹出操作使得栈减小。
在经典的操作系统中,栈总是向下增长(由高地址向低地址)的
2.认识相关寄存器和汇编指令
3.具体实现
3.1.具体实现前的认知
首先,我们需要明确:
- 每个函数调用,都要在栈上开辟空间,这个空间就是函数栈帧
- 这个空间由ebp和esp来维护
- 不同编译器对于函数栈帧的销毁与创建略有差异
- 此篇文章是在vs2022 x86环境下实现的
3.2.函数调用堆栈
int Mul(int x, int y) {
int z = 0;
z = x * y;
return z;
}
int main() {
int a = 10;
int b = 11;
int c = 0;
c = Mul(a, b);
printf("%d\n", c);
return 0;
}
我们可以看到:
main()函数调用之前,是由invoke_main()函数来调用main()函数。
在invoke_main()函数之前的函数调用我们就暂时不考虑了。
那么,我们就可以有如下认知:
invoke_main() main() Mul()都有自己的函数栈帧
3.3.函数栈帧的创建
我们将main()转到返汇编,如下图:
接下来我们一行一行分析
3.3.1.进入main()前的准备
//压入ebp 现在ebp存的是invoke_main()的函数栈帧的ebp esp-4
008E18D0 push ebp
//移动esp到压入ebp的所在位置 将esp的值存到ebp中 产生了main()的ebp 而这个值就是invoke_main()的esp
008E18D1 mov ebp,esp
//esp-0E4h esp向上移动0E4h的字节,这是main()的esp,产生了main()的函数栈帧
008E18D3 sub esp,0E4h
//将ebx压入栈 esp-4
008E18D9 push ebx
//将esi压入栈 esp-4
008E18DA push esi
//将edi压入栈 esp-4
008E18DB push edi
//上面3条指令保存了3个寄存器的值在栈区 这3个寄存器的在函数随后执行中可能会被修改
//所以先保存寄存器原来的值 以便在退出函数时恢复
//把[ebp-24h]的地址放在edi中
008E18DC lea edi,[ebp-24h]
//把9放在ecx中
008E18DF mov ecx,9
//把0CCCCCCCCh放在eax中
008E18E4 mov eax,0CCCCCCCCh
//将栈帧空间初始化为0xCC
008E18E9 rep stos dword ptr es:[edi]
//8EC00Eh赋给ecx
008E18EB mov ecx,8EC00Eh
//调用地址为 008E132F 的函数
008E18F0 call 008E132F
小tips:为什么我们未初始化字符数组,会打印出烫烫烫?
0xCCCC(两 个连续排列的0xCC)的汉字编码就是“烫”
3.3.2.main()核心代码
//局部的变量的创建和初始化 在函数栈中中进行
//将10储存到[ebp-8]的位置
int a = 10;
000E18F5 mov dword ptr [ebp-8],0Ah
//将11储存到[ebp-14h]的位置
int b = 11;
000E18FC mov dword ptr [ebp-14h],0Bh
//将0储存到[ebp-20h]
int c = 0;
000E1903 mov dword ptr [ebp-20h],0
//调用Mul()传参
c = Mul(a, b);
//把地址为[ebp-14h]的值传给eax 即把b传给eax
000E190A mov eax,dword ptr [ebp-14h]
//将eax的值压栈 esp-4
000E190D push eax
//把地址为[ebp-8]的值传给ecx 即把a传给ecx
000E190E mov ecx,dword ptr [ebp-8]
//将ecx的值压栈 esp-4
000E1911 push ecx
//跳转调用函数 函数地址为 000E104B
000E1912 call 000E104B
//添加保存call下一条指令的地址
000E1917 add esp,8
//把eax存储到[ebp-20h]的位置
000E191A mov dword ptr [ebp-20h],eax
3.4.函数调用具体实现
int Mul(int x, int y) {
//将main()函数栈帧的ebp保存 esp-4
000E1790 push ebp
//将main()函数栈帧的esp赋给ebp 现在ebp就是Mul()的ebp
000E1791 mov ebp,esp
//将esp移动到0CCh的位置 现在esp就是Mul()的esp
000E1793 sub esp,0CCh
//将ebx压入栈 esp-4
000E1799 push ebx
//将esi压入栈 esp-4
000E179A push esi
//将edi压入栈 esp-4
000E179B push edi
//把[ebp-0Ch]存入edi中
000E179C lea edi,[ebp-0Ch]
//把3放在ecx中
000E179F mov ecx,3
//把0CCCCCCCCh放入eax中
000E17A4 mov eax,0CCCCCCCCh
//将栈帧空间初始化为0xCC
000E17A9 rep stos dword ptr es:[edi]
//将0EC00Eh赋给ecx
000E17AB mov ecx,0EC00Eh
//调用000E132F这个地址的函数
000E17B0 call 000E132F
int c = 0;
//把[ebp-8]这个位置初始化成0 创建了z变量
000E17B5 mov dword ptr [ebp-8],0
c = x * y;
//把[ebp+8]地址的值存入eax
000E17BC mov eax,dword ptr [ebp+8]
//把[ebp+0Ch]地址的值与eax的值相乘 并存入eax
000E17BF imul eax,dword ptr [ebp+0Ch]
//将eax的结果存入[ebp-8]这个地址 即放到z中
000E17C3 mov dword ptr [ebp-8],eax
return c;
//将ebp-8地址的值放在eax中 即把z的值存储到eax中
//这里是想通过eax寄存器带回计算的结果 做函数的返回值
000E17C6 mov eax,dword ptr [ebp-8]
}
3.5.函数栈帧的销毁
//将之前存入edi的值弹出栈 esp+4
009D17C9 pop edi
//将之前存入esi的值弹出栈 esp+4
009D17CA pop esi
//将之前存入ebx的值弹出栈 esp+4
009D17CB pop ebx
//将esp地址加0CCh 移动esp 清理栈帧空间
009D17CC add esp,0CCh
//比较ebp和esp 检查栈的完整性 确保在函数执行过程中栈的使用没有出现异常
009D17D2 cmp ebp,esp
//调用009D1253这个地址的函数
009D17D4 call 009D1253
//将ebp的值赋给esp 相当于回收了Mul()的栈帧空间
009D17D9 mov esp,ebp
//弹出栈顶的值存放到ebp 栈顶此时的值恰好就是main()的ebp
//esp+4 此时恢复了main()的栈帧维护 esp指向main()函数栈帧的栈顶
//ebp指向了main()函数栈帧的栈底
009D17DB pop ebp
//返回 从栈顶弹出一个值 此时栈顶的值就是call指令下一条指令的地址 此时esp+4
//然后直接跳转到call指令下一条指令的地址处 继续往下执行
009D17DC ret
回到这里
三、回答引入的问题
- 局部变量是如何创建的?
给函数分配好栈帧空间后,通过控制esp指针移动,将局部变量的值压入栈,保存在相应的寄存器中
- 为什么局部变量不初始化内容是随机的?
在创建函数栈帧时,给空间中随机值0xCCCCCCCC
- 函数调用时参数如何传递?传参的顺序是怎样的?
将参数值压入栈,存入寄存器,由寄存器移动传递参数,顺序从右至左传递
- 函数的形参和实参分别是怎样实例化的?
通过寄存器,将实参临时拷贝一份,开辟出独立空间存放形参数值,也就是说形参只有在被调用的时候存放实参传过来的值时才在栈区开辟函数栈帧空间
- 函数的返回值是如何带回的?
寄存器保存着函数调用的下一条指令的地址,函数生命周期结束后,寄存器会通过保存的地址带回返回值
注意:本文只针对于栈空间来讨论