0
点赞
收藏
分享

微信扫一扫

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

江南北 2022-10-15 阅读 136
c语言

目录


前言

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

一. 夺命八大问

二.什么是函数栈帧

三.什么是调用堆栈?

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

#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 预备知识

在这里插入图片描述

4.1.1 什么是栈

4.1.2 相关寄存器

4.1.3 相关汇编命令

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的值

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

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再次向上移动一个单位

在这里插入图片描述

在这里插入图片描述

下面是非常关键的一步,我们即将调用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

在这里插入图片描述

在这里插入图片描述

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  

在这里插入图片描述

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函数不产生任何影响,它还是继续执行它代码块里面的内容。

在这里插入图片描述

五. 对开篇问题的解答

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

在这里插入图片描述

六. 例题

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

#include <stdio.h>

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

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

在这里插入图片描述
下面我们来详细的分析一下整个过程。

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

#include<stdio.h>

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

return 0;
}

在这里插入图片描述

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

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

举报

相关推荐

0 条评论