在我们刚开始学习C语言的时候,我们可能还有很多困惑的地方。
比如:
局部变量是怎么创建的?
为什么局部变量的值是随机值?
函数是怎么传参的,传参的顺序是怎么样的?
形参和实参是什么关系?
函数调用是怎么做的?
函数调用结束后是怎么返回的?
当看完今天这篇文章之后,一切都将豁然开朗。
目录
前言
预备知识
1.栈区的使用习惯
2.常见的几个寄存器
3.常用的汇编指令
2.变量的创建以及函数传参
3.Add函数栈帧的开辟
4.函数栈帧的销毁
前言
本人今天使用编译器的VS2013,我没有使用VS2019的原因是:越高级的编译器,越不容易去学习和观察,同时在不同的编译器下,函数调用过程中栈帧的创建是略有差异的,具体细节取决于编译器的实现。
预备知识
在正式开始之前还需要先了解以下几个小知识,以便于更好地去理解函数栈帧的创建与销毁。
1.栈区的使用习惯
栈区的使用习惯是会先使用高地址处的空间,再使用低地址处的空间。每个函数的调用,都需要在栈区中开辟一个空间,而这个空间的开辟,都会按照高地址向低地址的方向执行。也就是说在调用函数的时候,会先在栈区的高地址处开辟空间。
2.常见的几个寄存器
寄存器是CPU内部用来存放数据的一些小型存储区域,用来暂时存放参与运算的数据和运算的结果。我们常见的寄存器有:eax,ebx,ecx,edx,ebp以及esp等等。我们今天的两个主角便是ebp和esp。ebp,esp这两个寄存器中存放的是地址,这两个地址是用来维护函数栈帧的。ebp通常指向栈底,存储栈底地址,因此又被称为栈底指针。esp通常指向栈顶,存储栈顶地址,因此又被称为栈顶指针。
3.常用的汇编指令
1.add:加法指令,第一个是目标操作数,第二个是源操作数,格式为:目标操作数=目标操作数+源操作数;
2.sub:减法指令,格式同add一样;
3.call:调用函数,一般函数的参数放在寄存器中;
4.ret:跳转会调用函数的地方。对应于call,返回到对应的call调用的下一条指令,若有返回值,则放入eax中;
5.push:把一个32位的操作数压入堆栈中,这个操作在32位机中会使得esp被减去4个字节,esp通常是指向栈顶的(前面有说到),栈中顶部是地址小的区域,那么,压入堆栈的数据越多,esp也就越来越小。
6.pop:与push相反,每当有一个数据push(出栈),esp每次加4给四节。
7.mov:数据传送,第一个参数是目的操作数,第二个是源操作数,它的作用就是把第二个参数拷贝到第一个参数。
8.lea:取得第二个参数地址后放入到前面的寄存器(第一个参数)中。
下面我们通过这段代码和对应的汇编语言来详细的解释函数栈帧的创建与销毁
#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\n", c);
return 0;
}
int main()
{
00E41410 push ebp
00E41411 mov ebp,esp
00E41413 sub esp,0E4h
00E41419 push ebx
00E4141A push esi
00E4141B push edi
00E4141C lea edi,[ebp+FFFFFF1Ch]
00E41422 mov ecx,39h
00E41427 mov eax,0CCCCCCCCh
00E4142C rep stos dword ptr es:[edi]
int a = 10;
00E4142E mov dword ptr [ebp-8],0Ah
int b = 20;
00E41435 mov dword ptr [ebp-14h],14h
int c = 0;
00E4143C mov dword ptr [ebp-20h],0
c = Add(a, b);
00E41443 mov eax,dword ptr [ebp-14h]
c = Add(a, b);
00E41446 push eax
00E41447 mov ecx,dword ptr [ebp-8]
00E4144A push ecx
00E4144B call 00E410E1
00E41450 add esp,8
00E41453 mov dword ptr [ebp-20h],eax
printf("%d\n", c);
00E41456 mov esi,esp
00E41458 mov eax,dword ptr [ebp-20h]
00E4145B push eax
00E4145C push 0E45858h
00E41461 call dword ptr ds:[00E49114h]
00E41467 add esp,8
00E4146A cmp esi,esp
00E4146C call 00E4113B
return 0;
00E41471 xor eax,eax
}
00E41473 pop edi
00E41474 pop esi
00E41475 pop ebx
00E41476 add esp,0E4h
00E4147C cmp ebp,esp
00E4147E call 00E4113B
00E41483 mov esp,ebp
00E41485 pop ebp
00E41486 ret
通过在VS2013上面对该代码调试与调用堆栈我们发现main是被调用的,那么是被谁调用的呢?下面就以这几张图片来回答(有图有真相嘛)
?
?知道了main函数被谁调用之后,我们再通过上面代码与汇编指令来详细讲一下main函数-函数栈帧的创建与销毁。
00E41410 push ebp
00E41411 mov ebp,esp
00E41413 sub esp,0E4h
00E41419 push ebx
00E4141A push esi
00E4141B push edi
00E4141C lea edi,[ebp+FFFFFF1Ch]
00E41422 mov ecx,39h
00E41427 mov eax,0CCCCCCCCh
00E4142C rep stos dword ptr es:[edi]
?首先是push ebp意思就是将ebp这个参数压进去,因为esp一般是指向栈顶的所以当ebp压进去之后esp也会指向ebp的位置。这时esp会向上走,那么它的地址会变小(push一个参数,32位的,esp会减去4个字节,16位则减去两个),通过调用监视窗口我们也可以观察到esp的的值确是在变小
?第二步move ebp,esp 就是把esp的值给ebp,即把ebp的地址指向改为esp的地址。通过观察监视窗口我们也可以看到
再然后就是sub esp,0E4h 意思就是将esp减去0E4h,这时esp就会往上走。此时ebp与esp之间维护的这块空间就是为main函数开辟的栈帧。然后再push ebx,esi,edi则是把ebx,esi,edi压栈,每压一个参数esp的地址就会减去4个字节,此时esp会指向edi的位置上。
?通过调用监视窗口内存窗口我们可以发现ebx,esi,edi的三个值被压进去了。
然后我们再来看看接下来的四步(此时在VS2013里面打开了显示符号名)
00E4141C lea edi,[ebp-0E4h]
00E41422 mov ecx,39h
00E41427 mov eax,0CCCCCCCCh
00E4142C rep stos dword ptr es:[edi]
这段的作用是:(1)ebp-0E4h的地址放到edi里面去
? ? ? ? ? ? ? ? ? ? ? ?(2)把39h放到ecx里面去
? ? ? ? ? ? ? ? ? ? ? ?(3)把0CCCCCCCCh放到eax里面去
? ? ? ? ? ? ? ? ? ? ? ?(4)从edi这个位置开始以下的39h个4字节都变成eax也就是CCCCCCCC
简而言之就是将ebp到edi-0E4h这段空间都初始化成CCCCCCCC,而CCCCCCCC就是烫对应的乱码。这也就解释了为什么局部变量没有初始化的时候是随机值的原因。
?
?此时为main函数栈帧的开辟就已经准备好了。下面就要来执行正式有效的代码啦。
2.变量的创建以及函数传参
int a = 10;
00E4142E mov dword ptr [ebp-8],0Ah
int b = 20;
00E41435 mov dword ptr [ebp-14h],14h
int c = 0;
00E4143C mov dword ptr [ebp-20h],0
这段的作用是:(1)将0Ah(也就是10)放到ebp-8的位置上去
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? (2)将14h(也就是20)放到ebp-14h的位置上去
? ? ? ? ? ? ? ? ? ? ? ? ? ? ? (3)将0放到ebp-20h的位置上去
这就是变量创建的过程。?
接下来我们就要调用Add函数以及传参了
c = Add(a, b);
00E41443 mov eax,dword ptr [ebp-14h]
00E41446 push eax
00E41447 mov ecx,dword ptr [ebp-8]
00E4144A push ecx
00E4144B call 00E410E1
00E41450 add esp,8
00E41453 mov dword ptr [ebp-20h],eax
printf("%d\n", c);
将ebp-14h(也就是20)放到eax里面去,然后将eax压入栈中。将ebp-8(也是就10)放到ecx里面去,接着将ecx压入栈中。其实这就是在传参!!!但是我们可能还感受不到,我们接着看后面,call 执行之后就会将它下一条指令的地址给压进去
3.Add函数栈帧的开辟
int Add(int x, int y)
{
00E413C0 push ebp
00E413C1 mov ebp,esp
00E413C3 sub esp,0CCh
00E413C9 push ebx
00E413CA push esi
00E413CB push edi
00E413CC lea edi,[ebp+FFFFFF34h]
00E413D2 mov ecx,33h
00E413D7 mov eax,0CCCCCCCCh
00E413DC rep stos dword ptr es:[edi]
接着我们再来看这段指令,是不是感觉有点熟悉呢?对,没错 这段指令就是在为Add函数开辟栈帧
这里就不再重复赘述了,这里我们直接看执行完后的图是怎样的。
int z = 0;
00E413DE mov dword ptr [ebp-8],0
z = x + y;
00E413E5 mov eax,dword ptr [ebp+8]
00E413E8 add eax,dword ptr [ebp+0Ch]
00E413EB mov dword ptr [ebp-8],eax
return z;
00E413EE mov eax,dword ptr [ebp-8]
这段指令的意思是把0的值放到ebp-8(z)的位置,再将ebp+8的值(也就是之前ecx=10这个值)放到eax里面,然后再将ebp+0ch的值(也就是之前eax=20这个值)加上eax现在的值,最后再将我们eax现在的值放到ebp-8(z)里面,此时我们z的值就是30了。
?再然后把ebp-8(也就是z的值)放到ebx里面去。
到这我们就真正清楚了函数是如何传参的以及传参的顺序。同时也明白了为什么说形参是实参的一份临时拷贝。?
4.函数栈帧的销毁
return z;
00E413EE mov eax,dword ptr [ebp-8]
}
00E413F1 pop edi
}
00E413F2 pop esi
00E413F3 pop ebx
00E413F4 mov esp,ebp
00E413F6 pop ebp
00E413F7 ret
? ? ? ? ? ? 接下来分别对edi,esi,ebx pop也就是将他们弹出栈,再将ebp值赋给esp,然后再把ebp弹到原main函数的最下面的地方,esp指向00E41450,此时又回到了main函数里头。?
??
?
?
?最后的ret指令,就是弹出栈顶(call指令下一条指令的地址),回到main含函数中call指令发出的地方,从而使得我们能够返回去,使得整个程序按流程继续下去。
00E41450 add esp,8
00E41453 mov dword ptr [ebp-20h],eax
printf("%d\n", c);
紧接着esp往下加8个字节,再将eax存放的z值(30)给ebp-20h(c).此时我们的形参就销毁了,并且z值也返回了。
到这我想你们对于我刚开始所问的几个问题心里应该已经有了答案。
如果觉得对你有用的话可以点赞关注一波哦!
|