函数调用:栈帧的开辟与回退

一份源文件是由数据和指令组成的,函数通常作为指令和数据的集合被人们创造出来实现各种功能。就是这个我们天天在调用着的函数,你有没有想过:

1.我们在调用它的时候系统做了什么?

2.main函数中如果还有另一个函数,在跳转后运行完这个函数时,编译器怎么知道下一行执行哪个语句呢?会不会又从头执行了?

3.函数在结束之后(运行到反花括号“}”处),系统又是怎么处理的?

4.不同的语言对函数形参内存的处理都是一样的吗?

5.函数的返回值有哪些类型?都是怎么从函数中返回回来的呢?

如果以上5个问题你都能轻松回答,那么说明函数的调用过程你已了如指掌,这篇文章不是为你准备的。相反如果你有几个问题不是很清楚,或者甚至一个问题都没考虑过,看完这篇文章我希望能解答你的疑惑。:P

 

我们首先先来上代码,作为我们来讲解函数调用的例子。

#include <stdio.h>

int sum (int a,int b)
{
    int temp = 0;
    temp = a + b;
    return temp;
}
    
int main ()
{
    int a = 10;
    int b = 20;
    int ret = 0;
    ret = sum(a,b);
    printf("ret = %d\n",ret);
    return 0;
}

代码很简单,定义了一个可以将参数相加的函数,在主函数定义局部变量,对sum进行一次调用并打印调用结果。

运行时,从main开始执行。在调用main函数时,首先要做的就是创建main函数的栈帧。也就是说,调用一个函数之前肯定是先给他开辟栈帧,存放函数中的变量以及其它的东西。具体有什么,我们就来看看main函数的栈帧吧。

                                          图1

这是main函数最开始的样子,其实也可以说是每个函数最开始的样子。ebp和esp是两个寄存器,不过它们都是在保存栈的地址,作用和指针没什么差别,我们就把它们看成指针。其中,ebp是栈底指针,处于高地址处,esp是栈顶指针处于低地址处。在调用了函数从左边的花括号开始,函数就开辟好了自己的空间。esp指向一块区域同时也是栈底,至于这块区域里面是什么,空间开辟了以后怎么初始化的,我们稍后讲到sum函数的调用你就都会明白了。

进入main之后继续往下走,是a,b,ret三个局部变量。系统就将它们都存入main函数的栈帧中,通过ebp减去偏移量的方式来访问内存。由于a、b、ret都是int类型,所以存入a时的汇编代码就是

mov    dword ptr [ebp-4],0Ah

存入b:

mov    dword ptr [ebp-8],14h

存ret:

mov    dword ptr [ebp-0Ch],0

mov就是赋值的意思,dword代表4字节,intel平台下的汇编代码是从右向左看,如a,将0A(10)这个dword(4字节的)数据赋值给[ebp-4]这块地址的解引用。在vc编译器中显示的是ebp的偏移量,而在visual studio中则直接使用变量名,如图2。

                                   图2

现在,我们将a、b、ret都存入栈了,现在栈长这样:

                                               图3

接下来我们继续执行。执行到这一句:ret = sum(a,b);

这里就是我们文章开头提的第二个问题了。我们现在要进入sum函数去了,等下我们执行完sum函数怎么回到当前位置继续执行下一行的打印呢?聪明的你肯定想到了,每一行指令都有在虚拟地址空间中有自己的地址,我把下一行代码的地址记录一下不就可以了?记录在哪呢?当然是栈里啦。

不过在这之前你可别着急。我们看看sum函数是要传参数的。我们一般使用的调用约定_cdecl规定参数入栈是从右向左的,这样我们自己给参数的时候会更加安全。我们在栈顶处将参数a和参数b入栈。怎么访问到它们呢?简单,用ebp就行了。但是怎么把它们入栈呢?将b通过ebp-8获取,赋给4字节寄存器eax,将a通过ebp-4获取赋给 4字节寄存器ecx。通过两个四字节的寄存器带出,也省的使用临时量了。现在的栈如下:

                                           图4

反汇编代码:

                                     图5

我们看到和我讲解的一样,按照从b到a的顺序分别使用eax和ecx寄存器执行两次push(入栈)操作将参数ab写入栈。同时因为执行了push操作,我们的栈顶指针esp同时也上移了,如图4所示,它现在指向参数a。

现在参数传完了,我们终于该正式地调用sum函数了。我们看到的call指令这一行,就是调用sum函数。点击一下逐步执行,看看会发生什么?

                                      图6

为什么没有直接跳转到sum的定义,而是显示了这一些什么乱七八糟的东西?我们注意到,这里好像是一个表,这些蓝字好像都是函数,还有一个jmp指令。我们在讲虚拟地址空间的时候说过给函数分配的地址是一个偏移量,偏移量加上pc寄存器的值才能跳转到函数真实的地址。所以在调用call时,先会跳转到jmp表确定函数的地址,再进入函数。

同时,我观察到我添加监视的esp发生了变化,向低地址方向移动了。这说明,在跳转到jump这里时,有什么东西入栈了——没错,这时候入栈的正是我们的sum函数的下一行指令地址,用于回到代码原来位置的。现在的栈以及点击下一步的汇编代码如下:

                                          图7

                                     图8

由于之前执行了sum下一行指令地址的入栈操作(call调用时),现在esp指向的就是这块地址。我们文章开头的第二个问题也就解决了。接下来看图8,我们进入到了sum函数里。不过,在往sum函数存它自带的东西之前我们当然要先开辟sum的栈帧啦!和main函数类似,我们就借此机会了解一下栈帧开辟的具体过程。

首先调用push ebp,根据图7我们此时的ebp指向的是main函数的栈底地址。push ebp即将这个地址入栈,这就是便于sum函数执行完毕之后能让ebp回退到main函数栈底的方法。所以这块内存存的就是调用方函数的栈底地址。我们一开始说main函数最底下那块“神秘区域”现在你明白里面存的什么了吗?没错,就是调用main函数的函数(mainCRTStartup)函数的栈底指针,main函数结束后ebp会回退到mainCRTStartUp()函数的栈底!

接下来mov ebp,esp    将esp赋给ebp,即让ebp指向esp指向的那块区域。由于刚才入栈了一次main函数的栈底地址,所以现在栈已经成了这样:

                                                 图9

接下来进行的操作  sub  esp 0CCh  将esp+=0CC 即将栈顶指针上移了0CC,作为sum函数的栈顶。也意味着sum函数栈帧的大小 就是0CC。接下来的对edi,eci,ecx寄存器的操作我们都不用关心,因为它们在这例子里什么都没做,只是入栈又出栈。我们要重点关心的是这两句:

mov  eax 0CCCCCCCCh

rep stos  dword ptr es:[edi]

这两句的意思是先将CCCCCCCC存进eax,再用rep stos指令(类似循环拷贝)对我们开辟的栈进行初始化赋值。于是栈变成了这样:

                                            图 10

这时,sum函数的栈帧就完全申请好了。main函数在创建的时候,也会经历这样一串固定的流程。说句题外话,0CCCCCCCC对应的汉字就是“烫”,所以我们平常如果看见一串“烫烫烫烫..”你就应该明白了,你打印的值指向的是栈中没有人为初始化的部分!(另一种情况是堆中未人为初始化,显示的是“屯”)

接下来就是存入temp这个局部变量了,当然地址是ebp-4。这时候我们的栈帧就是完全状态了!

                                         图 11

接下来执行代码:temp = a + b;我们这时需要取形参中传入的a和b了。看看图11,参数ab在哪呢?不就是ebp+0Ch 和ebp+8嘛。所以接下来就是:

mov   eax,dword ptr [ebp+0Ch]

add   eax,    dword ptr [ebp+8]

mov  dword ptr [ebp-4],eax 

直接将a的值存入eax,再读取b的值和a相加存入eax,通过eax四字节寄存器带给temp。

接下来就要return temp了:

 mov         eax,dword ptr [ebp-4]

将temp中的值存进eax,免去了中间变量。

这时,我们的sum函数已经执行完了,迎来了它生命的终结:“}”反花括号。那么开辟了的栈帧要怎么回退呢?

我们看到,首先是edi,esi,ebx这三个我们说过的没起作用的寄存器出栈。然后mov esp,ebp即将esp的指向从栈顶直接拉到栈底,放弃栈的空间。此时回到了图9的状态。

然后pop ebp,这行指令的意思是先将此时栈顶的元素赋值给ebp,再将栈顶出栈。那么这时候ebp就回到了main函数的栈底,回归到图7的状态。

接下来是ret指令。这个指令又具体干了什么呢?ret和pop很像,也是将当前栈顶元素(sum下一行指令的地址)赋给一个寄存器然后出栈。赋给谁呢?赋给专门存下一行运行地址的 pc寄存器了。

现在梳理一下状况,ebp已经回到main函数的栈底,esp也由于出栈操作退回到了sum函数的两个实参处,eax寄存器中保存着sum函数的运算结果:temp=a+b,pc寄存器中保存着sum下一行的指令。现在栈的情况是图4.

现在sum函数已然消失,传入的形参a和b自然也应该人去楼空了。编译器执行add         esp,8,将esp向高地址移动8位,退还a,b所占的栈帧。此时栈状态是图3.

接下来,我们要将eax中存的计算结果赋给main函数中的ret变量了:

mov         dword ptr [ebp-0Ch],eax

至此,sum函数正式结束。接下来就是打印函数了。当打印函数也完成后,我们的main函数也遇到了它的反花括号。重复上述的函数退栈过程,栈底指针回归mainCRTStartUp()中。这便是这份简单的源代码运行的背后的流程。

 

现在回到文章开头处第四个问题。不同语言对函数的结束处理都一样吗?

在这里我们要引入调用约定的概念。函数调用时遵循约定中的规则。共有以下几种:

1._cdecl

2._stdcall

3._fastcall

4._thiscall

5._pascal(已淘汰)

①_cdecl:我们本文中的例子就是调用的此约定,调用方(main函数)来为形参开辟内存,并为它清理。

②_stdcall:调用方开辟,被调用方自己清理。

③_fastcall:既然叫fast那就是很快的意思:根本不使用栈,直接使用寄存器存储形参。我们都知道寄存器是4个字节的,如果这个形参大于4个字节呢?4~8字节我们就用两个寄存器带回来不就完了嘛。若大于8个字节,就需要在栈里申请空间了。至于清理,仍然是调用方生成,被调用方进行清理。

 

针对第五个问题:函数返回值类型与我上面说的也有相同之处。返回值无外乎4字节,4~8字节,大于8字节三种。4字节的我们直接使用eax带回,没有中间变量产生,非常方便。4~8字节我们就是分两次,eax和edx带出。

若大于8字节,我们就要使用到esi和edi寄存器了。esi指向开辟的存放数据内存的栈顶:lea esi,[ebp-50h](这里我们假设这个参数的大小就是80字节,一个int[20],即0x50的大小)。在这种情况下,主调函数中将有一块内存被开辟专门用来保存临时量的叫做临时量内存。这块内存在调用子函数之前就产生了,是在call之后入栈地址的。(看见esi和edi很熟悉?我们在讲例子中sum函数call之后进入时初始化栈帧的时候它们就出现过啦!只不过当时我们的参数是int,只用寄存器就行了,所以我才会说它们在“本例中没用”。)

edi寄存器保存的就是主调函数中临时变量内存的地址,通常使用ebp+8访问。这时使用rep movs edi esi把esi中的数据循环拷贝到edi里。

可以看到在call之前,先是从edi中将b数组循环拷贝给esi,再从a数组中循环拷贝给esi。这就是大致的实现方法,更加具体的例子还请查阅《程序员自我修养》,有更加详细的解释。

评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值