最近看到一个问题,C语言函数递归是怎么实现的。
想知道c语言中函数递归是怎么实现的,需要深入理解c函数是怎么执行的。
本篇文章由浅入深,介绍C语言函数的调用和执行,希望看完这篇文章,能够有所收获!
本篇文章略有难度,如果对此部分没有兴趣,可以直接看通俗理解部分,可以快速帮你解决问题。如果想了解更多,欢迎阅读深入理解部分!
书归正传,C语言中函数到底是怎么调用执行的呢?
通俗理解
想通俗理解这个问题并不难,函数的每一次调用,都需要在内存中申请一份独立空间,用于存放函数形参等各种数据。
以下面这个代码为例,我们简单看一下是怎么创建的。
#include <stdio.h>
//Add函数
int Add(int x,int y)
{
c = x + y;
return x + y;
}
//main函数
int main()
{
int a = 10;
int b = 5;
int c = 0;
c = Add(a,b);
return 0;
}
首先在内存中创建main函数的空间,可以简单理解成是这样的
当在main函数执行到Add函数后,会为Add函数创建新的空间。
当Add函数返回结束后,此时Add函数的空间会被销毁,回到上一个main函数。
通过上面可以见得,函数的调用会重新开辟新的空间,Add函数中x = 10,y = 5,是新空间的变量,本质上是main函数中a和b的一份拷贝。
当Add函数函数结束后,即使销毁了x,y,依旧不会对a,b产生任何影响。
到这里,不知道你能不能理解,为什么函数递归中
反复的调用自己,依旧把函数内部的参数分的非常清晰,没有弄混。就是因为,每一次都递归调用自己都要重新开辟新的空间,都要把原空间的变量拷贝一份,结束的时候依次从上到下销毁。
下图是一次简单的函数递归调用示意图:
总结来讲就是,函数每一次调用,都会创建一个新的空间,把原空间的参数拷贝过来作为新空间的实参,当函数执行结束后,会返回执行后的值,并销毁当前的空间,回到上一个调用它的空间,递归也是如此。
深入理解
不仅仅是函数递归,你可能还会有以下疑惑:
-
局部变量是怎么实现的?
-
为什么未初始化的局部变量的值是随机的?
-
函数是怎么传参的?顺序是怎么样的?
-
形参和实参有什么样的关系?
-
函数调用是怎么做的?调用完结束后是怎么返回的?
要想深入理解函数执行的逻辑,肯定需要一点点汇编语言的基础。
首先我们要清楚的是,我们上面所说的"函数的空间",其实叫函数栈帧。
函数栈帧顾名思义,其实是在栈区中维护函数的一块空间。
如图所示:
由于栈是向下增长,所以要注意,栈底是高地址,栈顶是低地址,这都会后面的理解可能有影响!
函数栈帧的创建需要两个寄存器来维护,分别是ebp和esp用于存放地址。
esp -> 栈顶指针
ebp -> 栈底指针
esp 和 ebp 维护当前调用的函数的栈帧
我们还是用Add函数举例:
#include <stdio.h>
//Add函数
int Add(int x,int y)
{
z = x + y;
return z;
}
//main函数
int main()
{
int a = 10;
int b = 5;
int c = 0;
c = Add(a,b);
return 0;
}
这个程序分为以下几个步骤,我们依次分析
-
main函数栈帧的创建
-
main函数中变量的创建和初始化
-
传递用于Add计算的参数
-
调用Add函数
-
Add函数栈帧的创建
-
Add函数中变量的创建和初始化
-
Add函数的计算
-
Add函数的销毁
-
回到main函数计算,并销毁main函数
main函数栈帧的创建
事实上,main函数栈帧的创建并没有前面说的那么直接,以VS2013为例(越低版本的编译器越有助于我们观察过程)
main()函数也是被其它函数调用的,也就是被__tmainCRTStartup函数调用(它又被mainCRTStarup调用)
首先
为__tmainCRTStartup创建函数栈帧。
此时函数栈帧由ebp和esp维护。
那么main函数的栈帧到底如何创建呢?
开始创建main函数栈帧,注意观察这个过程。
首先进行的操作是push ebp
(压栈:就是在把ebp中的内容放到栈顶)(不理解为什么要这么做,到本文的最后会揭开谜底)。
然后调整esp存储的位置到最顶端(每一次压栈都会自动调整esp的指向),如下图。
接下来进行的操作是mov ebp,esp
简单来讲就是把esp中的内容,复制到ebp中,呈现的效果是ebp的指向将会与esp一致,如下图:
接下来进行的操作是sub esp,0e4h
上面说过栈顶是低地址,所以sub esp,0e4h
会改变esp指向的位置到栈顶处,这个操作是在为main函数栈帧预留空间,如下图:
随后
lea edi,[ebp - 0E4h]
把[ebp - 0E4h]这个地址加载到edi中,还记得不记得这个地址,其实就是我们最初为main函数预留的空间的栈顶,如图:
随后一套丝滑连招
mov ecx, 39h
mov eax, 0CCCCCCCCh
rep stos dword ptr es:edi
这段代码我们就不详细介绍了,没学过汇编语言的朋友可能不好理解,简单来说就是把ebp到edi指向的空间(也就是最开始main函数的栈帧)里面所有的内存初始化为CCCCCCCC…
想详细了解这段代码的朋友可以点击:代码分析
至此,main函数栈帧开创完毕
main函数中变量的创建和初始化
接下来就要进行的是变量的创建和初始化
首先是变量a的创建和初始化(int a = 10)
mov dword ptr [ebp - 8] , 0Ah
这段代码的含义是把0Ah(其实就是十进制的10,很明显这里存放的是a变量)放入ebp-8(也就是移动4字节)的位置中去,如图
此时ebp-8的位置存放的是十六进制数0a,也就是十进制数10,也就是变量a的大小。
以上就是main函数中变量a的创建和初始化,这也印证了,如果变量不进行初始化,确实会被赋予随机值,也就是这里的cccc…
接下来进行变量b和c的创建和初始化(int b = 5; int c = 0;)
mov [ebp-14h], 5
b变量
mov [ebp - 20h],0
c变量
同样的道理,结果如下图。
以上就是main函数栈帧以及变量的初始化过程,总结来讲就是,先压栈,为main函数创建栈帧,再压入几个变量,再把main函数里面都初始化为ccccc…,再给局部变量分配空间及初始化。
执行Add函数之前的准备步骤(传参)(Add(a,b))
在上面的代码中,执行Add的语句是Add(a,b);
在正式为Add函数创建栈帧之前,首先要进行的是如下操作:
mov eax,[ebp - 14h]
把a变量放到eax寄存器中
push eax
把eax压进栈
mov ecx,[ebp - 8h]
把b变量放到ecx寄存器中
push ecx
把ecx压进栈
如图所示
很明显,上面这个步骤就是在传参。
接下来,我们再次梳理一下大致流程
1.执行main函数
2.创建a,b,c变量并初始化
3.传参
4.调用add函数(此时已经跳出main函数)
5.add函数调用结束(此时已经跳出main函数)
6.返回main函数并打印
7.main函数执行结束
不知你是否有疑惑,前面在main函数内,程序都是顺序执行的,调用add函数以后,将会跳到add函数所在的空间,那add函数执行结束后,程序是怎么找到回来的位置的?
在汇编中,调用函数的指令是call,这里调用call指令后,会跳进Add函数的内部。
这个时候我们就需要把call指令的下一条指令的地址压入栈中,以便于程序可以找到来时的路,如图所示
至此,main函数的栈帧全部内容补充完毕。
add函数栈帧的创建
与main函数栈帧创建的过程一样
所以不再赘述,这里面我们就快一点画图
push ebp
mov ebp,esp
sub esp,0CCh
push ebx
push esi
push edi
上面的代码和前面一模一样,结果如下图
lea edi,[ebp-0CCh]
mov ecx,33h
mov eax,0CCCCCCCCh
rep stos dword ptr es:[edi]
还记得不记得上面说的一套"丝滑连招",没错,它的功能同样是初始化函数内部空间为CCCC…
变量z的初始化
mov [ebp - 8],0
和上面一样,这段代码是为z开启空间并初始化
如图
说了这么多之后,我们可以从图中看到a,b,c,z变量都已经在内存中开辟好了空间并初始化,那x,y变量呢,他们在哪里?
看一下Add函数是怎么计算的自然就知道啦。
Add函数计算
mov eax,[ebp+8]
add eax,[ebp+0Ch]
这段代码的意思是,把[ebp+8]
位置的量放到eax寄存器中,随后将它与[ebp+0Ch]
位置的量相加,并存到eax寄存器中。
那[ebp+8]
和[ebp+0Ch]
究竟在哪里呢?看图即可:
我们发现,这两个位置正好是刚刚调用Add函数之前传参的位置,也就是传入的参数十进制10和5的位置!
随后dword ptr [ebp-8],eax
把eax中的计算结果,赋值给z变量
为了防止后续返回值z会随着Add函数销毁而消失,所以要把返回值z要存回eax寄存器中(寄存器中的值不会因为Add函数销毁而消失)。
mov eax,[ebp - 8]
计算结束!现在eax寄存器中,存放的就是Add函数的返回值15。
这是计算结束后的图示:
add函数栈帧的销毁
计算结束,接下来就是Add函数栈帧的销毁
函数栈帧的销毁也是依赖于ebp和esp指向的改变。
pop edi
把edi这里面的值弹到edi寄存器中
pop esi
把esi这里面的值弹到esi寄存器中
pop ebx
把ebx这里面的值弹到e寄存器中
pop "弹出"意思就是把栈顶的元素弹走到指定寄存器中,那上面这连续三个pop,效果是这样的:
到现在为止,我们还没有完全销毁Add函数,接下来的这个操作可谓巧妙绝伦:
mov esp,ebp
把ebp存到esp中,这样我们很好的找到main函数栈顶的位置。
pop ebp 这一步非常重要!直接通过esi找到栈顶元素ebp(main)(存放的是main函数的栈底地址)把它弹出到ebp寄存器中,这样我们直接找到了main函数栈底的位置,也改变了ebp的指向!
经过这一套改变ebp和esp指向的操作,完美的销毁了Add函数所在的空间。
回到main函数计算,并销毁main函数
接下来是ret
指令:弹出call指令的下一条地址并回到call指令的下一条指令的地址处,我们很好的找到了下一条指令的地址,并且又改变了esp寄存器的指向。
但是在这里我们发现,在现在的main函数的栈帧内,之前创建的形参依然存在,我们需要消除它。
所以要继续改变esp的指向
add esp,8
现在,main函数的栈帧彻底回到了最初的模样。
mov [ebp - 20h],eax
最后把eax中存放的返回值15,放到变量c所在的位置中。
最终成效如下:
最后最后就是销毁main函数的空间,和销毁Add函数同理。
问题思考
到这里你应该能够对下面问题有自己的理解啦
- 局部变量是怎么实现的?
局部变量其实就是我们函数内部开辟空间后,在函数内部取的一块空间,对这个空间赋值。局部变量在函数空间内,也就是说函数执行结束被销毁,局部变量也会随之不见。
- 为什么未初始化的局部变量的值是随机的?
我们注意到,函数栈帧开辟之后,内部的空间被赋值成cccc...,其实这就是随机值的由来。
- 函数是怎么传参的?顺序是怎么样的?
函数传递的参数,其实就是我们在调用新的函数之前,开辟这个函数栈帧之前,在原函数栈顶push的几个值,顺序也是从上到下依次push。
- 形参和实参有什么样的关系?
我们可以看到,所谓的形参,其实就是原函数实参的一份临时拷贝,它的改变不会对原函数的参数有任何的影响。
- 函数调用是怎么做的?调用完结束后是怎么返回的?
这个就不多说啦,原文中有更清楚的解释!
本文结束,希望对你有帮助,码字画图不易,感谢你的支持!