越南做购物网站,百度登录个人中心,重庆营销网站,情侣博客 wordpress栈帧的结构 倘若我们要想搞清楚过程的实现#xff0c;就必须先知道栈帧的结构是如何构成的。栈帧其实可以认为是程序栈的一段#xff0c;而程序栈又是存储器的一段#xff0c;因此栈帧说到底还是存储器的一段。那么既然是一段#xff0c;肯定有两个端点#xff0c;这个不需…栈帧的结构 倘若我们要想搞清楚过程的实现就必须先知道栈帧的结构是如何构成的。栈帧其实可以认为是程序栈的一段而程序栈又是存储器的一段因此栈帧说到底还是存储器的一段。那么既然是一段肯定有两个端点这个不需要LZ再普及了吧。 这两个端点其实就是两个地址一个标识着起始地址一个标识着结束地址而这两个地址则分别存储在固定的寄存器当中即起始地址存在%ebp寄存器当中结束地址存在%esp寄存器当中。至于为什么要存在这两个寄存器当中就像程序的下一条指令地址为什么存在PC当中一样是毫无意义的问题就是这样规定的没有为什么。 起始地址和结束地址还有另外的名字起始地址通常称为帧指针结束地址通常称为栈指针也就是栈顶的地址。因此我们就把过程的存储器内存使用区域称为栈帧。这下我们就了解了栈帧的来历以及它们的命名习惯和存储惯例接下来是LZ画的一幅图它揭示了栈帧在存储器当中的位置。 这个图基本上已经包括了程序栈的构成它由一系列栈帧构成这些栈帧每一个都对应一个过程而且每一个帧指针4的位置都存储着函数的返回地址每一个帧指针指向的存储器位置当中都备份着调用者的帧指针。各位需要知道的是每一个栈帧都建立在调用者的下方也就是地址递减的方向当被调用者执行完毕时这一段栈帧会被释放。还有一点很重要的是%ebp和%esp的值指示着栈帧的两端而栈指针会在运行时移动所以大部分时候在访问存储器的时候会基于帧指针访问因为在一直移动的栈指针无法根据偏移量准确的定位一个存储器位置。 还有一点比较重要的内容就是栈帧当中内存的分配和释放。由于栈帧是向地址递减的方向延伸因此如果我们将栈指针减去一定的值就相当于给栈帧分配了一定空间的内存。这个理解起来很简单因为在栈指针向下移动以后也就是变小了帧指针和栈指针中间的区域会变长这就是给栈帧分配了更多的内存。相反如果将栈指针加上一定的值也就是向上移动那么就相当于压缩了栈帧的长度也就是说内存被释放了。需要注意的是上面的一切内容都基于一个前提那就是帧指针在过程调用当中是不会移动的。 过程的实现 过程虽然很好但想要实现过程还是存在一定难度的尽管现在看来它并不困难。它实现的难度主要就在于数据如何在调用者和被调用者之间传递以及在被调用者当中局部变量内存的分配以及释放。 不过天大的难题都难不倒那群计算机界的大神们他们找出了一种方式可以简单并有效的处理过程实现当中的难题。这一切似乎看起来十分偶然但其实也是必然的。世间的很多规律都是客观存在的只是它在等着我们去发现而已。 总的来说过程实现当中参数传递以及局部变量内存的分配和释放都是通过以上介绍的栈帧来实现的大部分情况下我们认为过程调用当中做了以下几个操作。 1、备份原来的帧指针调整当前的帧指针到栈指针的位置这个过程就是我们经常看到的如下两句汇编代码做的事情。 pushl %ebpmovl %esp, %ebp 2、建立起来的栈帧就是为被调用者准备的当被调用者使用栈帧时需要给临时变量分配预留内存这一步一般是经过下面这样的汇编代码处理的。 subl $16,%esp 3、备份被调用者保存的寄存器当中的值如果有值的话备份的方式就是压入栈顶。因此会采用如下的汇编代码处理。 pushl %ebx 4、使用建立好的栈帧比如读取和写入一般使用movpush以及pop指令等等。 5、恢复被调用者寄存器当中的值这一过程其实是从栈帧中将备份的值再恢复到寄存器不过此时这些值可能已经不在栈顶了。因此在恢复时大多数会使用pop指令但也并非一定如此。 6、释放被调用者的栈帧释放就意味着将栈指针加大而具体的做法一般是直接将栈指针指向帧指针因此会采用类似下面的汇编代码处理也可能是addl。 movl %ebp,%esp 7、恢复调用者的栈帧恢复其实就是调整栈帧两端使得当前栈帧的区域又回到了原始的位置。因为栈指针已经在第六步调整好了因此此时只需要将备份的原帧指针弹出到%ebp即可。类似的汇编代码如下。 popl %ebp 8、弹出返回地址跳出当前过程继续执行调用者的代码。此时会将栈顶的返回地址弹出到PC然后程序将按照弹出的返回地址继续执行。这个过程一般使用ret指令完成。 过程的实现大概就是以上八个步骤组成的不过这些步骤并不都是必须的大部分时候开启编译器的优化会优化掉很多步骤而且第6和第7步有时会使用leave指令代替。这里猿友们可以先了解一下这些步骤在接下来的内容当中还会有这几个步骤的详细示例。 过程相关指令call、leave、ret 由于过程调用当中会经常见到几个新的指令因此在这里LZ先给大家介绍一下这三个指令。它们三个都是过程实现当中非常重要的角色这三个指令很类似因为它们都是一个指令做了两件事这里LZ就依次介绍一下它们各自都做了什么事。 call指令它一共做两件事第一件是将返回地址也就是call指令执行时PC的值压入栈顶第二件是将程序跳转到当前调用的方法的起始地址。第一件事是为了为过程的返回做准备而第二件事则是真正的指令跳转。 leave指令它也是一共做两件事第一件是将栈指针指向帧指针第二件是弹出备份的原帧指针到%ebp。第一件事是为了释放当前栈帧第二件事是为了恢复调用者的栈帧。 ret指令它同样也是做两件事第一件是将栈顶的返回地址弹出到PC第二件事则是按照PC此时指示的指令地址继续执行程序。这两件事其实也可以认为是一件事因为第二件事是系统自己保证的系统总是按照PC的指令地址执行程序。 可以看出除了call指令之外leave和ret指令都与上面8个步骤有些不可分割的关系。call指令没有在8个步骤当中体现是因为它发生在进入过程之前因此在第1步发生的时候call指令往往已经被执行了并且已经为ret指令准备好了返回地址。 寄存器使用的规矩 寄存器一共就8个因此在数目上来说的话使用起来肯定是捉襟见肘的。在这种情况下就肯定需要一定的规矩去约束程序如何使用否则要是一群人翻同一个人的牌子那到底伺候谁才是呢。其实我们在之前已经或多或少的接触到了寄存器的规矩比如%eax一般用于存储过程的返回值%ebp保存帧指针%esp保存栈指针。这里要介绍的是另外一个规矩而这个规矩是与过程实现相关的。 试想一下在调用一个过程时无论是调用者还是被调用者都可能更新寄存器的值。假设调用者在%edx中存了一个整数值100而被调用者也使用这个寄存器并更新成了1000于是悲剧就发生了。当过程调用完毕返回后调用者再使用%edx的时候值已经从100变成了1000这几乎必将导致程序会错误的执行下去。 为了避免上面这种情况发生就需要在调用者和被调用者之间做一个协调。于是便有了这样的规矩它的描述如下我们假设这里在过程P中调用了过程QP是调用者Q是被调用者。 %eax、%edx、%ecx这三个寄存器被称为调用者保存寄存器。意思就是说这三个寄存器由调用者P来保存而对于Q来说Q可以随便使用用完了就不用再管了。 %ebx、%esi、%edi这三个寄存器被称为被调用者保存寄存器。同样的这里是指这三个寄存器由被调用者Q来保存换句话说Q可以使用这三个寄存器但是如果里面有P的变量值Q必须保证使用完以后将这三个寄存器恢复到原来的值这里的备份其实就是上面那8个步骤中第3个步骤做的事情。 一个过程示例 上面已经做好了充足的准备接下来我们就要探索真理了我们随便写一个调用过程的例子LZ写了以下的代码来做这个十分重要的例子我们称它为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; } 这里LZ为了完整的展现那8个步骤因此给变量c加了register关键字修饰这将会将c送入寄存器从而更改被调用者保存寄存器就会导致步骤3的发生。接下来我们就使用参数-S来编译这段代码然后使用cat来看看这段代码的汇编形式。以下是main函数以及add函数各自的栈帧情况LZ已经详细标记了它们属于哪个步骤。 由于我们没有使用编译优化因此汇编代码会多出很多这也为了完整的诠释我们的步骤。可以看到图中包含了完整的8个步骤但是无论是main函数还是add函数它们单独来讲都没有完整的8个步骤这其实是大多数的情况。大部分时候一个函数不会完全包含上述的8个步骤。LZ这里不再一一拆分各个步骤各位猿友可以严格按照各个指令的作用自己画图理解一下这个过程答案自会浮现。 LZ这里只说几点各位需要注意的地方首先第一点是add函数会将返回结果存入%eax前提是返回值可以使用整数来表示在main函数中call指令之后默认将%eax作为返回结果来使用。第二点是所有函数包括main函数都必须有第1步和第6、7、8步这是必须的4步。最后一点是我们的栈指针和帧指针有固定的大小关系即栈指针永远小于等于帧指针当二者相等时当前栈帧被认为没有分配内存空间。 还有一点十分有趣的事情注意main函数当中100和101的传递过程是先进入存储器然后再进去寄存器然后再进去存储器准备作为add函数的参数。这一来一回产生了四次寄存器与存储器之间的数据传输倘若我们加上-O1参数去编译这个程序编译器将产生如下的汇编代码。 可以看到整个main函数的指令数骤降100和101将直接进入存储器准备作为add函数的参数。可见编译器的优化当中至少会有一项就是减少数据的来回传输增加效率。不过这一点其实与过程的实现没有什么关系只是让以前可能不知道的猿友看一下编译器其实会将我们的程序做很大的改动。 递归过程调用 书中对递归调用还进行了说明这是为了让我们相信栈帧的建立和销毁惯例可以保证递归过程的正常运行。其实如果各位猿友愿意一点一点的将上面main函数和add函数的汇编代码搞清楚那么递归调用其实也可以很轻松的搞定。因为指令就这么多了只要严格按照-S编译出的汇编指令一步一步的推算寄存器和存储器的状态那么递归调用的实现也会自动浮现。 LZ这里准备给各位猿友诠释一下递归的过程各位猿友可以对照着上面的示例看一下以下是一段简单的求n的阶乘的代码。 int rfact(int n){int result; if(n1){ result 1; }else{ result n * rfact(n-1); } return result; } 接下来我们编译一下这段代码使用-O1优化我们可以得到如下的汇编代码。 LZ在图中详细标注了各个步骤所做的事情其实严格按照各个指令的作用分析很轻松的就可以分析出图中的解释部分即注释。难点就在于栈帧的变化是如何的LZ这里就给各位演示一下栈帧的变化过程如果各位已经把前面的那个main函数和add函数搞定了那么可以在这里验证一下自己的理解是否正确。 需要特殊说明的是以上每一个栈帧大括号括起来的最上面也就是地址递增方向的都是帧指针位置最下面的都是栈指针位置。然而寄存器中只有%ebp和%esp保存栈帧指针因此同一时间只能保存一对。当进展到第三层的时候已经有了三个栈帧原则上来讲一定是多于3个寄存器当然是存不下的因此就需要在存储器当中备份一下之后再恢复。于是就出现了每个栈帧的帧指针指向的存储器位置都会备份着外层方法也就是调用者的帧指针。 当方法递归到n1结束时栈帧会自下向上依次收回栈帧指针也就是%ebp和%esp当中的值都会依次向上移动直到程序结束。也就是说上面的三幅图如果倒过来就是递归方法依次结束时栈帧的状态。 由此就可以看出过程当中栈帧建立以及完成的惯例可以保证递归调用的正常运行包括循环调用。不得不说这群计算机界的大神们实在是太牛了尽管当栈帧出现以后看起来也并不复杂但难点就在于无中生有的发现或者说某种意义上的创造。 作者zuoxiaolong左潇龙 出处博客园左潇龙的技术博客--http://www.cnblogs.com/zuoxiaolong转载于:https://www.cnblogs.com/zzdbullet/p/9629909.html