C语言函数是如何调用的?参数又是如何传递的?什么是函数栈帧?
开门见山,先看一段简单代码,从这个简单代码着手,我们一步一步解开C语言函数调用与参数传递的谜团…(鉴于本人也在不断探索学习,如有错误,愿虚心请教,相互学习)
Add(int x, int y)
{
int z = 0;
z = x + y;
return z;
}
int main()
{
int a = 10;
int b = 20;
int c = Add(a, b);
printf("%d\n", c);
return 0;
}
这段代码对于任何一个C学习者来说都不是难事,按照我们往常的理解,一眼就可以看出其中门道:“这不就是函数参数的传值调用嘛!” 没错,此处函数调用方式方为C语言中两种函数参数传递方式的一种,今日我们先行讨论函数传值调用的具体底层逻辑,对于传址调用,其底层函数维护方式与传值调用雷同,只是在指针变量的维护上稍有不同,具体我们日后再叙。
言归正传,谈到底层,我们到底从哪里入手呢? 有人说:讲底层?你给我从main函数开始!
好!今天我们就从main函数的调用开始!!!(忽然觉得自己好刚)
几乎每一个C学习者的第一个代码都是从"Hello,World!"开始的,身为小白的我们会按照书中所示的那几行代码,认真的敲下几行代码:
#include<stido.h>
int main
{
printf("Hello,world\n");
return 0;
}
可当我们学习过函数的相关内容我就会知道,函数是需要定义的,函数需要参数,返回值类型等要素条件。那么这个main是哪里来的?怎么我们每一个C程序的启动都要从它开始?
有人说:main 就和 printf 等函数一样,都是“与生俱来”的。 这个说法宏观上讲并没有什么问题,可main 函数的作用可不仅仅是在打印输出这种功能上。更多的是,main函数给我们提供的是第一个入口,是整个程序的起点。 正好比无穷的宇宙,究其本源到底是什么? 而对于C程序,是怎么创造起点的?
第一个问题,尚属人类未解之谜。
针对第二个问题,我们可以通过VS编译器来找到答案:
在VS2013的编译环境下,通过调试窗口的调用栈堆,我们可以发现: main 函数被 __tmainCRTStartup调用,__tmainCRTStartup又被 tmainCRTStartup调用着
此处的“发现”可以说明: main函数并不是凭空产生的,他由其他隐式函数调用并发挥着功能。
创建函数,首先就得知道函数在哪里存在着:
在计算机内存中,栈区、堆区和静态区和其所对应存放的内容是这样一种关系: 其中自定义函数,和其对应的形参在创建时应在内存的栈区存放。主函数main也不例外,创建时也要从栈区创建。由malloc等函数申请的动态内存则存放在堆区。全局变量,以及static修饰的静态变量则存放在内存的静态区。 对于一个栈来说,低地址对应在栈区顶部,高地址对应在栈区底部。压栈时先进入高地址栈底,出栈时则从低地址栈顶弹出。
在正式介绍main函数创建过程之前,还需要一些准备工作。首先要知道在底层汇编中,通常是以寄存器的形式进行操作的。这里不对具体CPU进行说明,在VS编译环境下寄存器通常由eax,ebx,ecx,edx等组成。他们起到存放在变量和值的作用。 这里还介绍两个特别的寄存器:ebp和esp ebp又称为栈底指针,esp又称为栈顶指针 两者的作用就是维护当前函数的栈区,使之发挥功能。 由上述VS2013编译器环境下的main函数引出条件我们可知,主函数main的创建是由__tmainCRTStartup函数调用而来,那么在main函数作为正式入口进入整个程序之前,必存在着上图所示的esp/ebp维护空间以供__tmainCRTStartup函数存在,并调用主函数main。
为了破解main函数的起源,在VS2013环境下,对开始那段代码进行了反汇编操作。 经过反汇编后: 我们可以看到,首先在栈顶(低地址)push进ebp的值,然后将esp(栈顶指针)移动到ebp处,这时再将esp减去0E4h空间,由于地址大小是上低下高,所以esp在栈顶低地址处,做减法就向上远离了ebp所在位置。至此esp指向低地址栈顶处,ebp在高地址栈底处 至此,主函数main所需要的就空间创建了。(这里需要特别提示一点,每当在栈区push进一个东西,栈顶指针esp就会向上(低地址、栈顶)移动一个单位,所以当push进ebp值的时候esp指针是自动向上移动的)
然后依次进行push进其他寄存器单元入栈 接下来是这四句汇编指令 lea edi,[ebp-0E4h] mov ecx,39h mov eax,0CCCCCCCCh rep stos dword ptr es:[edi]
lea=load effictive address显示有效地址 rep指令的目的是重复其上面的指令.ecx的值是重复的次数 stos指令的作用是将eax中的值拷贝到es:edi指向的地址 dword表明是double双字节也就是四个字节的内容
总结起来的作用就是从edi这个位置开始,向下进行ecx=39h次的0cccccccch赋值(int3中断) 其效果就是edi以下 ebp以上的所有内容被赋值为0cc cc cc cch 此时main函数的栈帧,就已经开辟完毕。 之后在ebp,esp维护的主函数空间内对变量a,b进行赋值,在栈底指针ebp上8个字节处对a进行赋值10,在ebp上20个字节处对b赋值20。 至此对于main函数的空间开辟以及对变量赋值过程结束。
接下来进入函数Add的传参操作。 可以看到,在传递参数时,先将ebp以上的20个字节的位置的内容赋值给寄存器eax。ebp以上20个字节的位置是谁?答案就是b !虽然我们没有对调用函数过程进行完整的剖析,但从这一行汇编指令中我们猛地发现:在向函数传递参数时,往往由右侧开始,向左侧进行。在Add(a,b)函数中,先向Add函数传递b的值,再传递a的值。 此时汇编代码来到 call 指令的出现意味着Add函数的空间开辟准备开始,可见call指令的下一条指令的地址是002618CA,执行call指令之后,该地址自动push进栈,esp指针向上移动一个单位。当call指令结束时,返回的地址恰是下一条汇编指令的地址。 F11进入call,开启正式开辟Add函数空间: 看到这个界面时,是不是有几分熟悉? 没错!Add函数与main函数开辟空间步骤几乎完全相同。 只有一处略有不同,只有开辟空间的大小与main函数有所不同,空间由编译器自动生成,使其能满足其所需空间。
但是,此处有一点不容忽略,我们刚刚传递的参数并没有存在与Add开辟的函数空间中。从宏观来看,结构应该是这样的(我在这里我省略了Add函数初始化内容与具体的局部变量赋值,应该知道用cccccccch初始化): 可见,形参x,y并没有在Add函数所开辟的空间中,这样恰印证了,我们常在传值调用时,将形式参数认为是实际参数的临时拷贝。因此改变形参并不会改变实际参数的值。
初始化完成之后,在ebp以上8个字节位置处存放z的值,对z进行初始化赋值为0。
在Add函数的求和过程中,即为老生常谈的mov,add操作,此处将ebp向下8个字节位置的内容(a=10)赋值给eax寄存器,又将ebp向下12个字节的内容(b=20)加到eax寄存器上,最终将eax的值赋给z所在空间位置
当完成加和运算之后,因为函数内所有参数会在函数调用结束时销毁,所有将z的值赋给eax来存储,以便返回到主函数中进行输出,这项操作也印证了为什么函数调用结束仍可以向主函数返回值。 三次pop操作,使edi,esi,ebx依次弹出栈区,此时esp栈顶指针指向了ebx向下的一个空间位置。 最终,函数要走向销毁: 销毁操作可谓是极其巧妙!只需将epb的值赋给esp,栈底指针的值赋给栈顶指针,二者重合,Add函数空间自然就会被销毁。
之后,pop ebp将ebp的值存放到ebp中(注意此处第一个ebp是指创建Add函数时的起始那个push edp,第二个ebp是创建main函数时的push edp) 此时ebp又回到了主函数所在的栈区空间的起始地址,如图) ret汇编指令的操作使 操作回到002618C地址对应的汇编指令处即call指令的下一个指令的地址对应处: 与此同时,栈顶存放的 002618C pop出栈,esp指针指向 对应的向下一个空间位置。然后esp指针加8对形式参数进行销毁,至此esp又与ebp维护了主函数main所在位置空间。 再对c进行赋值,即完成了从主函数main调用Add函数再返回主函数main的过程。
之后的printf及return 0销毁主函数过程与Add函数调用销毁过程雷同,不再赘述。
至此,本文推演了主函数main的创建,以及简单传值函数Add函数的参数传递、求和及返回主函数并销毁的全过程。
总结:C程序从一个被其他函数调用发生的main函数开始。参数传递过程,往往是从右侧参数向左侧参数依次进行的。而由esp和ebp维护栈区函数空间的行为称为函数栈帧。
希望本文对你有些许的帮助,希望我们可以共同进步。
转载请标明出处~
|