CMM编译器和C编译器过程调用实现的比较

来源:互联网 发布:linux objdump 编辑:程序博客网 时间:2024/04/28 17:03

之前写了一个cmm的解释器,并且自己设计实现了函数调用。当时设计的时候并没有看c编译器是如何实现的,不过今天看了深入理解计算机系统之后,发现我的设计方法和c的还真的特别像,现在就简单总结一下,因为里面涉及了很多的汇编知识,所以不懂汇编的童鞋最好先看一下汇编:


首先我想说的是一个最重要的思想,那就是相对寻址的思想,如果没有这个方法,那么编写过程调用几乎是不可能的。因为计算机指令是以变量的地址作为操作数的,那么也就意味着你在生成代码的时候要事先把变量的地址都给算出来。但是过程调用有时候是不确定的,比如递归,你不确定它要调用多少次自身才会return,那么自然而然所产生的局部比变量的个数也是不确定的,那么很显然我们不能在操作数中使用绝对地址。那么做法就是设置一个基址寄存器,在c中就是ebp,它记录的是该过程调用开始处栈的基地址。有了这个东西,我们只需要确定过程调用代码中各个变量的相对过程调用开始处的地址,那么不管我们调用多少次这个过程的代码块,堆栈会往上成块增长,而且在过程中的相对地址不会发生改变。


那么下面我简单说一下我的设计:

我在每次发生过程调用的时候,要把老的ebp保存在新的栈帧的底部,然后利用回填的技术,将返回地址也保存在栈帧的底部,这样每发生一次过程调用,栈帧的底部必定是这两个值。然后新的栈帧指针便是老的ebp的堆栈位置,当然我们更新ebp的时候是通过ADD方法,不断累加上去的,即将ebp寄存器中的值加上目前相对位置的最大偏移量。

然后再压入的便是过程的参数了。我直接压入的是各个值,也就是说所有的过程调用都是传值方式。这也是我设计的局限性吧。把所有的参数压入栈中之后,在之后如果在过程中使用它们的时候,便可以查看符号表中有关函数的参数的信息,查出它们的参数顺序,然后通过计算转换成它们的相对地址。之后如果有局部变量声明的时候,便在该函数的栈帧中继续分配空间进行存储。

当遇到return语句的时候,便是进行返回了。返回之前,需要恢复ebp,但是这之前需要先把返回地址存储到临时变量区中,否则改变了ebp之后便取不到它的值了,而且还需要将返回值加载到eax中,以便为返回之后的赋值。

我的设计确实可以实现过程调用,包括递归。下面我们看看C编译器的设计,我是参考书以及通过查看反汇编代码来感悟的:

首先我们先看一下网上有关过程调用的活动记录的图示吧:


很显然,从图中可以看出c编译器和我的设计在堆栈保存结构上是不同的,而且最大的不同是,在发生过程调用的时候,它是选择将被调函数的参数和返回地址保存在调用者的栈帧中的。由于栈帧是从高地址向低地址增长的,这就造成被调过程在访问参数值的时候,需要将相对地址设置为正值,因为此时ebp已经更新了。而且,C中使用了esp寄存器,用来保存当前栈顶的绝对地址(按照我的想法是这样的)。为了更深入地理解这个过程调用的过程,我们来看下面的汇编代码。

调用者:main{

int start = 9;

int stride = start + 2;

char* tmp = extract_message1(start,stride);

}

被调者:char * extract_message1(int start, int stride) {.......return "cao";}

那么我们通过反汇编器来看看在发生过程调用的前后都发生了什么吧:(与过程调用无关的汇编代码都已经删去)

首先在过程调用的外部:

00A5183C  mov         eax,dword ptr [stride]  
00A5183F  push        eax  
00A51840  mov         ecx,dword ptr [start]  
00A51843  push        ecx  
00A51844  call        extract_message1 (0A51122h)  
00A51849  add         esp,8  
00A5184C  mov         dword ptr [tmp],eax 

那么从这个汇编代码中其实已经很清楚了。首先是从右自左地压入函数的参数。然后通过call指令发生函数调用并且跳转到函数进行执行。返回之后会继续执行下面的指令,注意这个add指令,其实它的意图很明显,相当于释放参数的空间。但是要注意,其实在add之后我们紧接着还是可以访问他们的。最后是执行赋值语句,将eax中的返回值移到变量tmp的存储空间内。


那么在call指令中都发生了什么呢?

看下面的汇编代码:

00A515A0  push        ebp  
00A515A1  mov         ebp,esp  
00A515A3  sub         esp,0F0h  
00A515A9  push        ebx  
00A515AA  push        esi  
00A515AB  push        edi  
00A515AC  lea         edi,[ebp-0F0h]  
00A515B2  mov         ecx,3Ch  
00A515B7  mov         eax,0CCCCCCCCh  


上面的代码就是在call指令开始执行之后所发生的。其实call指令做了两部分工作,第一部分是将返回地址压入到栈中,第二部分就是跳转到过程开始处进行执行。两部分工作都和pc寄存器相关。那么接下来就是过程建立的准备工作。从汇编代码中可以看出。首先是保存ebp,然后设置新的ebp,新的值就是当前esp的值。注意我是在ebp中直接更新的,也就是绝对地址是在ebp中不断更新的,而c中明显可以看出,它的绝对值的更新是通过esp的,更新ebp是借助于esp中的绝对地址的。更新过之后,再分配一段空间(我不太清楚是干嘛的,不过这个时候esp发生改变了,说明栈顶又上移了),然后是保存被调用者保存寄存器。

这里简单说一下调用者保存寄存器和被调用者保存寄存器。调用者保存的意思就是调用者在调用过程之前都需要把这些寄存器push到自己的栈帧中。也就是说它不确定子过程是否要使用这些个寄存器,所以为了保险都全都push了吧。而被调用者保存寄存器则是调用者不用保存,而在调用的过程中如果使用了这些个寄存器的话,那么就需要push到子过程的栈帧中,再进行覆盖。在子过程返回之后,需要再次恢复这些值。如果子过程不使用这些寄存器的话,那么就不必进行栈帧读写。所以这种方式可以潜在地减少栈帧读写的次数。因此C编译器采用的后一种方式。按照惯例,在Ia32机器中,调用者保存寄存器有eax,ecx,edx,而被调用者保存寄存器有ebx,esi,edi.所以上面的那三句push语句你应该明白了吧。最后的三条指令我想大概做的是初始化工作吧。暂且不去管它。

那么当遇到return语句之后,且看下面的汇编:

00A5164E  pop         edi  
00A5164F  pop         esi  
00A51650  pop         ebx  
00A51651  mov         esp,ebp  
00A51653  pop         ebp  
00A51654  ret  

可以看出,在恢复被调用者保存寄存器之后,计算机还恢复了ebp的值,pop ebp的效果就是将old ebp保存到了ebp寄存器中。最后的ret语句,则是将返回地址pop掉,并且将pc的值设置到那即可。


从上面可以看出,其实我设计的思想和c的还是一样的,都是相对,跳转,栈帧。所以从这点来看,至少我的设计是正确的。

























原创粉丝点击