前言:相信大家对C语言中的函数并不陌生,通过函数我们可以使代码更加简洁、可读性更高、复用性更高等。关于对C语言中函数的具体介绍感兴趣的朋友们可以看看支持一下博主的这篇文章【逐步剖C】第二章-函数,而本文将展示关于函数调用更深层次一些的东西,所以本文内容较干,看完并理解可能需要一定的耐心和精力,不过相信你在看完并理解后对C语言中的函数调用尤其是递归将有会一个新的认识,以后将会以一个全新的视角来看待函数的调用。
那么话不多说,让我们开始吧。
栈(stack)是现代计算机程序里最为重要的概念之一,几乎每一个程序都使用了栈,没有栈就没有函
数,没有局部变量,也就没有我们如今看到的所有的计算机语言。
在经典的计算机科学中,栈被定义为一种特殊的容器,用户可以将数据压入栈(存储到栈)中(入栈,push),也可以将已经压入栈中的数据弹出(取出)(出栈,pop),但是栈这个容器必须遵守一条规则:先入栈的数据后出(First In Last Out,FIFO)
栈就像叠成一叠的书,先叠上去的书在最下面,因此要最后才能取出。在计算机系统中,栈则是一个具有以上属性的动态内存区域。程序可以将数据压入栈中,也可以将数据从栈顶弹出。压栈操作使得栈增大,而弹出操作使得栈减小。(因为在经典的操作系统中,栈总是向下增长(由高地址向低地址)的)。
在我们常见的i386或者x86-64系统下,栈顶由成为 esp 的寄存器进行定位的。
函数栈帧(stack frame)就是函数调用过程中在程序的调用栈(call stack)所开辟的空间,这些空间用来存放:
函数参数和函数返回值;
临时变量(包括函数的非静态的局部变量以及编译器自动生产的其他临时变量);
保存上下文信息(包括在函数调用前后需要保持不变的寄存器)。
- 局部变量是如何创建的
- 所谓不初始化所得到的随机值的本质是什么
- 函数在进行调用时参数的具体传递方式是什么
- 为什么说改变形参不会影响实参
- 函数的返回值是如何带回的
那么接下来,容我为大家正式地介绍函数栈帧的创建和销毁的过程。
前言:由于是从反汇编的角度分析,故需要理解一些基本的汇编指令以及一些寄存器的功能,不过这里无需完全理解每条指令的具体用法或某个寄存器的全部功能等,重点是要理解整个函数的调用和返回的过程。
其中最需要我们注意的就是前三个寄存器,他们是整个过程中的关键角色。相信这里大家只看对寄存器的介绍可能会感到非常生硬和陌生,在后面的具体过程的介绍中大家再来慢慢体会到每个寄存器的作用。
同样地,但看这些指令大家可能会不明所以,但是没关系,在后面的具体过程的介绍中大家会慢慢理解每条指令的作用。
结合本部分所述内容与上一部分对栈与栈帧概念的介绍我们可以得到如下函数在调用时的栈帧空间示意图:
下面是整个说明过程所使用的代码,请看:
int Add(int x, int y)
{
int z = 0;
z = x + y;
return z;
}
int main()
{
int a = 3;
int b = 5;
int ret = 0;
ret = Add(a, b);
printf("%d\n", ret);
return 0;
}
(PS:其实只介绍到ret = Add(a, b);
语句为止,因为此时函数大体的调用和返回的逻辑已经展现完成,这也是本文着重想介绍的)
我们在调试下调用堆栈的窗口下可以看到,main函数其实也是由其他函数调用的:
这里先做个知识的补充即可,我们暂且就先只关注invoke_main
函数(直接调用main函数的函数),来介绍main函数函数栈帧的开辟过程。
下面先列出本次函数调用过程所用的汇编代码,并在后文逐句进行解析,请看:
int main()
{
00672B30 push ebp
00672B31 mov ebp,esp
00672B33 sub esp,0E4h
00672B39 push ebx
00672B3A push esi
00672B3B push edi
00672B3C lea edi,[ebp-24h]
00672B3F mov ecx,9
00672B44 mov eax,0CCCCCCCCh
00672B49 rep stos dword ptr es:[edi]
int a = 3;
00672B4B mov dword ptr [ebp-8],3
int b = 5;
00672B52 mov dword ptr [ebp-14h],5
int ret = 0;
00672B59 mov dword ptr [ebp-20h],0
ret = Add(a, b);
00672B60 mov eax,dword ptr [ebp-14h]
00672B63 push eax
00672B64 mov ecx,dword ptr [ebp-8]
00672B67 push ecx
00672B68 call 006710EB
00672B6D add esp,8
00672B70 mov dword ptr [ebp-20h],eax
printf("%d\n", ret);
00672B73 mov eax,dword ptr [ebp-20h]
00672B76 push eax
00672B77 push 679C24h
00672B7C call 00671109
00672B81 add esp,8
return 0;
00672B84 xor eax,eax
}
这里大家可以先关注一个点:每条汇编指令其实都对应着一个地址,通过地址可以根据需要执行指定的汇编指令。
main函数栈帧的开辟为下面这几行汇编代码:
00672B30 push ebp
00672B31 mov ebp,esp
00672B33 sub esp,0E4h
00672B39 push ebx
00672B3A push esi
00672B3B push edi
00672B3C lea edi,[ebp-24h]
00672B3F mov ecx,9
00672B44 mov eax,0CCCCCCCCh
00672B49 rep stos dword ptr es:[edi]
00672B30 push ebp
0x004ff938
,而0x004ff938
这块空间中的内容是调用invoke_main
函数的函数的ebp,(PS:这里的理解逻辑很重要,后面还会提到,可以先注意一下,至于调用invoke_main函数的函数是什么我们可以先不关注)也就是图中的0x004ff994
。也就是说,ebp是一个“指针”,通过“解引用”ebp就得到了调用invoke_main
函数的函数的ebp。0x004ff938
这里需要注意的是,此时只是将ebp的值进行压栈,栈底指针ebp实际所指向的地方并没有发生改变,故其值也就没有发生变化。
00672B31 mov ebp,esp
invoke_main
函数的ebp)。00672B33 sub esp,0E4h
0E4h
,即让esp向上移动了一大段空间,此时的esp就是main函数栈帧的esp,那么在上面的基础上,此时的ebp和esp就是维护main函数栈帧的栈顶指针和栈顶指针。00672B39 push ebx; 00672B3A push esi; 00672B3B push edi
00672B3C lea edi,[ebp-24h]
00672B3F mov ecx,9
00672B44 mov eax,0CCCCCCCCh
00672B49 rep stos dword ptr es:[edi]
如上四条指令都都用于main函数的初始化,故此处也在一起进行说明。
00672B3C lea edi,[ebp-24h]
:00672B3F mov ecx,9
:00672B44 mov eax,0CCCCCCCCh
:执行的结果是将0CCCCCCCCh放入寄存器eax中00672B49 rep stos dword ptr es:[edi]
:0xCCCCCCCC
,而具体的初始化方式是:从edi所指向的内存开始,通过edi不断加4(向栈底移动),每移动一次就将所指向空间中的内容更改为0xCCCCCCCC
,直到ebp为止(不包括ebp)。上面main函数栈帧初始化的过程其实可以等价为以下伪代码:
edi = ebp - 24h;
ecx = 9;
eax = 0xcccccccc;
for( ; ecx > 0; --ecx, edi+=4)
{
*edi = eax;
}
//这里的ecx可以理解为次数
至此,main函数栈帧的开辟与初始化工作就完成了。
在开始介绍main函数中语句的执行之前补充两点:
烫烫烫烫
的结果,这是因为所输出空间的内容尚未经我们自己初始化,而只经编译器初始化为了0xCCCCCCCC
,即在栈区的空间中每一个字节的内容都为0xCC
,而汉字“烫”对应的编码就为0xCCCC
(两个连续排列的0xCCCC
,一个字的大小为两个字节),故最后屏幕上会出现经典的 “烫烫烫”接下来我们继续介绍main函数中的语句执行。
在调用Add函数之前,main函数中语句对应的反汇编指令是这几句:
int a = 3;
00672B4B mov dword ptr [ebp-8],3
int b = 5;
00672B52 mov dword ptr [ebp-14h],5
int ret = 0;
00672B59 mov dword ptr [ebp-20h],0
如上三条语句执行的过程本质是一样的,把对应的值放对应的位置上,即:
将3存储到ebp-8的地址处;
将5存储到ebp-14h的地址处;
将0存储到ebp-20的地址处;
(PS:这里中间隔多少大小也取决于编译器)
三条语句执行前对应的内存信息为:
执行后为:
那么其实以上汇编代码是对局部变量a,b,ret的创建与初始化的过程,可以看出,局部变量的创建是在其所在函数的栈帧空间中创建的。
接下来我们进入Add函数的调用
前言:其实Add函数栈帧的开辟过程从大体上来说和main函数栈帧的开辟大同小异,其实所有函数栈帧的开辟都是如此:先将直接调用它的函数的ebp的值压栈,然后将指针esp的值赋给ebp,让ebp和esp指向同一块空间,然后再让esp减去一个十六进制数(具体减多少由编译器决定),减完后的esp和ebp之间的空间(或者说维护的空间)即为该被调用函数的栈帧空间。
那么下面同样以介绍main函数栈帧的开辟的方式介绍Add函数栈帧的开辟。
在真正进入Add函数的调用逻辑之前会先进行函数参数的传递:
ret = Add(a, b);
00672B60 mov eax,dword ptr [ebp-14h]
00672B63 push eax
00672B64 mov ecx,dword ptr [ebp-8]
00672B67 push ecx
00672B68 call 006710EB
00672B6D add esp,8
00672B70 mov dword ptr [ebp-20h],eax
如上代码中的前四句就是是函数传参的过程:
将ebp-14h地址处的值(由前面所述其实就是b的值)放到寄存器eax中,再将eax压栈,esp-4;
将ebp-8地址处的值(由前面所述其实就是a的值)放到寄存器ecx中,再将ecx压栈,esp-4;
前四条语句执行前对应的内存信息为:
执行后为:
内存示意图:
从这里我们又可以得到一个知识点:函数的传参是从右到左进行的。而且从这里我们其实就可以知道,改变形参的值并不会改变实参,因为从图中可以很明显看出它们在栈区上并不是使用同一块内存空间,即形参其实是实参的一份临时拷贝,改变形参不会影响实参。
那么如上只是进行函数参数的传递,Add的函数栈帧其实还没有开辟,那么从这我们又可以获得一个小知识,函数参数的传递其实是先于函数栈帧的开辟的,也就是说,函数的形参实际上是不在该函数的栈帧空间中的。
接下来的一条指令00672B68 call 006710EB
才会真正进入到Add函数中并开始Add函数栈帧的开辟。
这里做一个关于call指令的补充:call指令是要执行函数调用的逻辑的,在执行执行相应函数调用之前先会把call指令下一条指令的地址进行压栈(在上面一张的内存图中就是00672B6D
),这样做的目的是方便函数调用结束后回到call指令下一条指令的地方而继续往后执行。
call指令前面的地址是本条指令的地址,后面的地址是对应的jmp指令的地址
下面是将call指令下一条指令压栈后的内存信息:
图中蓝框中jmp指令的地址就是原来call指令后面的地址,而jmp指令后面的地址也将会使程序跳转到相应的指令处(其实就是Add函数的内部)来为Add函数开辟函数栈帧。
跳转到Add的函数调用逻辑后,其全部反汇编代码如下:
int Add(int x, int y)
{
00672890 push ebp
00672891 mov ebp,esp
00672893 sub esp,0CCh
00672899 push ebx
0067289A push esi
0067289B push edi
int z = 0;
0067289C mov dword ptr [ebp-8],0
z = x + y;
006728A3 mov eax,dword ptr [ebp+8]
006728A6 add eax,dword ptr [ebp+0Ch]
006728A9 mov dword ptr [ebp-8],eax
return z;
006728AC mov eax,dword ptr [ebp-8]
}
006728AF pop edi
006728B0 pop esi
006728B1 pop ebx
006728B2 mov esp,ebp
006728B4 pop ebp
006728B5 ret
Add函数反汇编代码中的前六句就是函数栈帧的开辟:
00672890 push ebp
00672891 mov ebp,esp
00672893 sub esp,0CCh
00672899 push ebx
0067289A push esi
0067289B push edi
第一条语句00672890 push ebp
:
先将调用它的函数的ebp,也就是main函数的ebp的值压栈,esp-4
执行前寄存器即内存具体信息为:
执行后:
内存示意图:
第二条语句00672891 mov ebp,esp
将esp的值给ebp,让ebp和esp指向同一块空间
在上面基础上语句执行后内存具体信息为:
内存示意图:
第三条语句00672893 sub esp,0CCh
让栈顶指针减去一个16进制数0CCh
,即让esp向上移动了一段空间,此时的esp就是Add函数栈帧的esp,那么在上面的基础上,此时的ebp和esp就是维护main函数栈帧的栈顶指针和栈顶指针。
语句执行后具体内存信息为:
内存示意图:
随后三条语句00672899 push ebx; 0067289A push esi; 0067289B push edi
:
三条语句分别将ebx,esi与edi的值压栈,同时栈顶指针-12。
语句执行前各相关寄存器的值情况为:
三条语句执行完后具体内存信息为:
内存示意图:
至此,Add函数栈帧的开辟完成,接下来开始Add函数中语句的执行
0067289C mov dword ptr [ebp-8],0
//对应z = 0;
006728A3 mov eax,dword ptr [ebp+8]
006728A6 add eax,dword ptr [ebp+0Ch]
006728A9 mov dword ptr [ebp-8],eax
//对应z = x + y;
先将ebp-8地址处的内容更改为0;接着将ebp+8地址处的值存储到寄存器eax中;再将ebp+0Ch处的地址值与寄存器eax中的相加;最后将寄存器eax中的值存储到ebp-8的地址处。
语句执行前对应具体内存信息为:
(PS:可以看到这里ebp+8地址处的值与ebp+0Ch地址处的值刚好就是Add函数传参阶段压入栈的值)
执行后:
内存示意图:
006728AC mov eax,dword ptr [ebp-8]
将ebp-8地址处的值存放再寄存器eax中,也就是将z的值存到eax中,通过eax寄存器带回函数计算的结果(因为存放z的值的空间要销毁(还给操作系统)了,而寄存器相当于是全局的)值存放完毕后,将开始进行函数的返回(函数栈帧的销毁)
Add函数通过如下六条汇编指令进行返回:
006728AF pop edi
006728B0 pop esi
006728B1 pop ebx
006728B2 mov esp,ebp
006728B4 pop ebp
006728B5 ret
可以发现,前三条汇编指令都是pop,也就是出栈。所以前三条语句执行的结果分别是:
在栈底弹出一个值,将该值赋给edi,esp+4;
在栈底弹出一个值,将该值赋给esi,esp+4;
在栈底弹出一个值,将该值赋给ebx,esp+4;
语句执行前具体内存信息为:
执行后:
内存参考图:
这里似乎只有栈顶指针esp的值发生了改变,不过也确实如此,对比在进行Add函数调用之前main函数中edi、esi和ebx的值来看,三个寄存器在Add函数中与main函数中的值其实是相等的。
Add函数栈帧空间的回收只需要一条指令:006728B2 mov esp,ebp
即将此时ebp的值赋给esp,也就是让esp和ebp指向同一块空间。
语句执行后具体内存信息为:
内存参考图:
Add函数通过最后两句汇编指令进行最终返回:
006728B4 pop ebp
006728B5 ret
首先通过指令006728B4 pop ebp
弹出栈顶的值放到ebp中,由前面一系列过程我们知道,栈顶的值恰好是main函数自己的ebp的值,此时将栈顶的值赋值给ebp也就是让此时的ebp指向了原来main函数栈帧中ebp的位置,即恢复了main函数栈帧的维护,esp+4后指向main函数栈帧的栈顶,ebp指向main函数栈帧的栈底。
语句执行后具体内存信息为:
内存示意图:
至此,Add函数栈帧的销毁就完成了。
最后还需要通过指令ret进行函数的最终返回以执行调用Add函数的函数(这里采用这种说法,或者说是理解方式可以更好地理解函数递归的过程),也就是main函数中之后的内容。
那么这里对ret指令做一个补充说明:
ret指令的执行,会从栈顶弹出一个值,而此时栈顶要弹出的值(也就是esp所指向的空间的值)刚好就是调用Add函数的call指令的下一条指令的地址,那么此时就直接跳转到call指令下一条指令的地址处,继续往下执行。
语句执行后具体内存信息为:
内存示意图:
返回后的剩余指令为:
00672B6D add esp,8
00672B70 mov dword ptr [ebp-20h],eax
(PS:由于到这一部分本文重点想介绍的内容已经介绍的差不多了,故这里就只介绍到将返回值赋给变量ret,之后关于ret值的打印与main函数栈帧的销毁等本文就不再进行说明啦,感兴趣的朋友可以自行研究一下)
通过指令00672B6D add esp,8
让esp直接加8,相当于直接跳过了之前作为形参压栈的3和5。
语句执行后具体内存信息为:
内存示意图:
通过指令00672B70 mov dword ptr [ebp-20h],eax
将寄存器eax中的值赋给(或者说“替换”)ebp-20h地址处(其实也就是变量ret)的值;由之前的内容我们知道,eax中的值其实就是Add函数中计算3+5的和;也就是说Add函数的返回值是通过 “全局” 作用的寄存器eax带回来的,而程序也通过该寄存器获取函数的返回值。
语句执行后具体内存信息为:
那么,本文想重点介绍的内容至此就接近尾声啦,希望本文能为朋友们提供更多一些关于函数调用的知识与看待函数调用的新视角。
本章完。
看完觉得有觉得帮助的话不妨点赞收藏鼓励一下,有疑问或有误地方的地方还恳请过路的朋友们留个评论,多多指点,谢谢朋友们!