C语言函数调用时候内存中栈的动态变化详细分析(彩图)
版权声明:本文为博主原创文章,未经博主允许不得转载。欢迎联系我qq2488890051 https://blog.csdn.net/kangkanglhb88008/article/details/89739105
先了解如下几点知识和过程:
* 冯诺伊曼体系计算机程序指令代码都是提前从硬盘加载进入内存从而执行的(如果是哈佛体系结构的计算机指令代码是直接在外存里面执行的,具体可以看我这篇文章,计算机冯诺伊曼体系结构和哈佛体系结构区别和处理器性能评判标准),这些指令代码是存放在内存中进程的代码段,同一个函数内的指令代码是按照地址顺序存储的(编译器决定的)(也就是说只要指令地址+1就可以自动得到下一条指令的地址),那么当发生函数调用的时候,就是进入另一个函数的连续地址代码段了,所以才有了调用函数时候得提前入栈保存此函数后的一条指令的地址。
* 栈的栈顶和栈底定义不是按照地址高低定义的,是按照入栈出栈的位置定义的,入栈出栈的地方就叫做栈顶(尽管在Windows操作系统中栈的增长是从高地址到低地址),栈是后入先出的,被调用函数后进栈,那么函数返回的时候,也是它先被回收,即一层层回退的回收空间
* stack是栈,但是也经常被人们称为堆栈,heap是堆,不要乱取名。关于内存中堆栈如何分配空间以及区别是什么,可以看我这篇文章,计算机程序存储分配详解和c语言函数调用过程概述
* 整个程序维护一个栈(如果是跑操作系统的话,可能会有多个进程,那么就会有多个独立的栈,而比如单片机裸机程序就只有一个栈),这个栈在动态改变,变量的分配和释放都是栈顶指针在动态的移动罢了,如果需要释放的栈内变量是需要保存起来继续使用的,那么就用pop方式,出栈同时会进行保存在某个cpu寄存器里,比如EAX,这个寄存器可以用来临时保存变量值或者最后的函数返回值
* cpu有多个寄存器,主要是用于当前活动函数(一个函数在正运行的时候,我这里称他为活动函数)的一些暂存值,而且可能会动态改变寄存器里面内容,跟当前活动函数进行交互啥的,但是我们比较关心的就四个,EAX,ESP,EBP,EIP,其解释如下
* 活动函数会把局部变量和一些寄存器里面的值入栈(而不是寄存器的地址入栈,因为寄存器的地址就是通过宏定义为ebx等等名字了,也就是寄存器地址已经公之于众的了,关于这个具体可以看我这篇文章详细讲解,嵌入式微处理器结构和上电启动到开始运行程序的过程讲解),因为整个cpu只有这么一组寄存器,但是函数调用却可以有很多层,所以当前函数调用了下一个函数后,所以活动函数就变成下一个函数了,此时这组寄存器的值就先入栈也就是保存起来,然后就得用于支持新的活动函数运行了,当新的活动函数结束后,就会把刚刚入栈保存的值重新赋值给这组寄存器,因此恢复调用前的执行状态。
* 程序只有一个栈,但是却可以有函数的层级调用,而每个函数都会在这个总栈里有一个局部栈,也叫做栈帧
比如当前处于主函数main中,栈内如下:
这里面的局部变量就是main函数内定义的,而ebx,esi,edi具体是干啥的,为什么要入栈(肯定是一些记录信息),不用明白,只要知道每个活动函数都会把这三个寄存器里的值入栈就行了。EBP寄存器的值存储的是当前活动函数栈帧的栈底地址,而ESP存的就是当前栈帧的栈顶地址了。cpu下一条执行的指令地址是直接去EIP寄存器里面读取的,EIP这个寄存器每次就是用来存放 即将执行的指令的地址的,那么每次执行之前就得把下一指令的地址我们手动填进去,也就是后面会看到的函数调用完成后从栈内pop出那个提前入栈的地址。(pop那个下一指令的地址的话,这个汇编指令应该是同时还把此地址给填进EIP寄存器了)
// main函数里面具体执行的指令过程的汇编代码,第一列代表的是指令的地址,我自己去掉了无关指令代码
011C1540 push ebp //压栈,保存ebp(这个是调用main函数那个函数的栈帧的栈底地址,我也不知道到底是谁调用了main函数,应该是操作系统了吧),注意push操作隐含esp-4
011C1541 mov ebp,esp //把esp的值传递给ebp,设置当前ebp
011C1543 sub esp,0F0h //给函数开辟空间,范围是(ebp, ebp-0xF0)
011C1549 push ebx
011C154A push esi
011C154B push edi
011C154C lea edi,[ebp-0F0h] //把edi赋值为ebp-0xF0 接下来这几条指令可以不用看
011C1552 mov ecx,3Ch //函数空间的dword数目,0xF0>>2 = 0x3C
011C1557 mov eax,0CCCCCCCCh
011C155C rep stos dword ptr es:[edi]
//rep指令的目的是重复其上面的指令.ECX的值是重复的次数.
//STOS指令的作用是将eax中的值拷贝到ES:EDI指向的地址,然后EDI+4
// 这里就是开始调用 print_out(0, 2)了
013D155E push 2 //第二个实参压栈
013D1560 push 0 //第一个实参压栈
013D1562 call print_out (13D10FAh)//返回地址压栈,本例中是013D1567,然后调用print_out函数
013D1567 add esp,8 //两个实参出栈
//注意在call命令中,隐含操作是把下一条指令的地址压栈,也就是所谓的返回地址
// 被调用函数执行到了return语句时候,即准备结束此函数了,做的返回过程
013D141C mov eax,1 //返回值传入eax中
013D1421 pop edi
013D1422 pop esi
013D1423 pop ebx //寄存器出栈
013D1424 add esp,0D0h //以下3条命令是调用VS的__RTC_CheckEsp,检查栈溢出
013D142A cmp ebp,esp
013D142C call @ILT+315(__RTC_CheckEsp) (13D1140h)
013D1431 mov esp,ebp //ebp的值传给esp,也就是恢复调用前esp的值
013D1433 pop ebp //弹出ebp,恢复ebp的值
013D1434 ret //把返回地址写入EIP中,相当于pop EIP
现在在main函数里调用了另一个函数print_out函数,其栈变化如下:
我们可以看出函数的层级调用实际上就是重复的入栈不同活动函数的内容(相同的方式),如果print_out函数再调用一个函数,也是同样的再加一个栈帧罢了。
现在我们来分析这个入栈的过程和顺序:
main函数也是被其他某函数调用的,这里我们就不追究了,因为栈是往低地址增长的,我们可以看出main函数执行过程(即还现在是当前活动函数)是先把main内定义的局部变量入栈了,紧接着是那3个寄存器的内容,此时继续往下执行,发现遇到函数prin_out调用了,这时首先会在栈内开辟两个4字节的空间(因为只发现两个int型的形参),也就是C语言中的声明了两个变量,同时把这两个空间中分别填入0和2,即完成了函数形参的声明和初始化(因为现在还处在main函数栈帧内,所以我们可以看出被调用函数的形参的声明和赋值都是在调用函数中完成的,而不是被调函数自己分配的空间),也就是上面看到的实参1,2在栈中存在了,接下来即将进入print_out函数之前,main函数还得把print_out函数的下一条指令地址(也就是上图的返回地址)给入栈保存起来(这个过程是call print_out汇编指令就会自动完成的,其实这个下一条指令的地址就是回收刚刚分配的那两个实参占用空间的操作,即add esp,8这句指令的地址,不急,我后面会详细分析为什么是这条),因为print_out函数运行完后,此时main函数才知道应该继续怎样运行。(疑问点:这个print_out函数的下一条指令的地址不能是print_out函数执行快执行完时自己告诉main函数吗,当然不行,因为print_out函数自己根本不知道自己下一条指令是谁,自己都可能被不同函数调用呢,对外层函数(调用者)一点也不知情)。当把返回地址也压入栈后,就可以进入print_out函数了。
进入print_out函数后,还是同曾经进入main函数一样的方式,首先入栈调用者(main函数)的栈帧的栈底地址
main函数栈帧的栈底地址,也就是图中红色箭头指向内存单元的地址,在栈中就是ebp(main)这个值(目的是为了print_out函数调用完成后,main函数又成为了活动函数,main的栈帧也就成了当前栈帧,填入EBP寄存器这个地址值,使得EBP能够迅速指到正确位置,即红色箭头处,此时ESP当然就得指向edi那儿的位置也就是main函数栈帧的栈顶位置了,这样一看,就是还原回未调用print_out函数时候的栈的模样了,就是上面右图,perfect,如此的完美),这时就可以进入print_out函数内部了。
然后为当前活动函数(print_out函数)分配局部变量需要的总空间(这里分配8个不一定准确,因为ebx,esi,edi三个寄存器内的值也要接着入栈,准确应该是20个字节,但是这里为了简便,就没有这么严谨,但是原理是对的),接着入栈局部变量,ebx,esi,edi三个寄存器内的值,然后进行相应的运算过程后,一旦遇到了return语句,此时print_out函数才知道自己即将执行结束了,所以就开始做本函数栈帧的回收工作了,仅仅把返回值给保存到EXA寄存器即可(存在返回值的情况,如果是无返回值,函数是void类型,那就不需要保存返回值到EXA寄存器了),由于局部变量以及ebx,esi,edi三个寄存器内的值都是无意义的值了,直接丢掉即可,即把esp寄存器的内容直接赋值为ebp寄存器里的地址值,即esp和ebp指向同一个内存单元,此时的栈顶就变成ebp(main)这儿了,就实现了栈内存的回收,如下图所示,具体对应的汇编代码就是 mov esp,ebp,
此时再把ebp寄存器填入提前压入栈内的main函数栈帧的栈底地址,也就是ebp(main)出栈,同时赋值进ebp寄存器里
即:pop ebp //弹出ebp,会同时进行把这个地址值赋值进入ebp寄存器,即恢复ebp的值,即ebp指向了main函数栈帧的栈底了,如下图
此时已经回收完print_out函数的栈帧了,此时,已经来到了main函数栈帧了,但是并没有来到main函数的指令代码段
然后print_out函数里面就来到ret指令了,即把返回地址(存在main栈帧里的)写入EIP中,相当于pop EIP,如下图
此时,print_out函数完全执行完了,就回到了main函数指令段了,很明显接下来的一条指令就是继续回收一下main函数内当初为print_out函数形参分配的两个变量空间(当初main函数为调用函数分配形参的过程也是属于main函数的指令),即下面这句指令
add esp,8 //两个实参出栈,即回收两个实参的空间,如下图
那也就是说在main函数内调用print_out函数这个指令之后的指令就是add esp,8这句指令(编译器就可以知道这两个指令的前后关系,所以这个并不是动态的),所以当初即将调用print_out函数时入栈的那个返回地址就是add esp,8这句指令的地址,也就是回收实参的空间这句指令的地址,那再下一条指令的地址呢,因为最开始我们就说了同一个函数的指令代码都是在连续的地址空间存放的,所以只需要把add esp,8这句指令的地址+1即可得到下一条待执行指令的地址了。
就这样,整个print_out函数调用完成了,同时main函数栈帧也恢复原来未调用print_out函数时候的模样了,如上图所示,perfect,完成。
接下来我们再来看一个例子,有了上面的分析基础,那么下面这个也就很容易同理分析出来了,里面的汇编指令代码清晰明了,整个过程清清楚楚了
/-------------------------------------------------------------------------------------------------------------------/
现在,我们来总结一下函数调用时候栈的变化过程:
1.调用者在自己的栈帧里开辟好被调函数形参需要的空间
2.入栈 函数调用结束后应该执行的地址值,即返回地址,其实就是回收第一步为形参开辟的空间的指令的地址
3.进入被调函数了,入栈调用函数栈帧的栈底地址
4.在新函数的当前栈帧内为局部变量分配空间后,入栈局部变量
5.被调函数遇到return语句了,说明即将结束本函数了,就开始做回收本栈帧的空间的事了:
1)如果有返回值,那么把返回值赋值给EAX,如果没有则忽略这一步。
2)回收局部变量空间,即esp指向调用函数栈帧的栈顶了
3)提前存好的main函数栈帧的栈底地址赋值进入ebp寄存器,从而使得ebp指向main函数栈帧的栈底
4)把返回地址填入EIP寄存器,接着就会指向,回收main函数当初为被调函数开辟的两个形参的空间的指令地址
5)回收形参空间
这样就还原了main函数栈帧,回到了未调用那个函数的时候栈帧的模样。
从上面可以得出一些结论,一个函数实际上是一种动态的概念,它的存在体现在内存中而已,也就是它对应的栈帧,当它的栈帧被回收了,那么这个函数就结束了。
最后再来讨论一下这样一个问题:刚刚我们看到被调用函数是通过cpu的eax,edx两个寄存器传递返回值,然后调用函数只需要去读取这两个寄存器的值就得到了被调函数的返回值,但是eax,edx这两个寄存器都是32位的,也就是总共能够返回8字节的数据,对于基本类型的数据(比如char,int,float,double(占8字节),指针类型)的返回没问题,但是假如我们想返回一个结构体类型的数据且成员总大小超过了8字节(常见方法是传递结构体指针。但作为语言上允许的方式,有必要弄清楚编译器如何实现这种方式),其原理是怎样的呢?
答:我们的编译器编译同样一个程序一般支持生成两个版本的目标代码,debug版本和release版本,debug版本编译结果一般是针对调试程序使用的,代码优化较低,更好的还原了开发者写的C语言源程序结构。release版本是指发行版,即软件上架发布使用,编译器对代码优化程度较高,对无用代码和不可达状态等等都进行了删除(有兴趣具体了解代码优化的可以查阅编译原理一书),不便于调试,但是运行效率更高。其实这两者的原理都是基本一样的,这里我们对debug版本和release版本分别进行简单讲解。
第一种情况,不超过8字节的结构体返回过程:如下图所示:
总结:
(1.1)用 edx:eax 传递返回值。调用方不需要在栈上向 add 函数传递接受返回值的地址。也就是跟基本数据类型变量返回是一样的过程。
(2.2)debug 版本在调用方生成临时对象返回值(而release版本就不是这样,上图中红色方框内的内存空间就不会存在,而是寄存器的值直接拷贝到main函数的t变量里面,所以release版本效率更高),然后再把临时对象拷贝到 main 指定变量t所在地址。效率低。我们可以看到临时对象是在main函数的栈帧里的,也就是说main函数在调用add函数前分析了一下它的返回值类型大小,就分配了空间,当调用完成后就会把临时对象(返回值内容)的值复制到左边的指定赋值的变量t,此时临时对象就完成了它的使命,main函数就回收临时对象的空间了。
第二种情况,超过8字节的结构体返回过程:如下图所示:
总结:
(1)当结构体超过 8 bytes,不能用 EDX:EAX 传递,这时调用者在自己栈帧栈上保留有一个用于填充返回值的结构体,其地址在入栈实参后 push 到栈上,如上图蓝色箭头处。被调用函数add将会根据这个地址,把返回值设置到这个地址,红色箭头处。
(2)在 main 函数中,debug 版本比 release 版本还多了一个临时对象,效率低。而 release 版本中只有返回值和临时变量 t(图中红色方框的临时对象不存在),效率略高于 debug。但两者模型基本一致,还是得从返回值那儿的空间的内容复制到左边指定的赋值变量t的空间(指的都是main函数内的t),然后回收返回值所对应的空间,总体效率还是低于传结构体指针(因为指针只占用4个字节,直接通过eax寄存器即可返回,然后赋值给指针t即可),所以建议在C语言中返回结构体类型数据时候尽量用指针返回,代码运行效率更高。
(3)对于上述两个实验,release 版本优化都比较厉害,main 函数中对 t 的赋值是不完整的,因为编译器认为有些成员没有使用到(比如t.b,t.c两成员的赋值,即无用代码),所以没有必要复制,只要满足代码等效即可(具体知识可参考编译原理一书代码优化章节)。
这里就不贴出上述两个实验对应的汇编代码了,编译器优化功能不是万能的,我们知道底层这样的过程后,以后写代码时候才能心中有数,写出更高质量更高效率的代码。
欢迎关注我的博客,我有空会写一些关于计算机基础理论方面通俗易懂的科普性文章,一方面用于记录自己的学习过程,另一方面可以分享给其他人,让更多的人了解如今生活中无处不在的计算机的工作原理。
参考文章:
函数调用--函数栈 https://www.cnblogs.com/rain-lei/p/3622057.html
程序编译后运行时的内存分配 https://www.cnblogs.com/guochaoxxl/p/6977712.html
堆和栈的区别 https://www.cnblogs.com/yechanglv/p/6941993.html
关于返回结构体的函数 https://www.cnblogs.com/hoodlum1980/archive/2012/07/18/2598185.html
---------------------
作者:biao2488890051
来源:CSDN
原文:https://blog.csdn.net/kangkanglhb88008/article/details/89739105
版权声明:本文为博主原创文章,转载请附上博文链接!