关于"堆栈"和"堆"的那点小事

来源:互联网 发布:手机电池修复软件 编辑:程序博客网 时间:2024/05/04 19:34

  本人没事喜欢扯淡,大笑,和同学一扯到程序,就难免会涉及“堆栈”和“堆”,但很长时间了,对它的认识还只停留在只知道他是干什么的,不知道它到底是个什么玩意。

  今天有时间,就去网上搜罗些资料,将【关于"堆栈"和"堆"的那点小事】记录在此,供以后思考用之。


  在深入讨论之前,我们要先了解以下基本知识:

  1、这里讨论的硬件平台是:Intel x86

  2、堆栈和堆的区别:

  (1)具体实现方式不同:程序中使用的“堆栈”是由底层硬件直接支持的数据结构(具体实现:使用堆栈寄存器,相当于一个栈顶指针),入栈、出栈都有专门的指令,这就决定了堆栈的操作效率高; 而“堆”则是C库函数提供的一种数据机构,它的实现机制相当复杂,例如:为了分配一块内存,库函数会按照一定的算法在堆内存空间中搜索一块足够大小的空间,如果找不到(例如可能由于内存碎片太多),还要进行系统调用去增加堆的空间,因此来讲,堆比堆栈的处理效率低很多。 

            堆栈的生长是向下生长:就是说,后入栈的数据的地址比先入栈的数据的地址小。

            在32位系统中,堆栈每个数据单元的大小为4字节。<=4字节的数据,比如字节、字、双字和布尔型,在堆栈中都是占4个字节的;大于4字节的数据在堆栈中占4字节整数倍的空间。

  (2)管理方式不同:栈是由编译器自动管理的,不需要我们程序员亲自操作;而对于堆来说,空间的分配和释放是由我们控制的,且很容易产生内存泄露(memory leak:由于在堆中申请了内存,但没释放掉,从而导致堆空间容量减少)

  (3)碎片问题:对于堆来讲,频繁的new和delete势必导致内存空间的不连续,从而造成大量的碎片,导致内存查找的开销增加。

  (4)空间大小:理论上讲,一般的32位系统,堆空间的大小可以达到4G;但是对于堆栈来说,他的空间大小一般是一定的(VC6下默认是1M,当然这是可以修改的),但一般不会产生堆栈溢出,因为栈结构先进先出的规定,当出栈时,当前函数使用的全部空间将会清空。

  3、堆栈帧的概念:堆栈中存储了,函数的参数、函数的局部变量、寄存器的值(用以恢复寄存器)、函数的返回地址以及用于结构化异常处理的数据,这些所有的数据是按照一定的顺序组织在一起的,我们称之为一个堆栈帧。

        一个堆栈帧,对应一次函数调用,在函数开始时,对应的堆栈帧已经完整地建立了(所有的局部变量在函数帧建立时就已经分配好空间了,而不是随着函数的执行而不断创建和销毁的);在函数退出时,整个函数帧将被销毁。



document_thumb_thumb4开始讨论堆栈是如何工作的 

    我们来讨论堆栈的工作机制。堆栈是用来支持函数的调用和执行的,因此,我们下面将通过一组函数调用的例子来讲解,看下面的代码:

view source
print?
01int foo1(int m, int n)
02{
03    int p=m*n;
04    return p;
05}
06int foo(int a, int b)
07{
08    int c=a+1;
09    int d=b+1;
10    int e=foo1(c,d);
11    return e;
12}
13  
14int main()
15{
16    int result=foo(3,4);
17    return 0;
18}

    这段代码本身并没有实际的意义,我们只是用它来跟踪堆栈。下面的章节我们来跟踪堆栈的建立,堆栈的使用和堆栈的销毁。

 document_thumb_thumb4堆栈的建立

    我们从main函数执行的第一行代码,即int result=foo(3,4); 开始跟踪。这时main以及之前的函数对应的堆栈帧已经存在在堆栈中了,如下图所示:

image

图1

    参数入栈 

   当foo函数被调用,首先,caller(此时caller为main函数)把foo函数的两个参数:a=3,b=4压入堆栈。参数入栈的顺序是由函数的调用约定(Calling Convention)决定的,我们将在后面一个专门的章节来讲解调用约定。一般来说,参数都是从左往右入栈的,因此,b=4先压入堆栈,a=3后压入,如图:

image

图2

   返回地址入栈

    我们知道,当函数结束时,代码要返回到上一层函数继续执行,那么,函数如何知道该返回到哪个函数的什么位置执行呢?函数被调用时,会自动把下一条指令的地址压入堆栈,函数结束时,从堆栈读取这个地址,就可以跳转到该指令执行了。如果当前"call foo"指令的地址是0x00171482,由于call指令占5个字节,那么下一个指令的地址为0x00171487,0x00171487将被压入堆栈:

image

图3

    代码跳转到被调用函数执行

    返回地址入栈后,代码跳转到被调用函数foo中执行。到目前为止,堆栈帧的前一部分,是由caller构建的;而在此之后,堆栈帧的其他部分是由callee来构建。

   EBP指针入栈

    在foo函数中,首先将EBP寄存器的值压入堆栈。因为此时EBP寄存器的值还是用于main函数的,用来访问main函数的参数和局部变量的,因此需要将它暂存在堆栈中,在foo函数退出时恢复。同时,给EBP赋于新值。

    1)将EBP压入堆栈

    2)把ESP的值赋给EBP

image

图4

    这样一来,我们很容易发现当前EBP寄存器指向的堆栈地址就是EBP先前值的地址,你还会发现发现,EBP+4的地址就是函数返回值的地址,EBP+8就是函数的第一个参数的地址(第一个参数地址并不一定是EBP+8,后文中将讲到)。因此,通过EBP很容易查找函数是被谁调用的或者访问函数的参数(或局部变量)。

    为局部变量分配地址

    接着,foo函数将为局部变量分配地址。程序并不是将局部变量一个个压入堆栈的,而是将ESP减去某个值,直接为所有的局部变量分配空间,比如在foo函数中有ESP=ESP-0x00E4,如图所示:

image

图5

     奇怪的是,在debug模式下,编译器为局部变量分配的空间远远大于实际所需,而且局部变量之间的地址不是连续的(据我观察,总是间隔8个字节)如下图所示:

 image

图6

    我还不知道编译器为什么这么设计,或许是为了在堆栈中插入调试数据,不过这无碍我们今天的讨论。

通用寄存器入栈

     最后,将函数中使用到的通用寄存器入栈,暂存起来,以便函数结束时恢复。在foo函数中用到的通用寄存器是EBX,ESI,EDI,将它们压入堆栈,如图所示:

image

图7

   至此,一个完整的堆栈帧建立起来了。

 

 

document_thumb_thumb4堆栈特性分析

 

   上一节中,一个完整的堆栈帧已经建立起来,现在函数可以开始正式执行代码了。本节我们对堆栈的特性进行分析,有助于了解函数与堆栈帧的依赖关系。

   1)一个完整的堆栈帧建立起来后,在函数执行的整个生命周期中,它的结构和大小都是保持不变的;不论函数在什么时候被谁调用,它对应的堆栈帧的结构也是一定的。

   2)在A函数中调用B函数,对应的,是在A函数对应的堆栈帧“下方”建立B函数的堆栈帧。例如在foo函数中调用foo1函数,foo1函数的堆栈帧将在foo函数的堆栈帧下方建立。如下图所示:

image图8 

  3)函数用EBP寄存器来访问参数和局部变量。我们知道,参数的地址总是比EBP的值高,而局部变量的地址总是比EBP的值低。而在特定的堆栈帧中,每个参数或局部变量相对于EBP的地址偏移总是固定的。因此函数对参数和局部变量的的访问是通过EBP加上某个偏移量来访问的。比如,在foo函数中,EBP+8为第一个参数的地址,EBP-8为第一个局部变量的地址。

   4)如果仔细思考,我们很容易发现EBP寄存器还有一个非常重要的特性,请看下图中:

image

图9

   我们发现,EBP寄存器总是指向先前的EBP,而先前的EBP又指向先前的先前的EBP,这样就在堆栈中形成了一个链表!这个特性有什么用呢,我们知道EBP+4地址存储了函数的返回地址,通过该地址我们可以知道当前函数的上一级函数(通过在符号文件中查找距该函数返回地址最近的函数地址,该函数即当前函数的上一级函数),以此类推,我们就可以知道当前线程整个的函数调用顺序。事实上,调试器正是这么做的,这也就是为什么调试时我们查看函数调用顺序时总是说“查看堆栈”了。

document_thumb_thumb4返回值是如何传递的

    堆栈帧建立起后,函数的代码真正地开始执行,它会操作堆栈中的参数,操作堆栈中的局部变量,甚至在堆(Heap)上创建对象,balabala….,终于函数完成了它的工作,有些函数需要将结果返回给它的上一层函数,这是怎么做的呢?

    首先,caller和callee在这个问题上要有一个“约定”,由于caller是不知道callee内部是如何执行的,因此caller需要从callee的函数声明就可以知道应该从什么地方取得返回值。同样的,callee不能随便把返回值放在某个寄存器或者内存中而指望Caller能够正确地获得的,它应该根据函数的声明,按照“约定”把返回值放在正确的”地方“。下面我们来讲解这个“约定”:  
    1)首先,如果返回值等于4字节,函数将把返回值赋予EAX寄存器,通过EAX寄存器返回。例如返回值是字节、字、双字、布尔型、指针等类型,都通过EAX寄存器返回。

    2)如果返回值等于8字节,函数将把返回值赋予EAX和EDX寄存器,通过EAX和EDX寄存器返回,EDX存储高位4字节,EAX存储低位4字节。例如返回值类型为__int64或者8字节的结构体通过EAX和EDX返回。

    3)  如果返回值为double或float型,函数将把返回值赋予浮点寄存器,通过浮点寄存器返回。

    4)如果返回值是一个大于8字节的数据,将如何传递返回值呢?这是一个比较麻烦的问题,我们将详细讲解:

        我们修改foo函数的定义如下并将它的代码做适当的修改:

view source
print?
1MyStruct foo(int a, int b)
2{
3...
4}

 

         MyStruct定义为:

view source
print?
1struct MyStruct
2{
3    int value1;
4    __int64 value2;
5    bool value3;
6};

 

     这时,在调用foo函数时参数的入栈过程会有所不同,如下图所示:

image

图10

    caller会在压入最左边的参数后,再压入一个指针,我们姑且叫它ReturnValuePointer,ReturnValuePointer指向当前ESP值下方很远的一个地址,这个地址将用来存储函数的返回值。函数返回时,把返回值拷贝到ReturnValuePointer指向的地址中,然后把ReturnValuePointer的地址赋予EAX寄存器。函数返回后,caller通过EAX寄存器找到ReturnValuePointer,然后通过ReturnValuePointer找到返回值。

    你或许会有这样的疑问,函数返回后,对应的堆栈帧已经被销毁,而ReturnValuePointer是在该堆栈帧中,不也应该被销毁了吗?对的,堆栈帧是被销毁了,但是程序不会自动清理其中的值,因此ReturnValuePointer中的值还是有效的。

    但是,这里还有一个问题我没有答案。ReturnValuePointer指向的地址是由caller决定的,而才caller并不知道callee对应的堆栈帧会有多大,如果callee对应的堆栈帧很大那么就可能会和返回值的地址重合。我还不知道VS编译器通过什么策略来避免这个问题。

document_thumb_thumb4堆栈帧的销毁

    当函数将返回值赋予某些寄存器或者拷贝到堆栈的某个地方后,函数开始清理堆栈帧,准备退出。堆栈帧的清理顺序和堆栈建立的顺序刚好相反:(堆栈帧的销毁过程就不一一画图说明了)

   1)如果有对象存储在堆栈帧中,对象的析构函数会被函数调用。

    2)从堆栈中弹出先前的通用寄存器的值,恢复通用寄存器。

    3)ESP加上某个值,回收局部变量的地址空间(加上的值和堆栈帧建立时分配给局部变量的地址大小相同)。

    4)从堆栈中弹出先前的EBP寄存器的值,恢复EBP寄存器。

    5)从堆栈中弹出函数的返回地址,准备跳转到函数的返回地址处继续执行。

    6)ESP加上某个值,回收所有的参数地址。

    前面1-5条都是由callee完成的。而第6条,参数地址的回收,是由caller或者callee完成是由函数使用的调用约定(calling convention )来决定的。下面的小节我们就来讲解函数的调用约定。

document_thumb_thumb4函数的调用约定(calling convention)

    函数的调用约定(calling convention)指的是进入函数时,函数的参数是以什么顺序压入堆栈的,函数退出时,又是由谁(Caller还是Callee)来清理堆栈中的参数。有2个办法可以指定函数使用的调用约定:

    1)在函数定义时加上修饰符来指定,如

view source
print?
1void __thiscall mymethod();
2{
3    ...
4}

 

    2)在VS工程设置中为工程中定义的所有的函数指定默认的调用约定:在工程的主菜单打开Project|Project Property|Configuration Properties|C/C++|Advanced|Calling Convention,选择调用约定(注意:这种做法对类成员函数无效)。

    常用的调用约定有以下3种:

    1)__cdecl。这是VC编译器默认的调用约定。其规则是:参数从右向左压入堆栈,函数退出时由caller清理堆栈中的参数。这种调用约定的特点是支持可变数量的参数,比如printf方法。由于callee不知道caller到底将多少参数压入堆栈,因此callee就没有办法自己清理堆栈,所以只有函数退出之后,由caller清理堆栈,因为caller总是知道自己传入了多少参数。

    2)__stdcall。所有的Windows API都使用__stdcall。其规则是:参数从右向左压入堆栈,函数退出时由callee自己清理堆栈中的参数。由于参数是由callee自己清理的,所以__stdcall不支持可变数量的参数。

    3) __thiscall。类成员函数默认使用的调用约定。其规则是:参数从右向左压入堆栈,x86构架下this指针通过ECX寄存器传递,函数退出时由callee清理堆栈中的参数,x86构架下this指针通过ECX寄存器传递。同样不支持可变数量的参数。如果显式地把类成员函数声明为使用__cdecl或者__stdcall,那么,将采用__cdecl或者__stdcall的规则来压栈和出栈,而this指针将作为函数的第一个参数最后压入堆栈,而不是使用ECX寄存器来传递了。

document_thumb_thumb4反编译代码的跟踪(不熟悉汇编可跳过)

    以下代码为和foo函数对应的堆栈帧建立相关的代码的反编译代码,我将逐行给出注释,可对照前文中对堆栈的描述:

    main函数中 int result=foo(3,4); 的反汇编:

view source
print?
1008A147E  push        4                     //b=4 压入堆栈   
2008A1480  push        3                     //a=3 压入堆栈,到达图2的状态
3008A1482  call        foo (8A10F5h)         //函数返回值入栈,转入foo中执行,到达图3的状态 
4008A1487  add         esp,8                 //foo返回,由于采用__cdecl,由Caller清理参数
5008A148A  mov         dword ptr [result],eax //返回值保存在EAX中,把EAX赋予result变量

 

    下面是foo函数代码正式执行前和执行后的反汇编代码

 

view source
print?
01008A13F0  push        ebp                  //把ebp压入堆栈 
02008A13F1  mov         ebp,esp              //ebp指向先前的ebp,到达图4的状态
03008A13F3  sub         esp,0E4h             //为局部变量分配0E4字节的空间,到达图5的状态
04008A13F9  push        ebx                  //压入EBX
05008A13FA  push        esi                  //压入ESI
06008A13FB  push        edi                  //压入EDI,到达图7的状态
07008A13FC  lea         edi,[ebp-0E4h]       //以下4行把局部变量区初始化为每个字节都等于cch
08008A1402  mov         ecx,39h 
09008A1407  mov         eax,0CCCCCCCCh 
10008A140C  rep stos    dword ptr es:[edi] 
11......                                      //省略代码执行N行
12......
13008A1436  pop         edi                   //恢复EDI  
14008A1437  pop         esi                   //恢复ESI
15008A1438  pop         ebx                   //恢复EBX
16008A1439  add         esp,0E4h              //回收局部变量地址空间
17008A143F  cmp         ebp,esp               //以下3行为Runtime Checking,检查ESP和EBP是否一致   
18008A1441  call        @ILT+330(__RTC_CheckEsp) (8A114Fh) 
19008A1446  mov         esp,ebp 
20008A1448  pop         ebp                   //恢复EBP 
21008A1449  ret                               //弹出函数返回地址,跳转到函数返回地址执行                                            //(__cdecl调用约定,Callee未清理参数)

document_thumb_thumb4[1]参考

Debug Tutorial Part 2: The Stack

Intel汇编语言程序设计(第四版) 第8章

http://msdn.microsoft.com/zh-cn/library/46t77ak2(VS.80).aspx

document_thumb_thumb4[1]声明

本文为Binhua Liu原创作品。本文允许复制,修改,传递,但不允许用于商业用途。转载请注明出处。本文发表于2010年8月24日。


原创粉丝点击