深度剖析C语言函数的传参和执行的细节

最近看到一个问题,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函数的空间,可以简单理解成是这样的

cd64b2b5920b4d5c9b301180ee71e371.png

当在main函数执行到Add函数后,会为Add函数创建新的空间。

32921b77066c4ae190dbbf5cd1cc9108.png

当Add函数返回结束后,此时Add函数的空间会被销毁,回到上一个main函数。

c550786b2deb4a4b9d3092625b25fb36.png

通过上面可以见得,函数的调用会重新开辟新的空间,Add函数中x = 10,y = 5,是新空间的变量,本质上是main函数中a和b的一份拷贝

当Add函数函数结束后,即使销毁了x,y,依旧不会对a,b产生任何影响。

到这里,不知道你能不能理解,为什么函数递归中

反复的调用自己,依旧把函数内部的参数分的非常清晰,没有弄混。就是因为,每一次都递归调用自己都要重新开辟新的空间,都要把原空间的变量拷贝一份,结束的时候依次从上到下销毁。

下图是一次简单的函数递归调用示意图:

59cc499a35a543578e8cf0d2ca9c4e82.png

总结来讲就是,函数每一次调用,都会创建一个新的空间,把原空间的参数拷贝过来作为新空间的实参,当函数执行结束后,会返回执行后的值,并销毁当前的空间,回到上一个调用它的空间,递归也是如此

深入理解

不仅仅是函数递归,你可能还会有以下疑惑:

  • 局部变量是怎么实现的?

  • 为什么未初始化的局部变量的值是随机的?

  • 函数是怎么传参的?顺序是怎么样的?

  • 形参和实参有什么样的关系?

  • 函数调用是怎么做的?调用完结束后是怎么返回的?

要想深入理解函数执行的逻辑,肯定需要一点点汇编语言的基础。

首先我们要清楚的是,我们上面所说的"函数的空间",其实叫函数栈帧

函数栈帧顾名思义,其实是在栈区中维护函数的一块空间。

如图所示:

72a1c22d7ef64d5b8edf78c97350f27f.png

由于栈是向下增长,所以要注意,栈底是高地址,栈顶是低地址,这都会后面的理解可能有影响!

函数栈帧的创建需要两个寄存器来维护,分别是ebpesp用于存放地址。

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维护。

2ddf2017d3d6428486eba8ba51215ce1.png

那么main函数的栈帧到底如何创建呢?

开始创建main函数栈帧,注意观察这个过程。

首先进行的操作是push ebp(压栈:就是在把ebp中的内容放到栈顶)(不理解为什么要这么做,到本文的最后会揭开谜底)。

然后调整esp存储的位置到最顶端(每一次压栈都会自动调整esp的指向),如下图。

9ba189afc6eb4e2baab6e853364fcd6f.png

接下来进行的操作是mov ebp,esp

简单来讲就是把esp中的内容,复制到ebp中,呈现的效果是ebp的指向将会与esp一致,如下图:

1c13faa1bb164529a973d53ce9fb6961.png

接下来进行的操作是sub esp,0e4h

上面说过栈顶是低地址,所以sub esp,0e4h会改变esp指向的位置到栈顶处,这个操作是在为main函数栈帧预留空间,如下图:

680864c5651c4353bb80a6a8ce054ac0.png

随后

lea edi,[ebp - 0E4h]

把[ebp - 0E4h]这个地址加载到edi中,还记得不记得这个地址,其实就是我们最初为main函数预留的空间的栈顶,如图:

412b984028664f3fbc11fed5fc52f19e.png

随后一套丝滑连招

mov ecx, 39h

mov eax, 0CCCCCCCCh

rep stos dword ptr es:edi

这段代码我们就不详细介绍了,没学过汇编语言的朋友可能不好理解,简单来说就是把ebp到edi指向的空间(也就是最开始main函数的栈帧)里面所有的内存初始化为CCCCCCCC…

想详细了解这段代码的朋友可以点击:代码分析

c6c6d676de6e4663901c79ebd9bbe727.png

至此,main函数栈帧开创完毕

main函数中变量的创建和初始化

接下来就要进行的是变量的创建和初始化

首先是变量a的创建和初始化(int a = 10)

mov dword ptr [ebp - 8] , 0Ah

这段代码的含义是把0Ah(其实就是十进制的10,很明显这里存放的是a变量)放入ebp-8(也就是移动4字节)的位置中去,如图

        975432e0da9442178efb9d4df0326bec.png

        

此时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变量

同样的道理,结果如下图。

3f24cd57ac024a8ca4b18f57f04c62f0.png

以上就是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压进栈

如图所示9a5b481e44d34d6484705f7c1ae8b528.png

很明显,上面这个步骤就是在传参。

接下来,我们再次梳理一下大致流程

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指令的下一条指令的地址压入栈中,以便于程序可以找到来时的路,如图所示

1ed036c86dc34bddba5642d7ec4a5df6.png

至此,main函数的栈帧全部内容补充完毕。

add函数栈帧的创建

与main函数栈帧创建的过程一样

所以不再赘述,这里面我们就快一点画图

push ebp

mov ebp,esp

sub esp,0CCh

push ebx

push esi

push edi

上面的代码和前面一模一样,结果如下图

063cc59d147743a1afac835ceb957092.png

lea edi,[ebp-0CCh]

mov ecx,33h

mov eax,0CCCCCCCCh

rep stos dword ptr es:[edi]

还记得不记得上面说的一套"丝滑连招",没错,它的功能同样是初始化函数内部空间为CCCC…

b6b04bccb33544129603ec3380e0d98f.png

变量z的初始化

mov [ebp - 8],0

和上面一样,这段代码是为z开启空间并初始化

如图

6a6a7979a20e406b9bad7fc258ddc1fc.png

说了这么多之后,我们可以从图中看到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]究竟在哪里呢?看图即可:32b30a20ff694de29d0f0139eaff6755.png

 

我们发现,这两个位置正好是刚刚调用Add函数之前传参的位置,也就是传入的参数十进制10和5的位置!

随后dword ptr [ebp-8],eax

把eax中的计算结果,赋值给z变量

为了防止后续返回值z会随着Add函数销毁而消失,所以要把返回值z要存回eax寄存器中(寄存器中的值不会因为Add函数销毁而消失)。

mov eax,[ebp - 8]

计算结束!现在eax寄存器中,存放的就是Add函数的返回值15。

这是计算结束后的图示:ab30b13e112645939220f42f8e3dc2cd.png

add函数栈帧的销毁

计算结束,接下来就是Add函数栈帧的销毁

函数栈帧的销毁也是依赖于ebp和esp指向的改变。

pop edi 把edi这里面的值弹到edi寄存器中

pop esi 把esi这里面的值弹到esi寄存器中

pop ebx 把ebx这里面的值弹到e寄存器中

pop "弹出"意思就是把栈顶的元素弹走到指定寄存器中,那上面这连续三个pop,效果是这样的:

 

3fddebed17cf4002bdc973da17f29394.png

到现在为止,我们还没有完全销毁Add函数,接下来的这个操作可谓巧妙绝伦:

mov esp,ebp

把ebp存到esp中,这样我们很好的找到main函数栈顶的位置。

0b655e9573c240c6965a49dd50cb2ccc.png

pop ebp 这一步非常重要!直接通过esi找到栈顶元素ebp(main)(存放的是main函数的栈底地址)把它弹出到ebp寄存器中,这样我们直接找到了main函数栈底的位置,也改变了ebp的指向!

e0bdc7c2cff84593af60c130b067d843.png

经过这一套改变ebp和esp指向的操作,完美的销毁了Add函数所在的空间。

回到main函数计算,并销毁main函数

接下来是ret指令:弹出call指令的下一条地址并回到call指令的下一条指令的地址处,我们很好的找到了下一条指令的地址,并且又改变了esp寄存器的指向。

d3c16b7001b8453f85f72bf8111b1cc3.png

但是在这里我们发现,在现在的main函数的栈帧内,之前创建的形参依然存在,我们需要消除它。

所以要继续改变esp的指向

add esp,8

3307401b72cc4b72901dcd0c40aba948.png

现在,main函数的栈帧彻底回到了最初的模样。

mov [ebp - 20h],eax

最后把eax中存放的返回值15,放到变量c所在的位置中。

最终成效如下:

08b1692fdbd84881a0dcdee71a7ec48b.png

 

最后最后就是销毁main函数的空间,和销毁Add函数同理。

问题思考

到这里你应该能够对下面问题有自己的理解啦

  • 局部变量是怎么实现的?

局部变量其实就是我们函数内部开辟空间后,在函数内部取的一块空间,对这个空间赋值。局部变量在函数空间内,也就是说函数执行结束被销毁,局部变量也会随之不见。

  • 为什么未初始化的局部变量的值是随机的?

我们注意到,函数栈帧开辟之后,内部的空间被赋值成cccc...,其实这就是随机值的由来。

  • 函数是怎么传参的?顺序是怎么样的?

函数传递的参数,其实就是我们在调用新的函数之前,开辟这个函数栈帧之前,在原函数栈顶push的几个值,顺序也是从上到下依次push。

  • 形参和实参有什么样的关系?

我们可以看到,所谓的形参,其实就是原函数实参的一份临时拷贝,它的改变不会对原函数的参数有任何的影响。

  • 函数调用是怎么做的?调用完结束后是怎么返回的?

这个就不多说啦,原文中有更清楚的解释!

本文结束,希望对你有帮助,码字画图不易,感谢你的支持!

 

  • 36
    点赞
  • 39
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值