说明
这篇文章基本上是完全了,但是还是不完全
所以以后可能会更新
哦对,头图是一张非常好看的雷电将军壁纸,可惜只能显示一半,不过配合内容也很有修仙的意境,版权出处可在图片右下角©悬浮鼠标查看。手机及其他触屏用户可点击查看(建议还是用电脑,电脑更好康)。
源文件
Tools
VS2019
VScode
Typora
引子
函数栈帧的创建和销毁
前期学习的时候,我们可能有很多困惑?
比如:
- 局部变量是怎么创建的?
- 为什么局部变量的值是随机值?
- 函数是怎么传参的?传参的顺序是怎样的?
- 形参和实参是什么关系?
- 函数调用是怎么做的?
- 函数调用结束后是怎么返回的?
知道函数栈帧的创建和销毁就都会了,其实就是修炼了自己的内功,也能搞懂后期更多的知识。
进入正题
今天讲解的时候,使用的环境是vs2013,不建议使用太高级的编译器,越高级的编译器,越不容易学习和观察。
同时在不同的编译器下,函数调用过程中栈帧的创建是略有差异的,具体细节取决于编译器的实现。
说明
首先,我们编写了一段代码:
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\n", c);
return 0;
}
注意
以下内容皆由这段代码做实验。
如果不是很清楚可以直接先看总结然后结合内容理解。
如果可以自己画图帮助自己理解,那再好不过了。
如果你的脑子或者你的知识存储量支持你直接想象,那也没问题。
每一次调试都会改变地址,所以如果自己搞不明白的话,请直接一头调试到尾不要退出。
再修炼过程中如果突发异常状态或者问题,可以先跳过研究或者看看文末注意内容,直接度娘或者其他询问方式也可以,切记不要钻牛角尖,走火入魔。没必要。
此乃呕心沥血集大成之作,修炼内功必备。如若促使修仙之人上天入地无所不能,不胜荣幸
1.寄存器
寄存器,是集成到CPU上的,她完全独立。
eax
ebx
ecx
edx
ebp 栈底指针 寄存当前函数栈帧栈底地址
esp 栈顶指针 寄存当前函数栈帧栈顶地址
epb和esp2个寄存器中存放的是地址
这个2个地址是用来维护函数栈帧的
每一个函数调用,都要在栈区创建一块空间
栈区的使用习惯:
先使用低地址,再使用高地址。(这个很重要,因为每一次操作都基于这个条件,不同的编译器可能会有不同)
2.main函数调用
可以看到,在VS2013中,main函数也是被其他函数调用的
__tmainCRTStartup
在626行调用了main函数
这个函数又是被别的函数调用的
mainCRTStartup
在466行调用了__tmainCRTStartup
3.反汇编(正篇开始,按动作划分步骤,详细到爆)
F10进行调试,右键反汇编
就可以看到我们C语言所对应的汇编代码了
为了方便我们看具体的地址,内存的布局,我们可以取消勾选显示符号名
可以看到a,b,c都变成了具体的地址
当然右键也可以选择取消勾选
注意
接下来的步骤皆由上述代码实验。
步骤过长且按部就班。
如需跳转请直点目录索引。
1.push 开始创建main的函数栈帧
注意
这里已经进入了main函数,那么也就意味着调用main函数的__tmainCRTStartup已经创建了栈帧,并且已有一个ebp和一个esp指针来维护
压栈:
给栈顶放一个元素 push
同样
出栈:
从栈顶删除一个元素 pop
在这里当我们进入第一步push!
就会在这个栈帧顶放一个ebp的值,没错,就是__tmainCRTStartup的ebp的值。
同时,esp是维护栈顶的,所以esp也要往上走。
也就是说:
push的动作
每一次push,esp都要往上走,esp的值都会变
可以查看一下esp地址
esp地址变小了
在内存中可以看到ebp被压进去了
2.mov
既然已经把ebp的值给了esp,那么是不是可以说ebp也已经提升上来了?
很显然是的
3.sub
sub就是减法
这里显示sub,esp,0E4h。意思就是说esp减去一个0E4h
- 0E4h是十六进制显示,十进制就是228
那也就是说,esp变小,会被放到更低的地址,用图来说明就是放到了上面的某一个地址
那么这就有了一个esp和ebp新维护的空间。这块空间,其实就是预先为main函数申请的空间
4.push ebx,esi,edi
接下来3个步骤,直接压3个寄存器
可以看到现在所有寄存器的值
同样esp也会移动
5.lea
Load Effective Address - 加载有效地址
勾选显示符号名就可以看到后面这个值
也就是说,把ebp-0E4h放到edi里面
ebp-0E4h意思和前面esp一样,减去了0E4h
此时的地址,放到了edi里去
注意
此步骤与接下来的2个mov,这3个步骤具体有什么用,需要看后面rep stos的操作
6.mov ecx,39h
把39h放到ecx里
7.mov eax,0CCCCCCCCh
把0CCCCCCCCh这个值放到eax里
8.rep stos
真正起作用的是这句话:
//rep stos dword ptr es:[edi]
这句话的意思是:
要把从edi开始,向下的ecx(39h)次,这么多个dword(一个word2个字节,double word就是双字,四个字节)数据全部改成eax(0CCCCCCCCh)。
注意edi,原本和ebp-0E4h一样的值,变成了和ebp一样的值
那么用图就可以说明,从ebp-0E4h到ebp,也就是main函数的栈帧。全部变成了c
9.mov 存放变量并初始化
这里的ebp-8,ebp-14h,ebp-20h,根据上述步骤即可得知
ebp-8h
把0Ah放到ebp-8里,0Ah转换成10进制就是10
在内存中也可以看到
这时,变量就创建好了。
变量初始化 烫烫烫
之前我们在研究的时候会打印的"烫",就是int a = 10没放进去的时候存放的随机值,这里就是c,不同的编译器可能放不同的随机值。所以,这就是变量初始化的重要性。
ebp-14h
然后,ebp-14h存放的就是b了。
十六进制的14转换成十进制就是20。在内存中可以看到
存放距离
通过内存可以很直观的看到和int a = 10隔了2个字节的距离,当然因为是dword操作,也要算上其他6个bit位的0,所以b存放在了14h的位置。
在别的编译器上可能是紧挨着,也可能会间隔别的距离
ebp-20h
int c = 0又和b = 20间隔了2个字节
此时,所有的变量都已创建。
10.mov 开始函数调用
笔者的话
看到这里,想必你已经有了一定的耐心毅力,且已经从懵逼状态进化到了略懂一点。当然,能够从前面研究到现在已经很了不起了,我的老师说过:“函数栈帧就是修炼内功的过程”,到现在为止也只是创建了main函数的函数栈帧,明白了一些操作内容等等。接下来开始进入函数相比也会轻松起来。加油吧!
函数传参
函数调用就要传参呀,那到底是怎么传的呢?
把epb-14h的值放到eax里面去
可以看到之前的eax值被填满了随机值c
运行这一步后,eax变成了20,也就是b的值
到这里,可以看到,传参先传了b的值,说明传参的顺序是从右到左的。
11.push eax
顾名思义把eax压栈到顶,当然esp也要上升
在内存中可以看到esp也变成了20
12.mov ecx
ebp-8前面存放了a,也就是说要把a放到ecx里去了,可以看到原来的ecx什么都没存。
运行一步就可以看到把a存放进去了
13.push ecx
这一次顶上又多了一个元素,同样esp也上升
可以考虑一下,步骤10到13把a和b都移动到了上面,是不是就是传参呢。答案是肯定的。
这时,形参创建了。
14.call
这里的call指令是做了什么呢,我们先记一下。哦对,也同时记一下内存中刚被传参的a的地址的上一个地址。
在这运行下一步需要按F11了,因为要进入函数了。
此时,反编译跳入了我们所写的Add函数中。
这时我们观察内存,call指令的下一条指令的地址,被压到了原本是0的这里
也就是说在执行call指令的时候,在顶上又压了个地址,是call指令的下一条指令的地址。
这是为什么呢?
这是因为:call执行的时候跳入了函数中,call执行完返回的时候,需要返回到下一个地址,不能没地方返回。
15.真正进入函数
再按一下F11
注意:这时才真正的进入了我们所写的Add函数里面
我们可以发现,函数里面一样有前面main的函数栈帧创建的步骤,push ebp到rep stos。这就是为我们的Add函数创建函数栈帧的过程。
16.push epb
17.mov ebp,esp
18.sub esp,0CCh
把esp移走,也就是说给Add函数分配了空间创建了栈帧
19.push ebx
20.push esi
21.push edi
22.lea edi,[ebp+FFFFFF34]
ebp+FFFFFF34显示符号名ebp-0CCh
23.mov ecx,33h
把33h放到ecx里
24.mov eax,0CCCCCCCCh
把0CCCCCCCCh放到eax里
25.rep stos
从edi开始,向下的ecx(33h)次,这么多个dword(一个word2个字节,double word就是双字,四个字节)数据全部改成eax(0CCCCCCCCh)。(和前面main函数栈帧一样)
此时,Add函数的栈帧创建完成
26.mov
初始化z变量,把0放到ebp-8的位置
27.mov
这时,函数创建了,但是x和y呢?
通过我们前面传参的步骤,这里就可得知,ebp+8的位置,就是传参来的a,ebp+12的位置,就是传参来的b。
所以,把ebp+8放到eax里,eax现在是10
28.add
0Ch转换成10进制就是12
ebp+12的位置是20,20加上前面eax的10,就是30,放到eax里。
现在eax是30.
29.mov
把eax也就是30,放到ebp-8里面去
形参的位置
可以看到,在进行运算的时候,2个参数根本就没有在栈帧中存放,而是存放在之前mov到的位置。
函数的传参方式
还没有调用函数的时候,参数已经先传过去了。当开始进行运算的时候,再找回之前压栈进去的参数。
形参是实参的一份临时拷贝。
30.mov
把ebp-8的值放到eax里去
栈帧销毁寄存器是不会消失的
所以,这里把值保存到了寄存器中,eax是不会销毁的,等回到主函数中再拿出来。
31.pop edi,esi,ebx
这里就好理解了,把3个寄存器都弹出
esp自然也就向下移动了,从地址上看就是esp+4+4+4
32.mov
函数已经用完了,栈帧销毁,只需一行指令
意思就是把ebp的值赋给esp,从图来看就是把esp指向下面
33.pop
这个时候,ebp的位置指向之前记录的ebp-main。
也就是说,当返回的时候,正好之前压栈记录下了main函数栈帧的位置,这时可以直接把之前存的ebp-main直接弹出,ebp就会顺利的返回main函数栈帧的位置,继续和esp维护。
34.ret
和之前的call相对应,返回之后当然是从call指令的下一条指令的地址接着运行。所以之前在栈顶压栈存了一个地址。
ret把这条地址pop,于是返回到了主函数。
此时,开头提出的所有问题,全部已经有了答案。
当然,运算好的值还存放在寄存器中。
35.add
2个参数已经没有用了
给esp加上8指向下面
这时,形参销毁了。
36.mov
这时,把存放在寄存器eax里的值放到c里面去。
4.后续
接下来打印,销毁main函数栈帧,就大同小异了。
总结
可以看到,在反汇编中,从步骤1到8是main函数的栈帧创建,然后把里面填满了随机值c,步骤9存放了a,b,c3个值,也就是初始化变量。步骤10开始函数调用,到13这4个步骤就是函数传参。步骤14记录下返回时的地址,步骤15真正进入函数。步骤16到25创建函数栈帧,步骤27到29计算完成。步骤30把运算好的值保存到寄存器中。步骤31到34函数栈帧销毁。步骤35和36把值返回。
通过一个动作一个动作的讲解,我把函数栈帧非常详尽的表述了出来。(可以说细的比吴签还细,再细,也不是这次要讲述的内容了。)
注意
1.函数栈帧创建使用过一次销毁后,下次再用,找的是代码,创建新的栈帧,而不是销毁的栈帧。
2.函数栈帧的动作,不仅要走出去,还要回得来。
3.只要一次函数调用完,无论是函数栈帧或者是参数,都会统统销毁返回给操作系统。就算主函数中还要指回去,也没用。
4.编译器会为函数预开辟空间,不会出现空间不够的情况。给每个函数预开辟的空间肯定也不一样。
5.还有其他一些问题,注意有编译器原本固定的做法,可以不用深入研究,每个编译器每次调试都可能不一样,非要搞明白的话建议自己做一个编译器。
4.此乃博主自随师从学耗良久时日编写,如果对你有帮助或者感触良多,请直接通过给出的链接喂饱我!(理直气壮)
如果还想有更加深♂刻♀的交流或者问题,可以通过下方评论或者留言板告诉我,即刻会有邮件交互(当然本地笔记没这功能)