深入理解计算机系统:过程(函数调用栈帧变化)

一:栈帧结构

寄存器帧指针%ebp ,栈指针%erp 。

因为栈指针经常移动,所以基于地址的访问多数是以帧指针为基础的。而被调用者的栈帧一般在调用者下方。

 

实现过程

过程的实现主要就是在于数据如何在调用者和被调用者之间传递,以及在被调用者当中局部变量内存的分配以及释放。

而过程实现当中,参数传递以及局部变量内存的分配和释放都是通过以上介绍的栈帧来实现的,大部分情况下,我们认为过程调用当中做了以下几个操作。

  ①、备份原来的帧指针,调整当前的帧指针到栈指针的位置,这个过程就是我们经常看到的如下两句汇编代码做的事情。

pushl   %ebp
movl    %esp, %ebp

 

  ②、建立起来的栈帧就是为被调用者准备的,当被调用者使用栈帧时,需要给临时变量分配预留内存,这一步一般是经过下面这样的汇编代码处理的。

1
subl    $16,%esp

  ③、备份被调用者保存的寄存器当中的值,如果有值的话,备份的方式就是压入栈顶。因此会采用如下的汇编代码处理。  

1
pushl    %ebx

  ④、使用建立好的栈帧,比如读取和写入,一般使用mov,push以及pop指令等等。

  ⑤、恢复被调用者寄存器当中的值,这一过程其实是从栈帧中将备份的值再恢复到寄存器,不过此时这些值可能已经不在栈顶了。因此在恢复时,大多数会使用pop指令,但也并非一定如此。

  ⑥、释放被调用者的栈帧,释放就意味着将栈指针加大,而具体的做法一般是直接将栈指针指向帧指针,因此会采用类似下面的汇编代码处理(也可能是addl)。

1
movl    %ebp,%esp

  ⑦、恢复调用者的栈帧,恢复其实就是调整栈帧两端,使得当前栈帧的区域又回到了原始的位置。因为栈指针已经在第六步调整好了,因此此时只需要将备份的原帧指针弹出到%ebp即可。类似的汇编代码如下。  

1
popl    %ebp

  ⑧、弹出返回地址,跳出当前过程,继续执行调用者的代码。此时会将栈顶的返回地址弹出到PC,然后程序将按照弹出的返回地址继续执行。这个过程一般使用ret指令完成。

  过程的实现大概就是以上八个步骤组成的,不过这些步骤并不都是必须的(大部分时候,开启编译器的优化会优化掉很多步骤),而且第6和第7步有时会使用leave指令代替。

 

过程调用和返回

 

 

  (1)call指令:call 指令有一个目标,即指明被调用过程起始的指令地址。直接调用的目标可以是一个标号,间接调用的目标是 * 后面跟一个操作符。它一共做两件事,第一件是将返回地址(也就是call指令执行时PC的值)压入栈顶,第二件是将程序跳转到当前调用的方法的起始地址。第一件事是为了为过程的返回做准备,而第二件事则是真正的指令跳转。

 (2)leave指令:它也是一共做两件事,第一件是将栈指针指向帧指针,第二件是弹出备份的原帧指针到%ebp。第一件事是为了释放当前栈帧,第二件事是为了恢复调用者的栈帧。

 (3)ret指令:它同样也是做两件事,第一件是将栈顶的返回地址弹出到PC,第二件事则是按照PC此时指示的指令地址继续执行程序。这两件事其实也可以认为是一件事,因为第二件事是系统自己保证的,系统总是按照PC的指令地址执行程序。

  可以看出,除了call指令之外,leave和ret指令都与上面8个步骤有些不可分割的关系。call指令没有在8个步骤当中体现,是因为它发生在进入过程之前,因此在第1步发生的时候,call指令往往已经被执行了,并且已经为ret指令准备好了返回地址。

 

 在 IA32 中,寄存器%eax,%edx和%ecx被划分为调用者保存寄存器。当过程 P 调用 Q 时,Q可以覆盖这些寄存器,而不会破坏 P 所需的数据。

 寄存器%ebx,%esi和%edi被划分为被调用者保存寄存器。这里 Q 必须在覆盖这些寄存器的值之前,先把他们保存到栈中,并在返回前恢复它们,因为 P(或某个更高层次的过程)可能会在今后的计算中需要这些值。上面所说的过程实现的8个步骤中第三步便是如此。

int P(int x)
{
    int y = x*x;
    int z = Q(y);     
    return y+z;
}

  (1)可以在调用 Q 之前,将 y 的值保存在自己的帧栈中;当 Q 返回时,过程 P 就可以从栈中取出y 的值。换句话说就是调用者 P 自己保存这个值。 过程 P 在调用 Q 之前会先计算 y 的值,而且它必须保证 y 的值在 Q 返回后是可用的。这里有两种方法实现:

  (2)可以将 y 保存在被调用者保存寄存器中。如果 Q ,或者其它 Q 调用的程序想使用这个寄存器,它必须将这个寄存器的值保存在帧栈中,并在返回前恢复该值。换句话说就是被调用者保存这个值。当 Q 返回到 P 时,y 的值会在被调用者保存寄存器中,或者是因为寄存器根本就没有改变,或者是因为它被保存并恢复了。

  这两种方法在 IA32 中是都采用的。

 

 

 

过程的实现主要就是在于数据如何在调用者和被调用者之间传递,以及在被调用者当中局部变量内存的分配以及释放。

  而过程实现当中,参数传递以及局部变量内存的分配和释放都是通过以上介绍的栈帧来实现的,大部分情况下,我们认为过程调用当中做了以下几个操作。

  ①、备份原来的帧指针,调整当前的帧指针到栈指针的位置,这个过程就是我们经常看到的如下两句汇编代码做的事情。

1
2
pushl   %ebp
movl    %esp, %ebp

  ②、建立起来的栈帧就是为被调用者准备的,当被调用者使用栈帧时,需要给临时变量分配预留内存,这一步一般是经过下面这样的汇编代码处理的。

1
subl    $16,%esp

  ③、备份被调用者保存的寄存器当中的值,如果有值的话,备份的方式就是压入栈顶。因此会采用如下的汇编代码处理。  

1
pushl    %ebx

  ④、使用建立好的栈帧,比如读取和写入,一般使用mov,push以及pop指令等等。

  ⑤、恢复被调用者寄存器当中的值,这一过程其实是从栈帧中将备份的值再恢复到寄存器,不过此时这些值可能已经不在栈顶了。因此在恢复时,大多数会使用pop指令,但也并非一定如此。

  ⑥、释放被调用者的栈帧,释放就意味着将栈指针加大,而具体的做法一般是直接将栈指针指向帧指针,因此会采用类似下面的汇编代码处理(也可能是addl)。

1
movl    %ebp,%esp

  ⑦、恢复调用者的栈帧,恢复其实就是调整栈帧两端,使得当前栈帧的区域又回到了原始的位置。因为栈指针已经在第六步调整好了,因此此时只需要将备份的原帧指针弹出到%ebp即可。类似的汇编代码如下。  

1
popl    %ebp

  ⑧、弹出返回地址,跳出当前过程,继续执行调用者的代码。此时会将栈顶的返回地址弹出到PC,然后程序将按照弹出的返回地址继续执行。这个过程一般使用ret指令完成。

  过程的实现大概就是以上八个步骤组成的,不过这些步骤并不都是必须的(大部分时候,开启编译器的优化会优化掉很多步骤),而且第6和第7步有时会使用leave指令代替。下面会详细讲解这些步骤。

pushl   %ebp
movl    %esp, %ebp
posted @ 2020-09-07 14:31  dragonsbug  阅读(670)  评论(0编辑  收藏  举报