汇编世界其中过程的经典
前言
越是难的部分写的就得越具体,排版也得非常美丽,本来男的东西就不好理解,排版不好了更没有人看了.
上一章和大家一起探讨了汇编其中对于流程控制的实现,其中12种条件码寄存器的组合比較困难,有付出就会有回报,你好好搞,早晚有一天会成功!
程序设计语言发找到几天,过程已经是我们程序设计语言其中必备的功能,它能够使我们的代码结构更加清晰,也能够添加代码的复用性,降低非常多冗余代码的出现.本次我们一起来学习汇编过程中过程(或者说方法,函数)得实现方式,这一章的难度较高,可是也非常重要,大家不要放弃!
过程就是方法,函数,仅仅是叫法不同.
正文
栈帧的结构(重要)
倘若我们要想搞清楚过程的实现,就必须先知道栈帧的结构是怎样构成的.栈帧事实上可觉得是程序栈的一段,而程序栈又是存储器的一段,因此栈帧说究竟还是存储器的一段.那么既然是一段,肯定有两个端点,一根棍子有两头懂吗?
这两个端点事实上就是两个地址,一个标志着事实上地址,一个标志着结束地址,而这两个地址,则分别存储在固定的寄存器其中,即起始地址在%ebp寄存器中,结束地址存在%esp寄存器其中.至于为什么要存放在两个寄存器其中呢?
这就好像程序的下一条指令地址为什么存在PC其中一样,这还是王八的屁股----龟腚(规定).
起始地址和结束地址还有两外的名字,起始地址通常称为帧指针,结束地址通常称为栈指针(也就是栈顶的地址).因此,我们就把过程的寄存器内存使用区域称为栈帧.这下我们就了解了栈帧的来历以及它们的命名习惯和存储习惯,以下这幅图解释了栈帧在存储器其中的位置.
这个图基本上已经包括了程序栈的的构成,它由一系列栈帧构成,这些栈帧每个都相应着一个过程,并且每个帧指针+4的位置都存储着函数的返回地址,每个帧指针指向的存储器位置其中都备份着调用者的帧指针.大家须要知道的是,每个栈帧都建立在调用者的下方(也就是地址递减的方向),当被调用者执行完成的时候,这一段栈帧会被释放.另一点非常重要,%ebp和%esp的值指示着栈帧的两端,而栈指针会在执行时移动,所以大部分时候,在訪问存储器的时候会基于栈指针方位,由于在一直移动的栈指针无法依据偏移量准确的定位一个存储器的位置.
另一点也非常重要,就是栈帧其中内存的分配和释放.由于栈帧是向地址递减的方向延伸,因此假设我们将栈指针减去一定的值,就相当于给栈帧分配了一定空间的内存.这个理解起来非常easy,由于在栈指针向下移动以后(也就是变小了),帧指针和栈指针中间的区域会变长,这就是给栈帧分配了很多其它的内存.相反,假设将栈指针加上一定的值,也就是向上移动,那么相当于压缩了栈帧的长度,也就是说内存被释放了.须要注意的是,上面的一切内容都是基于一个前提,那就是帧指针在过程调用其中是不会移动的.
过程的实现
过程虽然非常好,但想要实现过程,还是存在一定难度的,虽然如今看来它并不困难.他实现的难度主要就在于数据怎样在调用者和被调用者之间传递,以及在被调用者其中局部变量内存的分配以及释放.
计算机方面的大神,想到了一种办法,能够简单的而且有效的处理过程实现其中的难题.这一切似乎看起来十分偶然,但事实上也是必定的.
从的来手,过程实现其中,參数传递以及局部变量内存的北呸和释放都是通过以上介绍的栈帧来实现的,大部分时候,我们觉得过程调用其中做了下面几个操作:
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加了registerkeyword修饰,这将会将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其中的值)都会依次向上移动,指针程序结束.也就是说,上面的三幅图,倒过来就是递归方法依次结束时栈帧的状态.
小小的结一下
不知道大家看了以后是什么感觉,我反正是不明确,当中最重要的是栈帧的建立和恢复.