您的位置:首页 > 其它

汇编世界当中过程的经典

2015-08-19 21:20 239 查看

汇编世界当中过程的经典

前言

越是难的部分写的就得越详细,排版也得很漂亮,本来男的东西就不好理解,排版不好了更没有人看了.

上一章和大家一起探讨了汇编当中对于流程控制的实现,其中12种条件码寄存器的组合比较困难,有付出就会有回报,你好好搞,早晚有一天会成功!

程序设计语言发找到几天,过程已经是我们程序设计语言当中必备的功能,它可以使我们的代码结构更加清晰,也可以增加代码的复用性,减少很多冗余代码的出现.本次我们一起来学习汇编过程中过程(或者说方法,函数)得实现方式,这一章的难度较高,但是也非常重要,大家不要放弃!

过程就是方法,函数,只是叫法不同.



正文

栈帧的结构(重要)

倘若我们要想搞清楚过程的实现,就必须先知道栈帧的结构是如何构成的.栈帧其实可认为是程序栈的一段,而程序栈又是存储器的一段,因此栈帧说到底还是存储器的一段.那么既然是一段,肯定有两个端点,一根棍子有两头懂吗?



这两个端点其实就是两个地址,一个标志着其实地址,一个标志着结束地址,而这两个地址,则分别存储在固定的寄存器当中,即起始地址在%ebp寄存器中,结束地址存在%esp寄存器当中.至于为什么要存放在两个寄存器当中呢?这就好像程序的下一条指令地址为什么存在PC当中一样,这还是王八的屁股----龟腚(规定).

起始地址和结束地址还有两外的名字,起始地址通常称为帧指针,结束地址通常称为栈指针(也就是栈顶的地址).因此,我们就把过程的寄存器内存使用区域称为栈帧.这下我们就了解了栈帧的来历以及它们的命名习惯和存储习惯,下面这幅图解释了栈帧在存储器当中的位置.






这个图基本上已经包含了程序栈的的构成,它由一系列栈帧构成,这些栈帧每一个都对应着一个过程,而且每个帧指针+4的位置都存储着函数的返回地址,每一个帧指针指向的存储器位置当中都备份着调用者的帧指针.大家需要知道的是,每一个栈帧都建立在调用者的下方(也就是地址递减的方向),当被调用者执行完毕的时候,这一段栈帧会被释放.还有一点很重要,%ebp和%esp的值指示着栈帧的两端,而栈指针会在运行时移动,所以大部分时候,在访问存储器的时候会基于栈指针方位,因为在一直移动的栈指针无法根据偏移量准确的定位一个存储器的位置.

还有一点也很重要,就是栈帧当中内存的分配和释放.由于栈帧是向地址递减的方向延伸,因此如果我们将栈指针减去一定的值,就相当于给栈帧分配了一定空间的内存.这个理解起来很简单,因为在栈指针向下移动以后(也就是变小了),帧指针和栈指针中间的区域会变长,这就是给栈帧分配了更多的内存.相反,如果将栈指针加上一定的值,也就是向上移动,那么相当于压缩了栈帧的长度,也就是说内存被释放了.需要注意的是,上面的一切内容都是基于一个前提,那就是帧指针在过程调用当中是不会移动的.





过程的实现

过程虽然很好,但想要实现过程,还是存在一定难度的,尽管现在看来它并不困难.他实现的难度主要就在于数据如何在调用者和被调用者之间传递,以及在被调用者当中局部变量内存的分配以及释放.



计算机方面的大神,想到了一种办法,可以简单的并且有效的处理过程实现当中的难题.这一切似乎看起来十分偶然,但其实也是必然的.

从的来手,过程实现当中,参数传递以及局部变量内存的北呸和释放都是通过以上介绍的栈帧来实现的,大部分时候,我们认为过程调用当中做了以下几个操作:

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

pushl  %ebp
movl 	%esp, %ebp




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

subl 	$16,%esp




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

pushl 	%ebx




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



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



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

movl 	%ebp,%esp




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

popl 	%ebp




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





过程的实现大概就是以上这八个步骤组成的,不过这些步骤并不都是必须的(大部分时候,开启编译器的优化会优化掉很多步骤),而且第六和第七有时会使用leave指令代替.这些步骤不是重点,了解一下就行,我说不是重点的意思是说和其他的内容相比,你懂我意思.在接下来的内容当中,还会有这几个步骤的详细示例.





过程相关指令:call,leave,ret

由于过程调用当总会经常见到几个新的指令,因此在这里,咱们先来介绍一下这三只领.他们都是过程实现当中非常重要的角色,这三个指令很类似,因为它们都是一个指令做了两件事,接下来就依次介绍它们各自都做了什么事.

call指令:它一共做两件事,第一件事是将返回地址(也就是call指令执行时PC的值)压入栈顶,第二件事就是将程序跳转到当前调用的方法的起始地址.第一件事是为了为过程的返回做准备,而第二件事则是真正的指令跳转.

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

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

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



寄存器使用的规矩

寄存器一共就8个,因此数目上来说的话,使用起来肯定是不够的.在这种情况下,就肯定需要一定的规矩去约束程序如何使用,否则要是一群人翻同一个人的牌子,那到底伺候谁才是呢.其实我们在之前已经或多或少的接触到了寄存器的规矩,比如%eax一般用于存储过程的返回值,%ebp保存帧指针,%esp保存栈指针.这里要介绍的,是另外一个规矩,而这个规矩是与过程实现相关的.

试想一下,在调用一个过程时,无论是调用者还是被调用者,都可能更新寄存器的值.假设调用者在%edx中存了一个整数值100,而被调用者也是用这个寄存器,并更新成了1000,于是悲剧发生了.当过程调用完毕返回后,调用者再使用%edx的时候,值已经变成了1000了,而不是以前的100了,这几乎必将导致程序会错误的执行下去.

为了避免上述这种情况发生,就需要在调用者和被调用者之间做一个协调.于是便有了这样的规则,他的描述如下,我们先假设在过程P中调用了过程Q,P是调用者,Q是被调用者.

%eax, %edx, %ecx:这三个寄存器被称为调用者保存寄存器.意思就是说,这三个寄存器由调用者P来保存,而对于Q来说,Q可以随便使用,用完了就不用管了.

%ebx, %esi, %edi :这三个寄存器被称为被调用者保存寄存器.同样的,这里是指这三个寄存器由被调用者Q来保存,换句话说,Q可以使用这三个寄存器,但是如果里面有P的变量值,Q必须保证使用完以后将这三个寄存器恢复到原来的值,这里的辈分,其实就是上面那八个步骤中第三个步骤做的事情.



一个过程实例

好吧,已经做好了充足的准备,看一个案例:



//文件名为function.c
int add(int a,int b){
   register int c = a + b; 
   return c;
}
 
int main(){
   int a = 100;
   int b = 101;
   int c = add(a,b);
   return c;
}


为了那八个完整的步骤,因此给变量c加了register关键字修饰,这将会将c送入寄存器,从而更改被调用者保存寄存器,就会导致步骤3的发生.接下来我们使用参数-S来编译这段代码,然后使用cat来查看这段代码的汇编形式.以下是main函数以及add函数的各自的栈帧情况:



由于我们没有使用编译优化,因此汇编代码会多出很多,这是为了完整的诠释我们的步骤.可以看出,图中包含了完整的八个步骤,单数无论是main函数还是add函数,他们单独来说,都没有完整的八个步骤,这其实是大多数情况.打不扥时候,,一个函数不会完全包含上述的8个步骤.

有几点需要注意,第一点是,add函数会将返回结果存入%eax(前提是返回值可以使用整数来表示),在main函数中,call指令之后,默认将%eax作为返回结果来使用.第二点是,所有函数(包括main函数)都必须有第1步和第6,7,8步,这是必须的四步.最后一点是,我们的帧指针和栈指针有固定的大小关系,即栈指针永远小于帧指针,当二者相等时,当前栈帧被认为没有分配内存空间.

这有一点十分有趣的事情,注意main函数当中100和101的传递过程,实现进入存储器,然后在进入寄存器的,然后再进入存储器,准备作为add函数的参数.这一来一回产生了四次寄存器与存储器之间的数据传输,倘若我们加上-O1参数去编译这个程序,编译器会将产生如下的代码:



可以看到,整个main哈数的指令数骤降,100和101将直接进入存储器,准备作为add函数的参数.可见编译器的优化当中至少会有一项,就是减少数据的来回传输,增加效率.不过这一点其实与过程的实现没啥关系,只是让以前可能不知道的同学看一下,编译器其实会将我们的程序做很大的改动.



递归过程调用

栈帧的建立和销毁惯例,可以保证递归过程的正常运行.其实如果大家愿意将上面的main函数和add函数的汇编代码搞清楚,那么递归调用其实也就没啥了,因为指令就这么多了,只要严格按照-S编译出的汇编指令,一步一步的推算寄存器和存储器的状态,那么递归滴啊用的实现也会自动浮现.

这里有一段简单的求n的阶乘的代码:

int rfact(int n){
    int result;
    if(n<=1){
        result = 1;
    }else{
        result = n * rfact(n-1);
    }
    return result;
}


接下来我们编译一下这段代码,使用-O1优化,我们得到如下的汇编代码:



各个步骤都已经做了详细的标注,其实按照严格指令FENIX,很轻松的就能够分析出图中的解释部分(即注释).难点在于,战阵的变化是如何的.下图演示了栈帧的变化过程




需要说明的是,以上每一个栈帧(大括号括起来的),最上面(也就是地址递增方向)的都是帧指针位置,最下面的都是栈指针位置.然而寄存器中只用%ebp和%esp保存栈帧指针,因此同一时间只能够保存一对.当进展懂啊第三层的时候,已经有了三个栈帧(原则上来讲一定是大于3个),寄存器当然是存不了的,因此需要在存储器当中备份一下,之后再恢复.于是就出现了每个栈帧的帧指针指向的存储器位置,都会备份着外层方法(也就是调用者)的帧指针.

当方法递归到n=1结束时,栈帧会自上而下的依次收回,栈帧指针(也就是%ebp和%esp当中的值)都会依次向上移动,指针程序结束.也就是说,上面的三幅图,倒过来就是递归方法依次结束时栈帧的状态.



小小的结一下

不知道大家看了以后是什么感觉,我反正是不明白,其中最重要的是栈帧的建立和恢复.
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: