c++利用栈书写的计算机程序,没太看懂?

版权声明:本文为博主原创文章,遵循 版权协议,转载请附上原文出处链接和本声明。

要说堆、栈的区别,首先要有一个概念:一个进程的4G虚拟地址空间划分:(如图)

 整体上从低地址到高地址可以划分为:3G的用户空间和1G的内核空间。

用户空间中:从低地址到高地址分别为:128M的不可以访问区;.text指令段;.data数据段;.bss数据段;... ;heap堆区(自低地址向高地址开辟空间);....;stack栈区(自高地址向低地址开辟空间);......

堆区(heap)。用于动态内存分配。堆在内存中位于BSS区和栈区之间。一般由程序员分配和释放,若程序员不释放,程序结束时有可能由OS回收。

栈区。由编译器自动分配释放,存放函数的参数值、局部变量的值等。其操作方式类似于数据结构中的栈。每当一个函数被调用,该函数返回地址和一些关于调用的信息,比如某些寄存器的内容,被存储到栈区。然后这个被调用的函数再为它的自动变量和临时变量在栈区上分配空间,这就是C实现函数递归调用的方法。每执行一次递归函数调用,一个新的栈框架就会被使用,这样这个新实例栈里的变量就不会和该函数的另一个实例栈里面的变量混淆。

     这条短短的一句话就包含了堆与栈,关键new指示分配了一块堆内存,而指针P分配的是一块栈内存,所以这句话的意思就是:在栈内存中存放了一个指向一块堆内存的指针p。在程序会先确定在堆中分配内存的大小,然后调用operator new分配内存,然后返回这块内存的首地址,放入栈中。

1、申请方式不同:堆的话,程序员动态申请,new和malloc()申请的空间;栈,由编译器在需要的时候分配,在不需要的时候自动清除的变量的存储区。里面的变量通常是局部变量、函数参数等

2、管理方式不同。堆,程序员自己管理,若没有free、delete释放,程序结束后由OS释放。栈,系统管理。

3、空间大小不同。栈是自高址向低地址扩展的结构,是一块连续内存区域,栈顶的地址和栈的最大容量是系统预先规定好的,当申请的空间超过栈的剩余空间时,将提示溢出。因此,用户能从栈获得的空间较小,通常为1M,也有2M的,可修改。

Linux下,可用命令:ulimit -s 查看栈大小 并重新设置大小。

堆是自低地址向高地址扩展的数据结构(它的生长方向与内存的生长方向相同),是不连续的内存区域。因为系统是用链表来存储空闲内存地址的,且链表的遍历方向是由低地址向高地址。由此可见,堆获得的空间较灵活,也较大。堆的大小受限于计算机系统中有效的虚拟内存。一般来讲在32位系统下,堆内存可以达到2.9G的大小。(除去1G的内核空间,几乎占满3G的用户空间)

4、申请后系统的响应:

栈:只要栈的空间大于所申请空间,系统将为程序提供内存,否则将报异常提示栈溢出。

堆:操作系统有一个记录空闲内存地址的链表,当系统收到程序的申请时,会遍历该链表,寻找第一个空间大于所申请空间的堆结点,然后将该结点从空闲链表中删除,并将该结点的空间分配给程序,另外,对于大多数系统,会在这块内存空间中的首地址处记录本次分配的大小,这样,代码中的free语句才能正确的释放本内存空间。另外,找到的堆结点的大小不一定正好等于申请的大小,系统会自动的将多余的那部分重新放入空闲链表中。

对于堆来讲,频繁的malloc/free势必会造成内存空间的不连续,从而造成大量的碎片,使程序效率降低。对于栈就不会存在这个问题。

堆上频繁的new delete会产生内存碎片,栈上是连续的空间则不会有这个问题。

6)分配效率:栈是机器系统提供的数据结构,计算机会在底层对栈提供支持:分配专门的寄存器存放栈的地址,压栈出栈都有专门的指令执行,这就决定了栈的效率比较高。堆则是C/C++函 数库提供的,它的机制是很复杂的,例如为了分配一块内存,库函数会按照一定的算法,在堆内存中搜索可用的足够大小的空间,如果没有足够大小的空间(可能是 由于内存碎片太多),就有可能调用系统功能去增加程序数据段的内存空间,这样就有机会分到足够大小的内存,然后进行返回。显然,堆的效率比栈要低得多。

推荐一篇文章,详细介绍4G虚拟地址空间中各个段。

学习C语言,我们都听过堆(heap)和栈(stack)的概念。需要注意的是:有些地方“堆栈”这个词特指的是栈,而不是堆和栈。命名约定:本文中堆栈一次出现的地方,指的是两种东西,而非一种。

在数据结构中,我们也听过栈和堆这两种数据结构,当然和我本文要讲的东西是不同的概念。不过数据结构中的栈(算法、数学意义上的一种抽象),和本文中的栈(实际存在的存储区)有一共同之处就是FILO —— 先入后出。但是数据结构中的堆和我们本文中的堆则是毫不相干。

注意,这里指的都是虚拟内存空间,并不是实际的物理内存布局。每一个进程都有自己的虚拟内存空间,也就是说我们程序中指针所表示的地址实际是虚拟地址,而不是物理地址。

所谓地址增长方式,是指堆或栈在分配内存的时候,其分得的内存空间的地址的增长方式。堆的地址增长方式是从低到高的,而栈的地址增长方式是从高到低的。(在说这句话的时候,要明确的一点就是这是指的x86系统,并非所有架构的系统都如此)

之所以它们会呈现出相反的两种地址增长方式是有其历史原因的:

早期的机器上内存的大小十分有限,如果堆和栈使用相同的地址增长方式(比如,都是从低到高),假设,一段内存空间的起始位置(先忽略其他段)设置为堆的起始地址,那么栈的起始地址必然在这段内存空间的中间某处,这个起始点如果选择得太靠后,则可能导致栈的内存大小不够,太靠前则会导致堆在分配内存的时候,可能与栈地址冲突。这时x86系统的设计师们,巧妙地将栈的起始位置定于内存空间的另一端,而其地址增长方式也是和堆相反的从高到低,这样从一定程度上,减轻了堆栈内存地址冲突或栈空间不足的可能性。就好比两个小伙伴在同一本笔记本上记笔记,两个人分别从笔记本的首页和尾页开始写,其笔记冲突的概率大大减小。

栈与函数有莫大的关系。简单说来,我们在函数中声明的任何局部变量(非静态)都是在栈中分配的(编译期间完成)。并且函数的参数,以及返回值也是依赖于栈。

为了深入地探讨这些概念,我们需要从汇编角度来展开研究。在使用gcc编译的时候,-S选项可以生成汇编代码。但此时生成的汇编代码是AT&T风格的,我们可以用-masm=intel生成intel风格的汇编。比如:gcc -S -masm=intel hello.c 这时就会生成汇编文件hello.s。请注意我们此时探讨的真相都是不开编译器优化选项的,因为如果开了编译器优化选项,那么其汇编行为往往已经完全不是我们代码本来的执行细节了。(编译器保证的是最终一致性,为了优化可能会改变我们的程序的细节)

其通过gcc -S -masm=intel汇编之后的汇编代码主要部分如下,注意不同版本的gcc编译器,不同位数的操作系统(32位或64位)其汇编代码可能不同,但大致的意思相同。

可以忽略掉其中点号. 开头的语句。再看一遍:

因为我是64位系统,所以汇编代码中的寄存器都是r打头的(rbp,rsp)64位寄存器。x86架构(通常是32位系统)是e打头的(ebp,esp)。以下涉及到寄存器时,我通常会省略其前缀,而直接用bp和sp来称呼。bp是基址寄存器,sp是栈顶指针寄存器(sp的位置,是我们实际能使用的栈的大小)。

请自行区分操作系统位数和cpu架构位数的区别。x64(x86-64),x86是CPU架构。如果你是x64的CPU装了32位系统,那么也不会使用到x64的寄存器(比如r8d),或者不能完整使用x64CPU的寄存器,比如rax。你只能使用该寄存器的一半:eax

首先将bp入栈(push rbp),然后将当前sp位置存取bp(mov rbp, rsp)。这两步是通用操作。下面是重点。

给变量分配空间,可以看到只有两个赋值语句,说明我们的int a因为没有初始化,所以实际不会被分配空间。

请注意下面(低地址)是栈顶。可以看到c在高地址,b在低地址。这与变量的初始化顺序相反。(如果你开优化选项-O来观察的话,你会发现汇编代码里什么都没有做,这是因为声明的变量b,c虽然被初始化了,但是后续并没有被调用,所以编译器在优化的时候,就什么都不做了。所以再次提醒请不要开编译器优化选项来研究本文的内容,本文不是讨论编译器优化原理的,因为举得例子过于简单,可能就被编译器优化抹掉了)

再验证一下,变量被分配的栈位置是否和变量初始化顺序相反:

如果是数组的初始化,则每个数组元素分配的栈地址,与其初始化顺序就没有关系了。下标越大的地址越高。比如:int a[4];.....。那么a[0]是数组中栈地址最低的元素,a[3]是地址最高的元素。这里我就不一一演示了,大家可以自己写个demo,汇编一下看看。。其实很好里面,数组的地址位置与其下标的关系一定要严格遵守,这样才方便随机访问(通过下标直接计算偏移)。要注意的是,a[0]在最低的地址。

面试的时候可能出遇到以下类型的题目:

上面这段代码的输出结果是什么?结果是:3,3。出现这样的结果,一些编程书籍中可能会做出解释:因为函数参数的入栈顺序是从右到左的。这句话应该是对的,它揭露了一个事实,那就是函数参数是存入栈中的,如果参数是表达式,那么一定要先计算出表达式的值,然后再入栈。所以上面的printf中,会首先计算表达式a=a+b的值。此时a的值就变化了。这样解释应该是没有问题的,面试官也应该不会挑刺。其实真正的编译器行为没有这么简单。看下它的汇编代码:

我删除一些不必要的信息,并且增加了注释。

可以看到,上面的代码中,函数参数并未使用到栈来传递参数,而是通过寄存器来传递参数。当然这并不能说用栈来传递参数的说法是错的,因为寄存器的数量是有限的。来看一个拥有7个参数的函数:

可以看到函数从6个寄存器和1个栈地址中读取参数。曾几何时,以为面试官也曾经问我,函数的参数是通过什么传递的,我就说寄存器,寄存器不够了,就用栈。然后他接着问我几个寄存器不够了,就用栈了。。我有点哑口无言。说了个四五个。。其实这种问题是很开放的。通过上面的代码我们可以看到是6个寄存器。edi,esi,edx,ecx,r8d,r9d。然而,这只是我64位系统上的(r8d,r9d是X64上才有的),X86则不同(具体我没3试)。那么是不是说,你和面试官说,“在64位系统中,使用的是6个寄存器来传递函数参数,参数多了,就使用栈来传递”。这样回答就完美了么?其实不是的。

刚才的示例代码中都规避了一些情景。上面我使用的函数的参数类型都是整型int。而如果参数类型是浮点型,指针等等其他类型就不能使用寄存器来传递函数参数了。具体这些问题探究起来就太多了,我在这里也只是抛砖引玉。大家可以自己去试试。推荐一篇文章《X86-64寄存器和栈帧》

说个题外话,上面我的代码如果开了优化会怎么样呢?用gcc -S -masm=intel -O 来编译一下看看。此时它的汇编代码为:

哈哈。看到了吧,开了优化之后main函数里面连函数调用都没有。直接把28赋值给了寄存器edx,然后让printf来打印。28是啥?正是1 + 2 + 3 + 4 + 5 + 6 +7=28是也。所以我强调,尽量不要在开启优化选项的时候研究编译器行为,开了优化以后编译器保证的只是最终一致性,破坏力原有的程序逻辑。不具备通用性,不利于理解编程语言和编译器行为。当然大家也不要断章取义。其实我只是说在研究本文所探讨的问题的时候,不要开优化。如果正常的生产环境下,编译器优化可是很有用的。另外如果你研究的就是编译器的优化行为那么优化开关当然也要打开了。

前面已经解释过sp(栈顶指针寄存器)和bp(基址寄存器)。需要明确的是:

sp所指向的栈顶是我们所能分配的栈空间的极限,如果栈空间不够则需要移动sp指针,分配更多的栈空间。如前文所述,栈的地址增长方式是从高地址到低地址,所以sp指向的是我们能够使用的栈地址的最低出。

可以看到在main函数开始部分有三步走:

最后进行了一个sp减16的操作。(栈顶向下移动16,即多分配16字节空间)

前两步是每个函数开始的必备操作,保存原始的栈位置(bp入栈,便于函数调用结束后恢复原函数的栈位置)。第三步不是必备操作,仅当函数中局部变量过多时会进行栈指针的移动来分配更多的空间,我们可以看到add函数的开始部分是没有这个操作的。

接着看一下add函数的结束部分。两步操作:

出栈,将保存在栈里的值(原始bp)恢复给bp(pop rbp)

结束当前函数,控制权转移给调用它的函数(ret)。

这两个是函数结束时的必备操作。关于函数的返回值主要是通过eax寄存器来返回。本文聚焦堆、栈,不再过多介绍寄存器的知识。

看一个trick现象:

用gcc编译一下,看看该程序的运行结果是什么,教科书告诉我们:在declare函数中声明的局部变量a[100]在函数结束后被销毁了,在print函数中去打印a[100]数组将输出不确定的值。

yes,这样理解完全没有问题。but,这个程序确确实实可以成功打印出0到99这100个数。why?

汇编代码我们就不看了。原理很简单,那就是:decalre函数返回之后确实它的栈空间被销毁了,其实所谓的销毁只不过是sp指针回到declare函数被调用前的原位,即main函数中sp位置,实际上declare函数给栈空间中赋的值却并不会被删除。当print函数被调用的时候,sp指针又继续向下移动,这时的循环输出语句会将之前储存在栈空间中的值进行打印。

通过这个例子,我只想说明关于栈自动销毁释放的真实情况,其实只是sp指针的移动而已。然而我们并不能依赖上述这种行为,比如:我们开了优化之后gcc -O去编译一下,其输出结果却是又是未定义的了。


所谓“堆”,即动态存储区,与栈不同,堆是在程序运行时被分配的。C语言中的malloc,C++中的new完成的都是堆上的操作。堆不会自动释放所以需要free和delete。

还记得经典面试题吗:比较一下malloc和new的不同。这个问题其实不难,首先明确:malloc是函数,而new是关键字。然后new作为C++中动态对象创建的基石,除了完成堆空间的分配操作以外还要完成一些初始化操作,及new的过程中会调用对象的构造函数去初始化,而malloc不会。最后要明确的是malloc分配的内存只能用free来释放,而new分配的地址只能用delete来释放,如果new分配的是数组,则需要delete[ ]来释放,否则会出现未定义行为。

无论是malloc还是new返回的都是一个指针,即堆地址。堆与栈不同它不是顺序分配的,而是离散分配的,它的空闲内存可能不是连续的,而是断断续续的,通常通过链表来连接每个空闲存储区(实际数据结构要更复杂和多样化)。关于动态内存的分配策略其实大家的“操作系统”课上都有学过,只不过通常大家很难直接和malloc/new联系起来。

随便翻开一本操作系统的课本,找到存储器管理的章节都能找到动态内存的分配策略(简单描述之):

首次适应算法:在空闲区链首开始向后寻找,找到第一块能满足所需大小的空闲块

循环首次适应算法:从上一次找的空闲区开始向后寻找。直到链尾都不满足,则返回链首寻找空闲块

最佳适应算法:每次分配内存时,都选择能满足要求,并且又是最小的空闲块。优点是:每次第一次找到的满足要求的空闲块,必然是最佳的

最坏适应算法:每次总是选择一个最大的空闲块分配给进程使用。优点是:产生内存碎片的几率较小

快速适应算法:将空闲区依据其容量大小进行分类,每一类同容量的空闲块都有自己的链表。同时在内存中设立一张管理索引表,每个表项为一种空闲块类型,并记录其链首指针。其优点是:查找效率高,不会产生内存碎片。缺点是实现复杂,系统开销大。

实际上真实的堆内存管理要比上述各类算法所描述的还要复杂。比如内存管理器(一般是glibc的ptmalloc)可能将零散的空闲内存移动到一起,形成更大的空闲内存块。基于各自不同的动态内存分配策略,很多其他的内存管理器应运而生,比如谷歌的 tcmalloc,BSD的jemalloc。

我们通过malloc分配的内存空间返回给我们一个这段内存的首地址,你有没有疑问,当我们free的时候,只是将该首地址传递给free函数。而free函数又是如何知道该释放该首地址以后多大的内存空间的呢?在初学的时候,我有这个疑惑,我认为如果是我来实习free函数的话,它需要两个参数:带释放堆内存的首地址及其偏移量(分配空间的大小)。

实际上关于这个问题,不同的内存管理器有各自的策略,但大致的思想就是将偏移量存储在内存中。比如一种简单实现是:在堆分配内存的时候,通常会比你需要的内存多分配一些,其中一部分用于字节对齐(本文暂不讨论,本博客其他博文中有涉及4字节/8字节对齐的内容),另外一小部分用于存取偏移量。比如用前4个字节存储已分配内存的大小,然后将其后面的地址返回,首地址前的4个字节可以被称之为“首部”。这样free的时候,先搜索该首地址前的首部,取出这个值即为偏移量。(这只是一种最简单的概念性的方案,实际编译器的解决方案更为复杂,但无一例外都会分配额外的内存保存元数据)

知道这点后,我们可以理解这样一种C语法的限制,那就是free的时候,其参数只能是malloc等动态内存分配函数返回的(显然,不包括new)值。举个例子:

比如你分配了100个字节,其首地址为a,如果你将a+10(首地址之后偏移10字节处)传递给free,那么free并不能为你释放掉剩余的90个字节,并且这是一个危险的行为。因为内存管理器会将a+10位置之前视作首部,并从中去取出该段地址的“偏移量”,然后进行释放。显然这样是达不到你想要的效果的。

当然,这只是举了一种例子来阐述free函数的参数为什么只需要一个首地址,实际上其真实的行为远比我这里描述的要复杂,可能有首部,还有尾部,其存储的内容除了偏移量以外可能还有其他信息,比如空闲链表的信息等。

内存是分页的(页式存储)。物理内存、虚拟内存都是。并且这两者的页大小是严格相同的。不同之处当然是物理内存的页数比较多,虚拟内存的页数比较少。这两者之间是存在一定映射关系的。通过一道笔试题来揭露这种关系:

某虚拟存储器的用户编程空间共4个页面,每页1K,主存位16K,假定某时刻系统为用户的0,1,2,3页分别分配物理块号6,8,12,3中,则虚拟地址0C8Eh对应的物理地址为:_____

这道题虚拟地址位CC8Eh,h表示16进制。为了便于计算可以把它转换位十进制:0C8Eh = 3214。接着计算它所属的页号和页内偏移,页面大小是1K,即1024。页号= = 3。偏移=2

物理地址=物理页号*页面大小+页内偏移。因为根据题意,逻辑地址中的3号页对应物理地址中的页号也是3。所以实际这题物理地址和逻辑地址是一样的。


在进程的内存空间图示中,有一段标识是:共享库内存映射区。表示这段内存地址是给共享库使用的。何谓“共享库(shared libraries)”?共享库在Windows系统中叫做动态链接库(.dll),或者简称“动态库”。动态库就需要和静态库区分开来。你应该见过这两种库,知识你不知道罢了。

如果一个程序引用了静态库,那么在编译期静态库文件就会和该程序编译到一起。这样编译出来的可执行文件通常比较大,并且如果多个程序都使用了同一个静态库,那么每个程序编译后的文件都包含一份该静态库的拷贝;而如果一个程序引用了共享库,那么在编译期共享库文件不会和该程序编译到一起,而是在程序运行时动态地加载该共享库文件。如果多个程序都使用了同一个共享库,那么这些程序都是在运行时加载该共享库,系统中之后存在一份该库的拷贝,这就是为什么叫做共享库的原因。

因为共享库是运行时加载的,在加载后也必须有一个地址,图中的“共享内存映射区”就是用来给共享库分配地址的,它的地址增长方式同堆一样,从低到高。

关于共享库如何实现的,如何动态连接的。本文不详述,有兴趣的可以阅读《深入理解计算机系统》的“链接”一章。

另外需要注意的是,如果你要链接的共享库(动态库)不是标准库,并且不在标准库的路径下。那么在编译的时候通常会报错。此时需要给gcc加上-L选项加上共享库所在的路径,并用-l选项去连接对应的库,这里要明确的是如果你的库文件名叫libabc.so.1234那么连接选项l要写成 -labc(去掉前后缀),而当同一个库里面同时有静态库和共享库的时候,优先连接的是共享库。

此时只是解决了编译期间的麻烦,因为共享库实际是程序运行时链接的,即使你编译期间使用了-L选项也可能会找不到库(-L只解决编译期间的问题)。如果你有root的话,那么去/etc/ld.so.conf.d/目录下,新建一个conf文件,里面加入你的库路径。然后用ldconfig命令刷新共享库路径。

但如果你没有root权限的话,你可以使用gcc的-Wl,-rpath选项去指定路径(这是一个选项)。此时一个完整的编译命令类似:


 程序的执行过程可看作连续的函数调用。当一个函数执行完毕时,程序要回到调用指令的下一条指令(紧接call指令)处继续执行。函数调用过程通常使用堆栈实现,每个用户态进程对应一个调用栈结构(call stack)。编译器使用堆栈传递函数参数、保存返回地址、临时保存寄存器原有值(即函数调用的上下文)以备恢复以及存储本地局部变量。

     不同处理器和编译器的堆栈布局、函数调用方法都可能不同,但堆栈的基本概念是一样的。

程序的内存可以参考 
对于程序,编译器会对其分配一段内存,在逻辑上可以分为代码段,数据段,堆,栈 
代码段:保存程序文本,指令指针EIP就是指向代码段,可读可执行不可写 
数据段:保存初始化的全局变量和静态变量,可读可写不可执行 
BSS:未初始化的全局变量和静态变量 
堆(Heap):动态分配内存,向地址增大的方向增长,可读可写可执行 
栈(Stack):存放局部变量,函数参数,当前状态,函数调用信息等,向地址减小的方向增长,非常非常重要,可读可写可执行

这里主要关注的是栈的增长方向,是向低地址增长。

Intel 32位体系结构包含8个四字节的寄存器。

栈帧指针寄存器 
为了访问函数的局部变量,许多编译器使用帧指针寄存器FP(Frame Pointer) 记录栈帧基地址。局部变量和函数参数都可通过帧指针引用。因为他们到FP的距离不会受到压栈和出栈操作的影响。Intel 中使用EBP作为帧指针。当堆栈向下(低地址)增长时,以FP为基准,函数参数的偏移量为正值,而局部变量的偏移为负值。

函数调用经常是嵌套的,在同一时刻,堆栈中会有多个函数的信息。每个未完成运行的函数占用一个独立的连续区域,称作栈帧(Stack Frame)。栈帧是堆栈的逻辑片段,当调用函数时逻辑栈帧被压入堆栈, 当函数返回时逻辑栈帧被从堆栈中弹出。栈帧存放着函数参数,局部变量及恢复前一栈帧所需要的数据等。

使用栈帧的一个好处是使得递归变为可能,因为对函数的每次递归调用,都会分配给该函数一个新的栈帧,这样就巧妙地隔离当前调用与上次调用。

栈帧的边界由栈帧基地址指针EBP和堆栈指针ESP界定(指针存放在相应寄存器中)。EBP指向当前栈帧底部(高地址),在当前栈帧内位置固定;ESP指向当前栈帧顶部(低地址),当程序执行时ESP会随着数据的入栈和出栈而移动。因此函数中对大部分数据的访问都基于EBP进行。

为更具描述性,以下称EBP为帧基指针, ESP为栈顶指针,并在引用汇编代码时分别记为%ebp和%esp。

函数调用栈的典型布局如图:

图中给出了主调函数(caller)和被调函数(callee)的栈帧布局。有两个假设,函数的返回值不是结构体或是联合体,否则第一个参数将位于12(%ebp)处。第二,每个参数都是4字节大小。此外,图中Argument(参数)和local Variable(局部变量)不是栈帧结构的必须部分。

函数调用时的入栈顺序 
实参N~1—>主调函数返回值—>主调函数帧基指针EBP—>被调函数局部变量1~N

1、主调函数将参数按照调用规定依次入栈从右向左 
2、指令指针EIP入栈以保存函数的返回值。 
3、进入被调函数时,被调函数将主调函数的帧基指针EBP入栈(push %ebp),并将主调函数的栈顶指针ESP值赋给被调函数的EBP,作为被调函数的EBP(mov %esp,%ebp) 
4、改变ESP的值来为局部变量预留空间。

此时,被调函数帧基指针指向被调函数的栈低。以该地址为基准,向上(栈底方向,高地址方向)可获取主调函数的返回地址、参数值,向下(栈顶方向,底地址方向)能获取被调函数的局部变量值,而该地址处又存放着上一层主调函数的帧基地址。本级调用结束后,将EBP指针赋给ESP,使得ESP再次指向被调函数函数栈底以释放局部变量;再将已压栈的主调函数帧基指针弹出到EBP,并弹出到EIP。ESP继续上移越过参数,最终回到函数调用前的状态。即恢复原来主调函数的栈帧。

EBP指针在当前函数运行过程中(未调用其他函数时)保持不变。在函数调用前,ESP指针指向栈顶地址,也是栈底地址。在函数完成现场保护之类的初始化工作后,ESP会始终指向当前函数栈帧的栈顶,此时,若当前函数又调用另一个函数,则会将此时的EBP视为旧EBP压栈,而与新调用函数有关的内容会从当前ESP所指向位置开始压栈。

在多线程(任务)环境,栈顶指针指向的存储器区域就是当前使用的堆栈。切换线程的一个重要工作,就是将栈顶指针设为当前线程的堆栈栈顶地址。

 以下代码用于函数栈布局示例:


 

 
编译链接并执行后,输出打印如下:

函数栈布局示例如下图所示。为直观起见,低于起始高地址0xbfc75a58的其他地址采用点记法,如0x.54表示0xbfc75a54,以此类推。

内存地址从栈底到栈顶递减,压栈就是把ESP指针逐渐往地低址移动的过程。而结构体tStrt中的成员变量memberX地址=tStrt首地址+(memberX偏移量),即越靠近tStrt首地址的成员变量其内存地址越小。因此,结构体成员变量的入栈顺序与其在结构体中声明的顺序相反。
函数调用以值传递时,传入的实参(locMain1~3)与被调函数内操作的形参(para1~3)两者存储地址不同,因此被调函数无法直接修改主调函数实参值(对形参的操作相当于修改实参的副本)。为达到修改目的,需要向被调函数传递实参变量的指针(即变量的地址)。

【扩展阅读】函数局部变量布局方式

与函数调用约定规定参数如何传入不同,局部变量以何种方式布局并未规定。编译器计算函数局部变量所需要的空间总数,并确定这些变量存储在寄存器上还是分配在程序栈上(甚至被优化掉)——某些处理器并没有堆栈。局部变量的空间分配与主调函数和被调函数无关,仅仅从函数源代码上无法确定该函数的局部变量分布情况。

基于不同的编译器版本(gcc3.4中局部变量按照定义顺序依次入栈,gcc4及以上版本则不定)、优化级别、目标处理器架构、栈安全性等,相邻定义的两个变量在内存位置上可能相邻,也可能不相邻,前后关系也不固定。若要确保两个对象在内存上相邻且前后关系固定,可使用结构体或数组定义。

 
 
 函数调用时的具体步骤如下:
 1) 主调函数将被调函数所要求的参数,根据相应的函数调用约定,保存在运行时栈中。该操作会改变程序的栈指针。
 注:x86平台将参数压入调用栈中。而x86_64平台具有16个通用64位寄存器,故调用函数时前6个参数通常由寄存器传递,其余参数才通过栈传递。
 2) 主调函数将控制权移交给被调函数(使用call指令)。函数的返回地址(待执行的下条指令地址)保存在程序栈中(压栈操作隐含在call指令中)。
 3) 若有必要,被调函数会设置帧基指针,并保存被调函数希望保持不变的寄存器值。
 4) 被调函数通过修改栈顶指针的值,为自己的局部变量在运行时栈中分配内存空间,并从帧基指针的位置处向低地址方向存放被调函数的局部变量和临时变量。
 5) 被调函数执行自己任务,此时可能需要访问由主调函数传入的参数。若被调函数返回一个值,该值通常保存在一个指定寄存器中(如EAX)。
 6) 一旦被调函数完成操作,为该函数局部变量分配的栈空间将被释放。这通常是步骤4的逆向执行。
 7) 恢复步骤3中保存的寄存器值,包含主调函数的帧基指针寄存器。
 8) 被调函数将控制权交还主调函数(使用ret指令)。根据使用的函数调用约定,该操作也可能从程序栈上清除先前传入的参数。
 9) 主调函数再次获得控制权后,可能需要将先前的参数从栈上清除。在这种情况下,对栈的修改需要将帧基指针值恢复到步骤1之前的值。
 步骤3与步骤4在函数调用之初常一同出现,统称为函数序(prologue);步骤6到步骤8在函数调用的最后常一同出现,统称为函数跋(epilogue)。函数序和函数跋是编译器自动添加的开始和结束汇编代码,其实现与CPU架构和编译器相关。除步骤5代表函数实体外,其它所有操作组成函数调用。
 

 
以下介绍函数调用过程中的主要指令。
 压栈(push):栈顶指针ESP减小4个字节;以字节为单位将寄存器数据(四字节,不足补零)压入堆栈,从高到低按字节依次将数据存入ESP-1、ESP-2、ESP-3、ESP-4指向的地址单元。
 出栈(pop):栈顶指针ESP指向的栈中数据被取回到寄存器;栈顶指针ESP增加4个字节。
 

 

可见,压栈操作将寄存器内容存入栈内存中(寄存器原内容不变),栈顶地址减小;出栈操作从栈内存中取回寄存器内容(栈内已存数据不会自动清零),栈顶地址增大。栈顶指针ESP总是指向栈中下一个可用数据。
 调用(call):将当前的指令指针EIP(该指针指向紧接在call指令后的下条指令)压入堆栈,以备返回时能恢复执行下条指令;然后设置EIP指向被调函数代码开始处,以跳转到被调函数的入口地址执行。
 离开(leave): 恢复主调函数的栈帧以准备返回。等价于指令序列movl %ebp, %esp(恢复原ESP值,指向被调函数栈帧开始处)和popl %ebp(恢复原ebp的值,即主调函数帧基指针)。
 返回(ret):与call指令配合,用于从函数或过程返回。从栈顶弹出返回地址(之前call指令保存的下条指令地址)到EIP寄存器中,程序转到该地址处继续执行(此时ESP指向进入函数时的第一个参数)。若带立即数,ESP再加立即数(丢弃一些在执行call前入栈的参数)。使用该指令前,应使当前栈顶指针所指向位置的内容正好是先前call指令保存的返回地址。
 

 


这里总结下
1、函数栈是从高地址向低地址增长的,这也说明了栈内存大小是有限制的。
2、ESP:栈指针寄存器(extended stack pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的栈顶
EBP:基址指针寄存器(extended base pointer),其内存放着一个指针,该指针永远指向系统栈最上面一个栈帧的底部
EIP:指令寄存器(extended instruction pointer), 其内存放着一个指针,该指针永远指向下一条待执行的指令地址。
3、函数的形参是从右向左入栈的,就是最右边的参数地址最高,最左边的参数地址最低,这是为了便于ebp寻址

我要回帖

更多关于 栈在计算机的实现方式 的文章