函数栈帧的创建和销毁[以C语言代码为例,汇编代码的角度分析]
一.前言
1.几个问题
在C语言学习阶段,我们可能会遇到下面几个问题,
在学习完函数栈帧的创建和销毁之后,我们就能更加深刻地理解下面几个问题了
2.几个说明
其次,我们要说明的是:不同编译器下汇编指令的样子是有所差异的
下面给大家看一下同样的代码在VS2013中的样子
同样的代码在Linux中的样子
而且在观察汇编代码学习函数栈帧的创建和销毁的过程中.
不要使用太高级的编译器,越高级的编译器越不容易学习和观察
同时在不同的编译器下,函数的调用过程是略有差异的,具体细节取决于编译器的实现
我们这一篇博客以VS2013为例,学习函数栈帧的创建和销毁的过程
二.相关寄存器和汇编命令的简要说明
三.从汇编代码调试的角度逐步分析函数栈帧的创建于销毁
我们以这份代码为例:
#include <stdio.h>
int MyAdd(int a, int b)
{
int c = a + b;
return c;
}
int main()
{
int x = 0xA;
int y = 0xB;
int z = MyAdd(x, y);
return 0;
}
1.函数栈区的知识:
首先我们要说明两点:
1.函数是开辟在栈区的,栈区空间的使用习惯是
先使用高地址,后使用低地址
也就是说函数栈区是从高到低去开辟的
2.main函数是也是被调用的
具体调用流程如下
2.逐步调试分析
初始情况:
esp(栈顶指针)
ebp(栈底指针)
esp和ebp之间有一块空间
这块空间其实就是__tmainCRTStartup的栈帧
1.保存__tmainCRTStartup这个函数栈帧的栈底地址
执行第一条指令:
push ebp
把ebp的值入栈,并且esp减小
2.正式进入main函数
执行第二条指令:
mov ebp esp
就是把esp的值给ebp
3.开辟main函数栈帧
执行第3条指令
sub esp 0E4h
esp = esp-0E4h
也就是esp减少0E4h大的空间
这就是为main函数开辟栈帧
也就是说在汇编中开辟栈帧的方式就是栈顶指针减少
其中
函数的栈帧大小是由编译器决定的
根据什么决定的呢?
编译器可以通过sizeof求出该函数栈帧中的所有变量的具体大小
并进行合理分配栈帧的大小
4.将main函数栈帧中的数据置为随机值
接下来是3条push指令
push ebx
push esi
push edi
请注意:esp每一次push之后的值都减4
为什么呢?
因为push是压栈操作.我们可以理解为push进去的数据在内存空间上是紧密相邻的
接下来是:
lea edi,[ebp+FFFFFF1Ch]
就是把ebp+FFFFFF1Ch这个地址加载到edi中
其实这个ebp+FFFFFF1Ch就是ebp-0E4h
而这个0E4h就是第三条指令中
sub esp 0E4h
这个esp减去的大小
也就是这个main函数的栈帧大小
下面两条指令:
mov ecx,39h
mov eax,0CCCCCCCCh
把16进制数字:39h给ecx
把0CCCCCCCCh给eax
下面这个指令:
rep stos dword ptr es:[edi]
因此我们就可以回答
为什么局部变量的值是随机值呢?
因为函数栈帧创建之后,会对栈帧中的数据进行初始化,
而局部变量是开辟在栈帧中的,
如果没有对该局部变量进行初始化
那么该局部变量的值就是随机值
而VS2013中的随机值就是0CCCCCCCCh
这也就解释了为什么我们经常会见到烫烫烫烫烫烫这样的字符
这就是我们初始化后的栈帧空间
执行完刚才那条指令之后ecx被清0
edi会指向ebp
5.函数传参的准备
1.创建形参
下面两条指令在main函数的栈帧中创建了x和y这两个局部变量
mov dword ptr [ebp-8],0Ah
mov dword ptr [ebp-14h],0Bh
把0Ah(就是10进制的10)赋值给ebp-8内存空间的值
把0Bh(就是10进制的11)赋值给ebp-14h内存空间的值
这里我们就可以回答第一个问题了:局部变量是如何创建的?
局部变量是通过栈底指针的偏移定位到空间
将对应空间分配给局部变量
然后将该空间内的值修改为对应初始化的值
下面四条指令是:
mov eax,dword ptr [ebp-14h]
push eax
mov ecx,dword ptr [ebp-8]
push ecx
本质是形成形参进行函数传参
其中这个eax中放的是y的值
ecx中放的是x的值
因此这也就解释了第三条问题:
函数是如何传参的?
通过栈底指针的偏移找到实参的值,
把对应实参的值传递给寄存器eax和ecx
然后把eax和ecx的值入栈
就形成了形参
也就是说形参是实参的一份拷贝,改变形参不会影响实参
传参的顺序是怎样的?
顺序是从右向左传参
eax变为11
ecx变为10
2.将call指令的下一条指令的地址压栈
下面就要进行call指令调用MyAdd函数了
首先编译器会先把call指令的下一条指令保存下来
也就是说这里实际上是把add指令的地址压入了栈帧当中
也就是执行了
push add的地址
下一条是jmp命令
这个命令就很简单
执行完jmp命令后就正式进入了MyAdd函数
jmp 00bb13c0
这样我们就能回答第5个问题了:
函数调用是怎么做的?
函数调用时,先形成形参,
然后把main函数的栈底指针的地址保存下来
然后把对应函数调用指令的下一条指令的地址压入栈中
然后正式进入MyAdd函数
6.正式进入MyAdd函数并且开辟栈帧,置为随机值
然后我们进入MyAdd函数
下面这10条指令跟创建main函数栈帧的指令如出一辙
就是先把main函数的栈底地址入栈
然后把esp的值赋给ebp:其实就是正式进入MyAdd这个函数的栈帧
然后sub esp,0CCh就是为MyAdd函数创建栈帧
push ebx,esi,edi 为后续初始化MyAdd函数栈帧做准备
rep stos dword ptr es:[edi]: 就是把MyAdd函数栈帧中的空间初始化为随机值0CCCCCCCCh
push ebp
mov ebp,esp
sub esp,0CCh
push ebx
push esi
push edi
lea edi,[ebp+FFFFFF34h]
mov ecx,33h
mov eax,0CCCCCCCCh
rep stos dword ptr es:[edi]
7.取出形参的值
然后下面这三条指令就是:
mov eax,dword ptr [ebp+8]
add eax,dword ptr [ebp+0Ch]
mov dword ptr [ebp-8],eax
其实就是通过ebp栈底指针的偏移找到创建的形参,取出形参的值
赋值,相加给eax
然后通过eax的赋值和ebp栈底指针的偏移创建MyAdd函数栈帧中的局部变量c
8.销毁MyAdd函数栈帧
下面这5条指令就是
mov eax,dword ptr [ebp-8]
pop edi
pop dsi
pop ebx
mov esp,ebp
保存返回值并且销毁MyAdd这个函数栈帧
9.返回
其中:
返回的本质是:
1.返回到main函数的栈帧
2.返回到call指令的下一条指令的地址处
3.如果有返回值,把返回值进行返回
下面两条指令就会
分别将ebp和esp返回到main函数中的对应的位置
pop ebp
ret
最后两条指令
add esp,8
mov dword ptr [ebp-20h] eax
这样就能回答第6个问题了
函数调用结束之后是怎么返回的呢?
首先先把返回值进行临时拷贝,在MyAdd这个函数中是用寄存器eax来保存这个返回值
然后通过弹出main函数的栈底地址,将栈底指针ebp恢复为main函数的栈底指针
通过弹出call指令的下一条指令的地址并将这个地址存入eip中
保证call指令结束后能够回到call指令的下一条指令的位置
我们同时也可以看出:
形参先创建,然后再调用函数
函数先销毁,然后再销毁形参
函数的销毁和形参的销毁都是通过栈顶指针的增大实现的
函数栈帧的创建是通过栈顶指针的减小实现的
在函数中创建局部变量是通过栈底指针的偏移定位到要创建的位置
通过eax,ecx等等寄存器来完成变量值的传递
以上就是以MyAdd为例,
整个函数栈帧的创建和销毁过程的分析
四.总结
综上,我们可以得出
以上就是函数栈帧的创建和销毁的全部内容,希望能对大家有所帮助!