你真的明白函数调用的整个过程吗

时间:2022-10-15 08:01:38

????️作者:@malloc不出对象
⛺专栏:《初识C语言》
????个人简介:一名双非本科院校大二在读的科班编程菜鸟,努力编程只为赶上各位大佬的步伐????????
你真的明白函数调用的整个过程吗


前言

本次我们将要深入的了解函数调用的整个过程,本部分可能有些复杂且难,不过相信以大家聪颖的头脑一定能够看懂的????????

一. 夺命八大问

1. 局部变量是如何创建的?
2. 为什么局部变量不初始化其内容是随机的?
3. 有些时候屏幕上输出的"烫烫烫"是怎么来的?
4. 函数调用时参数时如何传递的?传参的顺序是怎样的?
5. 函数的形参和实参的关系是什么?
6. 函数的返回值是如何带回的?
7. 函数是怎样在栈区上开辟和释放空间的?
8. 函数栈帧在什么时候开辟?

二.什么是函数栈帧

我们在写C语言代码的时候,经常会把一个独立的功能抽象为函数,所以C程序是以函数为基本单位的。那函数是如何调用的?函数的返回值又是如何待会的?函数参数是如何传递的?这些问题都和函数栈帧有关系。函数栈帧(stack frame)就是函数调用过程中在程序的调用栈(call stack)所开辟的空间,这些空间是用来存放:
函数参数和函数返回值
临时变量(包括函数的非静态的局部变量以及编译器自动生产的其他临时变量)
保存上下文信息(包括在函数调用前后需要保持不变的寄存器)。

三.什么是调用堆栈?

调用栈是解析器的一种机制,可以在脚本调用多个函数时,跟踪每个函数在完成执行时应该返回控制的点。
当脚本要调用一个函数时,解析器把该函数添加到栈中并且执行这个函数。任何被这个函数调用的函数会进一步添加到调用栈中,并且运行到它们被上个程序调用的位置。当函数运行结束后,解释器将它从堆栈中取出,并在主代码列表中继续执行代码。
如果栈占用的空间比分配给它的空间还大,那么则会导致Stack Overflow(栈溢出)错误。我们的调用栈列表生不带来,死不带走,从一个空的调用栈开始,当所有代码执行完毕,我们得到的还是一个空的调用栈。
基本的原理就是:每当我的代码中有函数被调用,该函数就会自动添加到栈中,在执行完该函数的所有代码任务后,它就会自动从栈中删除。

下面这个例子将作为调用堆栈和函数栈帧创建和销毁的演示例子:

#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;
}

首先我们进入调试状态,再像下图中操作就能看到调用堆栈的过程。
你真的明白函数调用的整个过程吗

每当代码中有函数被调用,该函数就会自动添加到栈中,下图是Add函数被main函数调用时的情形:

你真的明白函数调用的整个过程吗
在执行完该函数的所有代码任务后,它就会自动从栈中删除:

你真的明白函数调用的整个过程吗

那接着我们再进行调试会发生什么呢?

你真的明白函数调用的整个过程吗
你真的明白函数调用的整个过程吗
此时我们已经返回到了调用main函数的函数。

当前我使用的环境是VS2013,越高的版本封装性越严格,我们可能看不到很多细节,所以建议大家尽量使用版本较低的IDE去观察。
下图发现我们的调用main函数的_tmainCRTStartup函数也是被其他函数调用的,至于mainCRTCRTStartup函数它是由我们的内核系统去调用的,这里光乎到操作系统的知识这里就不做过多的赘述了。

你真的明白函数调用的整个过程吗

四.函数栈帧的创建和销毁

4.1 预备知识

首先我们达成一些预备知识才能有效的帮助我们理解,函数栈帧的创建和销毁。
1.每一次函数调用,都要为本次函数调用开辟空间,就是函数栈帧的空间。
2.这块空间的维护是使用了2个寄存器: espebpebp 记录的是栈底的地址, esp 记录的是栈顶的地址。

你真的明白函数调用的整个过程吗

4.1.1 什么是栈

栈(stack)是现代计算机程序里最为重要的概念之一,几乎每一个程序都使用了栈,没有栈就没有函数,没有局部变量,也就没有我们如今看到的所有的计算机语言。在经典的计算机科学中,栈被定义为一种特殊的容器,用户可以将数据压入栈中(入栈,push),也可以将已经压入栈中的数据弹出(出栈,pop),但是栈这个容器必须遵守一条规则:先入栈的数据后出栈(First In Last Out, FIFO)。就像叠成一叠的术,先叠上去的书在最下面,因此要最后才能取出。
在计算机系统中,栈则是一个具有以上属性的动态内存区域。程序可以将数据压入栈中,也可以将数据从栈顶弹出。压栈操作使得栈增大,而弹出操作使得栈减小。在经典的操作系统中,栈总是向下增长(由高地址向低地址)的。在我们常见的i386或者x86-64下,栈顶由成为 esp 的寄存器进行定位的。

4.1.2 相关寄存器

eax:通用寄存器,保留临时数据,常用于返回值
ebx:通用寄存器,保留临时数据
ebp:栈底寄存器
esp:栈顶寄存器
eip:指令寄存器,保存当前指令的下一条指令的地址

4.1.3 相关汇编命令

mov:数据转移指令
push:数据入栈,同时esp栈顶寄存器也要发生改变
pop:数据弹出至指定位置,同时esp栈顶寄存器也要发生改变
sub:减法命令
add:加法命令
call:函数调用,1. 压入返回地址 2. 转入目标函数
jump:通过修改eip,转入目标函数,进行调用
ret:恢复返回地址,压入eip,类似pop eip命令

4.2 函数栈帧的创建和销毁

首先我们按下F10进行调试,然后右击找到反汇编或者在调试窗口下找到反汇编,接下来我们将在反汇编下观察函数栈帧的创建和销毁过程,看起来是有点复杂不过不用担心,我会一步一步给大家分析的,注意本次我的代码讲解是在x86环境下进行的。

转到反汇编下,首先我们看到的是这种情况:

你真的明白函数调用的整个过程吗

我们发现有些东西是用符号名来表示的,可能观察到的情况并不直观,所以我们关掉右上方的显示符号名。

你真的明白函数调用的整个过程吗

接下来我们就逐步分析每一步的过程,这是进入反汇编下观察到的main函数的反汇编代码:

int main()
{
//main函数栈帧的创建
008F1A50  push        ebp  
008F1A51  mov         ebp,esp  
008F1A53  sub         esp,0E4h  
008F1A59  push        ebx  
008F1A5A  push        esi  
008F1A5B  push        edi  
008F1A5C  lea         edi,[ebp+FFFFFF1Ch]  
008F1A62  mov         ecx,39h  
008F1A67  mov         eax,0CCCCCCCCh  
008F1A6C  rep stos    dword ptr es:[edi]  
//main函数的核心代码
int a = 10;
008F1A6E  mov         dword ptr [ebp-8],0Ah  
int b = 20;
008F1A75  mov         dword ptr [ebp-14h],14h  
int c = 0;
008F1A7C  mov         dword ptr [ebp-20h],0  
c = Add(a, b);
008F1A83  mov         eax,dword ptr [ebp-14h]  
008F1A86  push        eax  
008F1A87  mov         ecx,dword ptr [ebp-8]  
008F1A8A  push        ecx  
008F1A8B  call        008F11E5  
008F1A90  add         esp,8  
008F1A93  mov         dword ptr [ebp-20h],eax  
printf("%d\n", c);
008F1A96  mov         esi,esp  
printf("%d\n", c);
008F1A98  mov         eax,dword ptr [ebp-20h]  
008F1A9B  push        eax  
008F1A9C  push        8F58A8h  
008F1AA1  call        dword ptr ds:[008F9114h]  
008F1AA7  add         esp,8  
008F1AAA  cmp         esi,esp  
008F1AAC  call        008F1136  

return 0;
008F1AB1  xor         eax,eax  
}```

4.2.1 main函数预开辟栈帧

//这几行汇编指令就是在为main函数预开辟栈帧
008F1A50  push        ebp                 //将_tmainCRTStartup的栈底指针ebp压栈,此时在x86环境中栈顶指针esp偏移一个单位,就是向上移动4字节;在x64环境中栈顶指针移动8字节
008F1A51  mov         ebp,esp             //move就是将esp赋给ebp,ebp就指向了esp所指的地方
008F1A53  sub         esp,0E4h            //esp-0E4h,注意h代表的是十六进制h ==> hex,esp向上偏移0E4h的单位,而我们的esp和ebp是维护函数栈帧的,所以此时我们就为main预开辟了函数栈帧。

你真的明白函数调用的整个过程吗

我们打开调试窗口找到寄存器,通过观察相关寄存器的变化来证实我们每一步操作到底是执行了什么。

你真的明白函数调用的整个过程吗

你真的明白函数调用的整个过程吗

接着按下F10进行下一步操作,此时我们观察esp的变化,esp随着压栈操作它向上偏移了4个字节,也就是减小了4个字节。

你真的明白函数调用的整个过程吗

继续按下F10,接下执行的是mov指令,表示将esp栈顶指针的内容赋给ebp

你真的明白函数调用的整个过程吗

接着下一步执行的是sub指令,esp = esp - 0E4hesp向上偏移0E4h个单位,也就是228个单位,偏移1个单位移动4个字节,这一步相当于为main函数预开辟了一块函数栈帧。

你真的明白函数调用的整个过程吗

ESP:012FF830 - 012FF74C = E4

你真的明白函数调用的整个过程吗

下图为main函数预开辟栈帧的创建图
你真的明白函数调用的整个过程吗

4.2.2 进行压栈和初始化main函数栈帧
//压栈操作
008F1A59  push        ebx                 //将ebx进行压栈,esp随着向上移动,esp向上偏移一个单位减少4个字节
008F1A5A  push        esi                 //将esi进行压栈,
008F1A5B  push        edi                 //将edi进行压栈
//下面几条代码实际上是将main函数的栈帧进行初始化
008F1A5C  lea         edi,[ebp-0E4h]      //lea表示load effective address-加载有效的地址,将ebp-0E4h的地址给edi
008F1A62  mov         ecx,39h             //mov,表示将39h次赋值给ecx
008F1A67  mov         eax,0CCCCCCCCh      //mov,表示将0CCCCCCCCh赋值给eax
008F1A6C  rep stos    dword ptr es:[edi]  //word表示两个字节,dword = double word表示四个字节,这一步操作是从lea那一步开始联合操作的,表示的是每次操作四个字节,
                                          //从edi开始向下操作39h(十进制:57)次,将这块内容全部赋值为eax(0CCCCCCCCh),所以在局部变量未进行初始化时,表示的就是0CCCCCCCCh这个随机值

进行三次压栈操作,esp也跟着向上偏三个单位,减少了12个字节,esp = 012FF74C - 12 = 012FF740

你真的明白函数调用的整个过程吗

你真的明白函数调用的整个过程吗

下面是对main函数栈帧进行初始化操作,此时为了方便观察,勾选上显示符号名

你真的明白函数调用的整个过程吗

按下F10进行下一步,lea指令的意思是将ebp - 0E4h的地址加载给edi,此时edi其实就是未进行push三次之前的esp

你真的明白函数调用的整个过程吗
继续下一步,mov指令将39h赋值给ecx

你真的明白函数调用的整个过程吗

下一步,mov指令将0CCCCCCCCh赋给eax

你真的明白函数调用的整个过程吗

下一步就是将edi以下39h(57)个单位全部赋值为eax的值

你真的明白函数调用的整个过程吗

你真的明白函数调用的整个过程吗

你真的明白函数调用的整个过程吗

你会发现一切都是如此的巧妙,刚好是将ebp - 0E4h那个位置(esp之前维护main函数栈帧)到ebp之间的内容全部初始化为eax的值CCCCCCCC,我们也发现其实为main函数栈帧预开辟了一段很大的空间。

那我想问一个问题:编译器是怎么确定我们要预先开辟多大的栈帧空间?难道没有可能出现预开辟的栈帧空间小了的情况吗?
变量都是有类型的,编译器有能力知道所有类型对应定义变量的大小空间,所以能提前预估要开辟多大的栈帧空间,换句话来说ebp - 0E4h并不是固定的,而是根据我们定了多少变量来进行预开辟栈帧空间的,并且一定不会出现开辟栈帧空间小了的情况。

你真的明白函数调用的整个过程吗

4.2.3 传参及调用Add函数
//赋值操作
        int a = 10;
008F1A6E  mov         dword ptr [ebp-8],0Ah     //将0Ah(10)放到给ebp-8处,这一步其实就是赋值操作
int b = 20;
008F1A75  mov         dword ptr [ebp-14h],14h   //同上
int c = 0;
008F1A7C  mov         dword ptr [ebp-20h],0     //同上
//传参
c = Add(a, b);                          //传参
008F1A83  mov         eax,dword ptr [ebp-14h]   //先将b的值赋给eax,保存在eax里面
008F1A86  push        eax                       //将eax进行压栈     
008F1A87  mov         ecx,dword ptr [ebp-8]     //再将a的值赋给ecx,保存再ecx里面
008F1A8A  push        ecx                       //将ecx进行压栈
//调用
008F1A8B  call        008F11E5(_Add)            //call指令是准备要调用Add函数了,接下来按F11进入Add函数,这时会发生什么变化呢,esp会将它进行压栈,这是为了在调用Add结束之后返回main函数时能找到一条回来的路
008F1A90  add         esp,8                     //esp+8,向下移动8个字节   

以下几步都是初始化操作,把值放到对应的空间里面去

你真的明白函数调用的整个过程吗

你真的明白函数调用的整个过程吗

接下来几步其实就是在进行传参,先将b的值传过去保存在寄存器eax当中,然后进行压栈,再将a的值传过去保存在寄存器ecx当中进行压栈,我们发现传参时是从右向左的。

你真的明白函数调用的整个过程吗

eax压栈,esp向上移动一个单位

你真的明白函数调用的整个过程吗

接着将a值保存在ecx当中,ecx入栈,esp再次向上移动一个单位

你真的明白函数调用的整个过程吗

你真的明白函数调用的整个过程吗

这几条代码形成了临时拷贝,我们得出俩个小结论:

1.临时变量的形成是在函数正式被调用之前就形成了的
2.形参实例化的顺序是从右向左的

下面是非常关键的一步,我们即将调用Add函数,执行call指令将它下一条的指令的地址压入栈中,这是为了在调用完Add函数之后找到一条回来的路,因为调用完Add函数之后Add函数栈帧就被销毁了,此时我们记住下一条指令的地址就是为了防止这种情况的出现;call指令还会转入到目标函数的地址,但下一步它要通过jmp指令才能跳到到Add函数内部。

你真的明白函数调用的整个过程吗

接下来按F11,千万不要按F10了,F10是逐过程,F11是逐语句,按下F10就跳过Add函数被调用的过程了,我们就观察不到其中的细节了,另外我们看到将call指令下一条指令的地址压栈之后,此时esp向上偏移了一个单位,证明此时返回地址已经入栈。

你真的明白函数调用的整个过程吗

此时esp里面的内容不就是call指令下一条指令的地址00EB1A60吗,而之所以是倒着存的是因为VS下采用的是小端模式.
继续下一步,按下F10之后就跳到Add函数的内部了,此时eip的值也发生了改变,跳转到下一条指令的地址。

你真的明白函数调用的整个过程吗

此时就已经进入到Add函数中了,以下是Add函数的核心代码:

你真的明白函数调用的整个过程吗

4.2.4 为Add函数预开辟栈帧

其实讲这个地方了,进入Add函数之后我们发现很多操作都跟main函数的汇编操作是大致差不多的,所以接下来我就不会那么详细的一步步来了,我只会给一些注释或者你们自己也可以跟着来思考整个过程。

008F3D70  push        ebp                 //将main函数的ebp栈底指针压栈,esp向上移动,这句代码运用的十分巧妙,当Add调用结束后返回到main函数时,我们的ebp栈底指针是难以找到,而
esp很容易知道,所以在此处记录下main函数的栈底指针ebp当返回到main函数时,保证了我们的esp和ebp还是维护这main函数的栈帧。
008F3D71  mov         ebp,esp             //esp赋值给ebp
008F3D73  sub         esp,0CCh            //esp-0CCh向上移动,此时前三句代码就相当于esp和ebp维护了Add函数的栈帧

你真的明白函数调用的整个过程吗

4.2.5 Add函数的压栈和初始化
008F3D79  push        ebx                 //将ebx压栈
008F3D7A  push        esi                 //同上
008F3D7B  push        edi                 //同上,这里压入了三次,esp减少了12个字节
//下面其实对Add函数栈帧进行初始化操作跟main函数一样,这里就不做解释了
008F3D7C  lea         edi,[ebp-0CCh]      
008F3D82  mov         ecx,33h  
008F3D87  mov         eax,0CCCCCCCCh  
008F3D8C  rep stos    dword ptr es:[edi]  

你真的明白函数调用的整个过程吗

4.2.6 接收传过来的实参并进行运算


int z = 0;                       
008F3D8E  mov         dword ptr [ebp-8],0      //将0赋给ebp-8处
z = x + y;
008F3D95  mov         eax,dword ptr [ebp+8]    //将ebp+8赋给eax,ebp+8其实就是x'(10),
008F3D98  add         eax,dword ptr [ebp+0Ch]  //执行加法指令,ebp+0Ch其实就是y'(20),eax+20 = 30
008F3D9B  mov         dword ptr [ebp-8],eax    //将eax(30)赋给ebp-8处,也就是z = 30

return z;
008F3D9E  mov         eax,dword ptr [ebp-8]    //最后用eax寄存器保存着,因为z局部变量出了Add函数就销毁了,而寄存器是不会被销毁的

Z的值保存在eax寄存器中,eax = 0000001E = 30

你真的明白函数调用的整个过程吗

你真的明白函数调用的整个过程吗

通过这里我们发现形参x,y其实并没有在Add函数内开辟空间,而是通过找到a和b存放在寄存器当中的临时拷贝来进行运算的,所以说形参是实参的一份临时拷贝并且改变形参并不影响实参,形参和实参的空间都不指向同一处。

4.3 函数栈帧的销毁

4.3.1 Add函数栈帧的销毁及返回值的带回

你真的明白函数调用的整个过程吗

008F3DA1  pop         edi      //pop表示出栈,弹回到指向edi的位置,此时esp往下移动一个单位,注意:此时向下移1个单位是增大4个字节
008F3DA2  pop         esi      //同上
008F3DA3  pop         ebx      //同上
008F3DA4  mov         esp,ebp  //将ebp赋给esp,表示Add函数栈帧就返回给操作系统了,因为此时esp和ebp指向同一处,所以就不存在维护Add的函数栈帧了
008F3DA6  pop         ebp      //出栈,ebp弹回到ebp-main,main函数的栈底指针,esp向下移动
008F3DA7  ret                  //返回时我们找到在main函数中的call指令的下一条语句的地址,这时候就返回到main函数中了,esp向下移动

此时pop指令弹栈后,esp由之前的0123FF658增加了4个字节

你真的明白函数调用的整个过程吗

此后再进行两次popesp增加8字节

你真的明白函数调用的整个过程吗

你真的明白函数调用的整个过程吗

下一步操作将ebp的赋给espespebp指向同一处,此时Add的函数栈帧已经不再维护了。

你真的明白函数调用的整个过程吗

你真的明白函数调用的整个过程吗

下面是非常关键的一步,pop弹到ebp指向的位置main函数的栈底指针,此时espebp维护main函数的栈帧。

你真的明白函数调用的整个过程吗

此时你看下图,ebp弹回到的是不是之前main函数的栈底指针。

你真的明白函数调用的整个过程吗

你真的明白函数调用的整个过程吗

最后我们执行的是ret指令,它恢复返回地址到main函数中,压入eip,类似pop eip命令。
所谓返回的本质:1.返回到main函数的栈帧 2.返回到main函数对应的代码。

4.3.2 返回到main函数

你真的明白函数调用的整个过程吗

你真的明白函数调用的整个过程吗

至此Add函数已经调用完毕,接下来就是main函数栈帧的销毁。

008F1A90  add         esp,8                    //esp+8,我们知道此时Add函数已经调用完毕,那么此时的实参a和b已经不需要用了,esp+8,esp向下走两步,此时实参a和b已不在esp和ebp所维护的函数栈帧范围之内,所以就被销毁了
008F1A93  mov         dword ptr [ebp-20h],eax  //将eax(30)赋给ebp-20h处,ebp-20h就是C的地址
printf("%d\n", c);         
 //剩下这些其实都是一样的原理了,接下来printf函数的调用销毁,main函数栈帧销毁......下面就不给大家来讲了
008F1A96  mov         esi,esp                  
008F1A98  mov         eax,dword ptr [ebp-20h]  
008F1A9B  push        eax  
008F1A9C  push        8F58A8h  
008F1AA1  call        dword ptr ds:[008F9114h]  
008F1AA7  add         esp,8  
008F1AAA  cmp         esi,esp  
008F1AAC  call        008F1136  

你真的明白函数调用的整个过程吗

我们可以看到整个函数栈帧创建和销毁过程一环扣一环,执行逻辑非常严谨、周密。真的这些科学家都是些什么大神啊????,汇编语言yyds????。

4.4 拓展知识

学完这部分我将栈内存的分布做了一个简单的分布图,当然可能并不一定这么准确,因为站在不同的角度来理解的话,中间那段push进入的栈帧空间既可以算做main函数的栈帧也可以是Add函数的栈帧空间,我这样画是为了更好的阐述下面的例子。

你真的明白函数调用的整个过程吗

以上述代码为例,接下来我们重点研究push进入栈帧的黑色部分,我们很明显的看到push进入的变量等都是相邻的,那么我们可不可以通过某一地址(指针)来间接修改另一临时拷贝的内容呢?

你真的明白函数调用的整个过程吗

我们一起来看向这段代码,它到底能不能修改其他形式参数的值呢?

#include<stdio.h>

int Add(int x, int y)
{

printf("Before: %d\n", y);//20
*(&x + 1) = 100;          //x的地址(指针)+1移动4个字节,就是y的地址,y的地址再进行解引用,将y的内容改为100 
printf("After: %d\n", y);

return 0;
}

int main()
{
int a = 10;
int b = 20;
int c = 0;
c = Add(a, b);

printf("%d\n", c);

return 0;
}

为什么能进行修改呢?因为它们之间的地址是相对确定的,我们从结果也可以看出已经修改了。

你真的明白函数调用的整个过程吗

大家也可以试一下通过x或y的地址间接修改其他push进入的值,看下会产生什么效果。这里我再带大家看一个例子,我们通过x的地址来间接修改返回main函数的地址,大家可以试一下你会发现程序运行一会儿就会崩溃掉的。这是因为你在调用Add函数时修改了返回main函数的地址使其变为了bug函数的地址,那么在调用完Add函数时,你将会返回到bug函数中,执行bug函数中的代码,但bug函数调用完之后又将返回哪里呢?所以bug函数根本找不到回家的路,那么程序就崩溃了,下图我们看到的其实这段程序已经崩溃了。那么该如何解决这个问题呢?我们是不是应该让bug函数记住返回main函数的地址,在调用完之后就返回到main函数中呢?

你真的明白函数调用的整个过程吗

其实在VS2013中安全机制已经做的很好了,可能看不到一些现象,你想一下如果我们知道相对位置的分布了,那么如果别人通过这个相对地址去篡改其他内容的话,是不是会产生极大的影响,所以干脆不让我们对其中的一些内容进行干涉,如果有读者有兴趣的话可以通过一些更老的编译器去查看现象,这里我就不带大家去观察了。

接下来还有个问题:在C语言中如果一个函数没有形式参数,那么我们还能给函数传递参数嘛?

答案是可以的,在C中只要发生了函数调用并且传递了参数必定形成临时拷贝,所谓的临时拷贝本质就是在栈帧内部形成的,从右往左依次形成临时拷贝。下面我举个例子验证一下这个问题。

#include<stdio.h>

void Bug()
{
printf("这是一个bug!\n");
}

int main()
{
Bug(1, 2, 3, 4);

return 0;
}

接下来调试进入反汇编,准备进行传参操作:

你真的明白函数调用的整个过程吗

传参时进行压栈操作,我们发现实参已经全部进行被压入栈中进行临时拷贝:

你真的明白函数调用的整个过程吗

接下来我们进入Bug函数内部观察现象,我们发现没有任何与临时拷贝的参数有关的指令,证明虽然有关函数没有形式参数,但我们仍然可以进行传参操作,只不过此时的传参没有任何意义因为该函数内没有任何与临时拷贝有关的代码,换句话说就是不对该函数产生任何影响。

你真的明白函数调用的整个过程吗

对Bug函数不产生任何影响,它还是继续执行它代码块里面的内容。

你真的明白函数调用的整个过程吗

五. 对开篇问题的解答

1.局部变量是如何创建的?
局部变量的创建是在局部变量所在的函数的栈帧创建完成并初始化后,然后在该栈帧内为局部变量分配空间的。
2.为什么局部变量不初始化其内容是随机的?
因为编译器在创建函数栈帧后会在栈帧空间里面放入一个值,而这个值是随机的。
3.有些时候屏幕上输出的"烫烫烫"是怎么来的?
因为main函数调用时,在栈区开辟的空间的其中每一个字节都被初始化为0xCC,而如果我们定义的是一个未初始化的数组,且这个数组恰好在这块空间上创建,因为0xCCCC(两个连续排列的0xCC)的汉字编码是“烫”,所以屏幕上输出的就是烫烫烫。
4.函数调用时参数时如何传递的?传参的顺序是怎样的?
我们在调用函数之前,就会在栈顶上从右向左依次压入需要传递的参数,在创建好被调函数的函数栈帧后通过指针的偏移量来使用传递过去的参数,而不是在被调函数的函数栈帧内创建形参。
临时变量的形成是在函数在正式被调用之前就形成了的;形参实例化的顺序是从右向左的。

5.函数的形参和实参的关系是什么?
形参是实参的一份临时拷贝,二者的存储位置不同,形参的改变不会影响实参。**
6.函数的返回值是如何带回的?
函数的返回值通过eax寄存器带回。
7.函数是怎样在栈区上开辟和释放空间的?
函数通过改变esp和edp的指向来创建和销毁空间,空间销毁并不会清除该空间中的数据,下一次使用该空间时新数据直接覆盖原数据即可。
8.函数栈帧在什么时候开辟?
定义函数时,系统不会给形参分配存储单元,只有函数被调用时系统才会给形参分配存储单元,调用结束后形参所占单元被释放。

这里单独的看看第三个问题的情况,通过观察终于知道为什么平时会出现这样的情况了,其实就是随机值CCCCCCCC

你真的明白函数调用的整个过程吗

六. 例题

下面来分享俩道题,学过函数栈帧你才能不掉入坑中,大家先来写一下吧????????

#include <stdio.h>

int main()
{
    int arr[3] = { 1,2,3 };
    int* p = &arr[0];
    printf("%d %d\n", *p, *(p++));
 
    return 0;
}

下面揭晓答案,屏幕前的小伙伴们有没有做对呢????????

你真的明白函数调用的整个过程吗
下面我们来详细的分析一下整个过程。

本题第一个坑点:*(p++),是不是p先进行++,之后再进行解引用呢?
其实并不是这样,我们知道++的优先级比要高,但是因为它是后置++,所以先进行之后再自增。不管这里有没有加括号,其实都是一样的意思,(p++) == p++,因为++的优先级本来就比要高,所以加不加括号其实都无所谓都是一样的意思。
本题第二个坑点(也是最大的坑点):我们平常的习惯看到printf就认为是从左往右先计算参数的结果再打印出来,但实际上printf执行顺序是从右向左的(输出确实是从左往右的),在学过函数栈帧之后我们知道每调用一次函数,都要创建一块对应的函数栈帧。C语言当中没有自己的输入输出语句,在平时我们可能误认为printf就是用来打印结果的语句,但其实printf是一个函数,它的返回值为int型,当main函数来调用printf函数时,我们的函数传参是从右向左的传递的,先把
(p++)进行压栈操作,*(p++)此时的p还是指向&arr[0]的,后续p++,p已经指向了&arr[1],所以第一个打印出的结果为2,第二个为1。听完本题的讲解你学废了嘛????

下面再来看向这道题,通过上题的讲解如果听明白了,相信大家都能做出来:

#include<stdio.h>

int main()
{
int c = 0;
printf("%d %d", c, c++);

return 0;
}

你真的明白函数调用的整个过程吗

如果还是做错了,请大家再看看上题的讲解,认真用心一点,勿要眼高手低????,那么该题就不进行讲解了。

最后一个灵魂拷问,大家觉得printf函数执行顺序是从右往左的觉得是不是非常别扭啊,这很不符合我们的使用习惯,我们是从左往右计算接着就输出结果,一时间根本无法改变长此已久的习惯,下面我总结了一个小tips:
如果右边参数执行后不对左边参数产生任何影响,那么我们就按平常的习惯从左到右执行并输出结果,如果右边的参数执行后对左边参数造成了影响那么我们就从右向左执行,最后从左往右输出结果。
当然,平时我们见到的大部分情况都是按照从左向右执行输出结果,我们只需小心留意一下这种不常出现的情形就好了,在面试的时候也许会遇到这种坑题,但实际上在工作时,我们一定要避免使用这种模棱两可的代码,因为这不符合我们平时大多数人的使用习惯啊,稍不留意就出现岔子了,给公司带来一定的维护难度。

以上就是函数栈帧的所有内容了,虽然这部分有些难但是大家沉下心来慢慢操作,相信大家都能懂的???????? 本文章耗费博主不少时间来进行讲解,这个过程实在是有点艰难哈哈 如果大家觉得有收获请给博主点点赞哦????????