C/C++堆栈模型 转载两篇经典

来源:互联网 发布:剑三男神脸数据 花哥 编辑:程序博客网 时间:2024/06/18 18:29

C/C++堆栈指引

Binhua Liu

document_thumb_thumb前言 via:http://blog.csdn.net/mynote/article/details/5835615

    我们经常会讨论这样的问题:什么时候数据存储在飞鸽传书堆栈(Stack)中,什么时候数据存储在堆(Heap)中。我们知道,局部变量是存储在堆栈中的;debug时,查看堆栈可以知道函数的调用顺序;函数调用时传递参数,事实上是把参数压入堆栈,听起来,堆栈象一个大杂烩。那么,堆栈(Stack)到底是如何工作的呢? 本文将详解C/C++堆栈的工作机制。阅读时请注意以下几点:

    1)本文讨论的语言是 Visual C/C++,由于高级语言的堆栈工作机制大致相同,因此对其他高级语言如C#也有意义。

    2)本文讨论的堆栈,是指程序为每个线程分配的默认堆栈,用以支持程序的运行,而不是指程序员为了实现算法而自己定义的堆栈。

    3)  本文讨论的平台为intel x86。

    4)本文的主要部分将尽量避免涉及到汇编的知识,在本文最后可选章节,给出前面章节的反编译代码和注释。

    5)结构化异常处理也是通过堆栈来实现的(当你使用try…catch语句时,使用的就是c++对windows结构化异常处理的扩展),但是关于结构化异常处理的主题太复杂了,本文将不会涉及到。

document_thumb_thumb[4]从一些基本的知识和概念开始

    1) 程序的堆栈是由处理器直接支持的。在intel x86的系统中,堆栈在内存中是从高地址向低地址扩展(这和自定义的堆栈从低地址向高地址扩展不同),如下图所示:

image

    因此,栈顶地址是不断减小的,越后入栈的数据,所处的地址也就越低。

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

    3) 和堆栈的操作相关的两个寄存器是EBP寄存器和ESP寄存器的,本文中,你只需要把EBP和ESP理解成2个指针就可以了。ESP寄存器总是指向堆栈的栈顶,执行PUSH命令向堆栈压入数据时,ESP减4,然后把数据拷贝到ESP指向的地址;执行POP命令时,首先把ESP指向的数据拷贝到内存地址/寄存器中,然后ESP加4。EBP寄存器是用于访问堆栈中的数据的,它指向堆栈中间的某个位置(具体位置后文会具体讲解),函数的参数地址比EBP的值高,而函数的局部变量地址比EBP的值低,因此参数或局部变量总是通过EBP加减一定的偏移地址来访问的,比如,要访问函数的第一个参数为EBP+8。

    4) 堆栈中到底存储了什么数据? 包括了:函数的参数,函数的局部变量,寄存器的值(用以恢复寄存器),函数的返回地址以及用于结构化异常处理的数据(当函数中有try…catch语句时才有,本文不讨论)。这些数据是按照一定的顺序组织在一起的,我们称之为一个堆栈帧(Stack Frame)。一个堆栈帧对应一次函数的调用。在函数开始时,对应的堆栈帧已经完整地建立了(所有的局部变量在函数帧建立时就已经分配好空间了,而不是随着函数的执行而不断创建和销毁的);在函数退出时,整个函数帧将被销毁。

    5) 在文中,我们把函数的调用者称为Caller(调用者),被调用的函数称为Callee(被调用者)。之所以引入这个概念,是因为一个函数帧的建立和清理,有些工作是由Caller完成的,有些则是由Callee完成的。

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

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

view source
print?
01intfoo1(intm,intn)
02{
03    intp=m*n;
04    returnp;
05}
06intfoo(inta,intb)
07{
08    intc=a+1;
09    intd=b+1;
10    inte=foo1(c,d);
11    returne;
12}
13  
14intmain()
15{
16    intresult=foo(3,4);
17    return0;
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(inta,intb)
2{
3...
4}

 

         MyStruct定义为:

view source
print?
1structMyStruct
2{
3    intvalue1;
4    __int64value2;
5    boolvalue3;
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日。



_____________________________________________________________________________________________________________________________________

另一篇写的也很棒的文章,转载 出处不详

一、预备知识—程序的内存分配

一个由c/C++编译的程序占用的内存分为以下几个部分
1、栈区(stack)— 由编译器自动分配释放 ,存放函数的参数值,局部变量的值等。其操作方式类似于数据结构中的栈。
2、堆区(heap) — 一般由程序员分配释放, 若程序员不释放,程序结束时可能由OS回收 。注意它与数据结构中的堆是两回事,分配方式倒是类似于链表,呵呵。
3、全局区(静态区)(static)—,全局变量和静态变量的存储是放在一块的,初始化的全局变量和静态变量在一块区域, 未初始化的全局变量和未初始化的静态变量在相邻的另一块区域。 - 程序结束后有系统释放
4、文字常量区—常量字符串就是放在这里的。 程序结束后由系统释放
5、程序代码区—存放函数体的二进制代码。

二、例子程序

这是一个前辈写的,非常详细

//main.cppint a = 0; //全局初始化区int a = 0; //全局初始化区char *p1; //全局未初始化区main() {    int b; //栈    char s[] = "abc"; //栈    char *p2; //栈    char *p3 = "123456"; //123456\0在常量区,p3在栈上。    static int c = 0; //全局(静态)初始化区    p1 = (char *)malloc(10);    p2 = (char *)malloc(20);    //分配得来得10和20字节的区域就在堆区。    strcpy(p1, "123456"); //123456\0放在常量区,编译器可能会将它与p3所指向的"123456"优化成一个地方。}

二、堆和栈的理论知识

2.1申请方式

stack:
由系统自动分配。 例如,声明在函数中一个局部变量 int b; 系统自动在栈中为b开辟空间
heap:
需要程序员自己申请,并指明大小,在c中malloc函数
p1 = (char *)malloc(10);
在C++中用new运算符
p2 = (char *)malloc(10);
但是注意p1、p2本身是在栈中的。

2.2 申请后系统的响应

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

2.3 申请大小的限制

栈:在Windows下,栈是向低地址扩展的数据结构,是一块连续的内存的区域。这句话的意思是栈顶的地址和栈的最大容量是系统预先规定好的,在WINDOWS下,栈的大小是2M(也有的说是1M,总之是一个编译时就确定的常数),如果申请的空间超过栈的剩余空间时,将提示overflow。因此,能从栈获得的空间较小。
堆:堆是向高地址扩展的数据结构,是不连续的内存区域。这是由于系统是用链表来存储的空闲内存地址的,自然是不连续的,而链表的遍历方向是由低地址向高地址。堆的大小受限于计算机系统中有效的虚拟内存。由此可见,堆获得的空间比较灵活,也比较大。

2.4 申请效率的比较:

栈由系统自动分配,速度较快。但程序员是无法控制的。
堆是由new分配的内存,一般速度比较慢,而且容易产生内存碎片,不过用起来最方便.
另外,在WINDOWS下,最好的方式是用VirtualAlloc分配内存,他不是在堆,也不是在栈是直接在进程的地址空间中保留一快内存,虽然用起来最不方便。但是速度快,也最灵活。

2.5 堆和栈中的存储内容

栈: 在函数调用时,第一个进栈的是主函数中后的下一条指令(函数调用语句的下一条可执行语句)的地址,然后是函数的各个参数,在大多数的C编译器中,参数是由右往左入栈的,然后是函数中的局部变量。注意静态变量是不入栈的。
当本次函数调用结束后,局部变量先出栈,然后是参数,最后栈顶指针指向最开始存的地址,也就是主函数中的下一条指令,程序由该点继续运行。
堆:一般是在堆的头部用一个字节存放堆的大小。堆中的具体内容有程序员安排。

2.6 存取效率的比较

char s1[] = "aaaaaaaaaaaaaaa";
char *s2 = "bbbbbbbbbbbbbbbbb";
aaaaaaaaaaa是在运行时刻赋值的;
而bbbbbbbbbbb是在编译时就确定的;
但是,在以后的存取中,在栈上的数组比指针所指向的字符串(例如堆)快。
比如:

#includevoid main() {    char a = 1;    char c[] = "1234567890";    char *p ="1234567890";    a = c[1];    a = p[1];    return;}
  • 1
  • 2
  • 3
  • 4
  • 5

对应的汇编代码

10: a = c[1];00401067 8A 4D F1 mov cl,byte ptr [ebp-0Fh]0040106A 88 4D FC mov byte ptr [ebp-4],cl11: a = p[1];0040106D 8B 55 EC mov edx,dword ptr [ebp-14h]00401070 8A 42 01 mov al,byte ptr [edx+1]00401073 88 45 FC mov byte ptr [ebp-4],al
  • 1
  • 2
  • 3
  • 4

第一种在读取时直接就把字符串中的元素读到寄存器cl中,而第二种则要先把指针值读到edx中,在根据edx读取字符,显然慢了。

2.7小结:

堆和栈的区别可以用如下的比喻来看出:
使用栈就象我们去饭馆里吃饭,只管点菜(发出申请)、付钱、和吃(使用),吃饱了就走,不必理会切菜、洗菜等准备工作和洗碗、刷锅等扫尾工作,他的好处是快捷,但是自由度小。
使用堆就象是自己动手做喜欢吃的菜肴,比较麻烦,但是比较符合自己的口味,而且自由度大。

三 、windows进程中的内存结构

在阅读本文之前,如果你连堆栈是什么多不知道的话,请先阅读文章后面的基础知识。

接触过编程的人都知道,高级语言都能通过变量名来访问内存中的数据。那么这些变量在内存中是如何存放的呢?程序又是如何使用这些变量的呢?下面就会对此进行深入的讨论。下文中的C语言代码如没有特别声明,默认都使用VC编译的release版。

首先,来了解一下 C 语言的变量是如何在内存分部的。C 语言有全局变量(Global)、本地变量(Local),静态变量(Static)、寄存器变量(Regeister)。每种变量都有不同的分配方式。先来看下面这段代码:

#include <stdio.h>int g1=0, g2=0, g3=0;int main(){    static int s1=0, s2=0, s3=0;    int v1=0, v2=0, v3=0;    //打印出各个变量的内存地址        printf("0x%08x\n",&v1); //打印各本地变量的内存地址    printf("0x%08x\n",&v2);    printf("0x%08x\n\n",&v3);    printf("0x%08x\n",&g1); //打印各全局变量的内存地址    printf("0x%08x\n",&g2);    printf("0x%08x\n\n",&g3);    printf("0x%08x\n",&s1); //打印各静态变量的内存地址    printf("0x%08x\n",&s2);    printf("0x%08x\n\n",&s3);    return 0;}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18

编译后的执行结果是:

0x0012ff780x0012ff7c0x0012ff800x004068d00x004068d40x004068d80x004068dc0x004068e00x004068e4
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11

输出的结果就是变量的内存地址。其中v1,v2,v3是本地变量,g1,g2,g3是全局变量,s1,s2,s3是静态变量。你可以看到这些变量在内存是连续分布的,但是本地变量和全局变量分配的内存地址差了十万八千里,而全局变量和静态变量分配的内存是连续的。这是因为本地变量和全局/静态变量是分配在不同类型的内存区域中的结果。对于一个进程的内存空间而言,可以在逻辑上分成3个部份:代码区,静态数据区和动态数据区。动态数据区一般就是“堆栈”。“栈(stack)”和“堆(heap)”是两种不同的动态数据区,栈是一种线性结构,堆是一种链式结构。进程的每个线程都有私有的“栈”,所以每个线程虽然代码一样,但本地变量的数据都是互不干扰。一个堆栈可以通过“基地址”和“栈顶”地址来描述。全局变量和静态变量分配在静态数据区,本地变量分配在动态数据区,即堆栈中。程序通过堆栈的基地址和偏移量来访问本地变量。

├———————┤低端内存区域│ …… │├———————┤│ 动态数据区 │├———————┤│ …… │├———————┤│ 代码区 │├———————┤│ 静态数据区 │├———————┤│ …… │├———————┤高端内存区域
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

堆栈是一个先进后出的数据结构,栈顶地址总是小于等于栈的基地址。我们可以先了解一下函数调用的过程,以便对堆栈在程序中的作用有更深入的了解。不同的语言有不同的函数调用规定,这些因素有参数的压入规则和堆栈的平衡。windows API的调用规则和ANSI C的函数调用规则是不一样的,前者由被调函数调整堆栈,后者由调用者调整堆栈。两者通过“__stdcall”和“__cdecl”前缀区分。先看下面这段代码:

#include <stdio.h>void __stdcall func(int param1,int param2,int param3){    int var1=param1;    int var2=param2;    int var3=param3;    printf("0x%08x\n",param1); //打印出各个变量的内存地址    printf("0x%08x\n",param2);    printf("0x%08x\n\n",param3);    printf("0x%08x\n",&var1);    printf("0x%08x\n",&var2);    printf("0x%08x\n\n",&var3);    return;}int main() {    func(1,2,3);    return 0;}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 12
  • 13

编译后的执行结果是:

0x0012ff780x0012ff7c0x0012ff800x0012ff680x0012ff6c0x0012ff70
  • 1
  • 2
  • 3
  • 4
├———————┤<—函数执行时的栈顶(ESP)、低端内存区域│ …… │├———————┤│ var 1 │├———————┤│ var 2 │├———————┤│ var 3 │├———————┤│ RET │├———————┤<—“__cdecl”函数返回后的栈顶(ESP)│ parameter 1 │├———————┤│ parameter 2 │├———————┤│ parameter 3 │├———————┤<—“__stdcall”函数返回后的栈顶(ESP)│ …… │├———————┤<—栈底(基地址 EBP)、高端内存区域

上图就是函数调用过程中堆栈的样子了。首先,三个参数以从右到左的次序压入堆栈,先压“param3”,再压“param2”,最后压入“param1”;然后压入函数的返回地址(RET),接着跳转到函数地址接着执行(这里要补充一点,介绍UNIX下的缓冲溢出原理的文章中都提到在压入RET后,继续压入当前EBP,然后用当前ESP代替EBP。然而,有一篇介绍windows下函数调用的文章中说,在windows下的函数调用也有这一步骤,但根据我的实际调试,并未发现这一步,这还可以从param3和var1之间只有4字节的间隙这点看出来);第三步,将栈顶(ESP)减去一个数,为本地变量分配内存空间,上例中是减去12字节(ESP=ESP-3*4,每个int变量占用4个字节);接着就初始化本地变量的内存空间。由于“__stdcall”调用由被调函数调整堆栈,所以在函数返回前要恢复堆栈,先回收本地变量占用的内存(ESP=ESP+3*4),然后取出返回地址,填入EIP寄存器,回收先前压入参数占用的内存(ESP=ESP+3*4),继续执行调用者的代码。参见下列汇编代码:

;--------------func 函数的汇编代码-------------------:00401000 83EC0C sub esp, 0000000C //创建本地变量的内存空间:00401003 8B442410 mov eax, dword ptr [esp+10]:00401007 8B4C2414 mov ecx, dword ptr [esp+14]:0040100B 8B542418 mov edx, dword ptr [esp+18]:0040100F 89442400 mov dword ptr [esp], eax:00401013 8D442410 lea eax, dword ptr [esp+10]:00401017 894C2404 mov dword ptr [esp+04], ecx……………………(省略若干代码):00401075 83C43C add esp, 0000003C ;恢复堆栈,回收本地变量的内存空间:00401078 C3 ret 000C ;函数返回,恢复参数占用的内存空间;如果是“__cdecl”的话,这里是“ret”,堆栈将由调用者恢复;-------------------函数结束-------------------------;--------------主程序调用func函数的代码--------------:00401080 6A03 push 00000003 //压入参数param3:00401082 6A02 push 00000002 //压入参数param2:00401084 6A01 push 00000001 //压入参数param1:00401086 E875FFFFFF call 00401000 //调用func函数;如果是“__cdecl”的话,将在这里恢复堆栈,“add esp, 0000000C”
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25

聪明的读者看到这里,差不多就明白缓冲溢出的原理了。先来看下面的代码:

#include <stdio.h>#include <string.h>void __stdcall func() {    char lpBuff[8]="\0";    strcat(lpBuff,"AAAAAAAAAAA");    return;}int main() {    func();    return 0;}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

编译后执行一下回怎么样?哈,“”0x00414141”指令引用的”0x00000000”内存。该内存不能为”read”。”,“非法操作”喽!”41”就是”A”的16进制的ASCII码了,那明显就是strcat这句出的问题了。”lpBuff”的大小只有8字节,算进结尾的\0,那strcat最多只能写入7个”A”,但程序实际写入了11个”A”外加1个\0。再来看看上面那幅图,多出来的4个字节正好覆盖了RET的所在的内存空间,导致函数返回到一个错误的内存地址,执行了错误的指令。如果能精心构造这个字符串,使它分成三部分,前一部份仅仅是填充的无意义数据以达到溢出的目的,接着是一个覆盖RET的数据,紧接着是一段shellcode,那只要这个RET地址能指向这段shellcode的第一个指令,那函数返回时就能执行shellcode了。但是软件的不同版本和不同的运行环境都可能影响这段shellcode在内存中的位置,那么要构造这个RET是十分困难的。一般都在RET和shellcode之间填充大量的NOP指令,使得exploit有更强的通用性。

├———————┤<—低端内存区域│ …… │├———————┤<—由exploit填入数据的开始│ ││ buffer │<—填入无用的数据│ │├———————┤│ RET │<—指向shellcode,或NOP指令的范围├———————┤│ NOP ││ …… │<—填入的NOP指令,是RET可指向的范围│ NOP │├———————┤│ ││ shellcode ││ │├———————┤<—由exploit填入数据的结束│ …… │├———————┤<—高端内存区域
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15

windows下的动态数据除了可存放在栈中,还可以存放在堆中。了解C++的朋友都知道,C++可以使用new关键字来动态分配内存。来看下面的C++代码:

#include <stdio.h>#include <iostream.h>#include <windows.h>void func(){    char *buffer=new char[128];    char bufflocal[128];    static char buffstatic[128];    printf("0x%08x\n",buffer); //打印堆中变量的内存地址    printf("0x%08x\n",bufflocal); //打印本地变量的内存地址    printf("0x%08x\n",buffstatic); //打印静态变量的内存地址}void main() {    func();    return;}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18

程序执行结果为:

0x004107d00x0012ff040x004068c0
  • 1
  • 2
  • 3

可以发现用new关键字分配的内存即不在栈中,也不在静态数据区。VC编译器是通过windows下的“堆(heap)”来实现new关键字的内存动态分配。在讲“堆”之前,先来了解一下和“堆”有关的几个API函数:

- HeapAlloc 在堆中申请内存空间- HeapCreate 创建一个新的堆对象- HeapDestroy 销毁一个堆对象- HeapFree 释放申请的内存- HeapWalk 枚举堆对象的所有内存块- GetProcessHeap 取得进程的默认堆对象- GetProcessHeaps 取得进程所有的堆对象- LocalAlloc- GlobalAlloc
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

当进程初始化时,系统会自动为进程创建一个默认堆,这个堆默认所占内存的大小为1M。堆对象由系统进行管理,它在内存中以链式结构存在。通过下面的代码可以通过堆动态申请内存空间:

HANDLE hHeap=GetProcessHeap();char *buff=HeapAlloc(hHeap,0,8);
  • 1

其中hHeap是堆对象的句柄,buff是指向申请的内存空间的地址。那这个hHeap究竟是什么呢?它的值有什么意义吗?看看下面这段代码吧:

#pragma comment(linker,"/entry:main") //定义程序的入口#include <windows.h>_CRTIMP int (__cdecl *printf)(const char *, ...); //定义STL函数printf/*--------------------------------------------------------------------------- 写到这里,我们顺便来复习一下前面所讲的知识: (*注)printf函数是C语言的标准函数库中函数,VC的标准函数库由msvcrt.dll模块实现。 由函数定义可见,printf的参数个数是可变的,函数内部无法预先知道调用者压入的参数个数,函数只能通过分析第一个参数字符串的格式来获得压入参数的信息,由于这里参数的个数是动态的,所以必须由调用者来平衡堆栈,这里便使用了__cdecl调用规则。BTW,Windows系统的API函数基本上是__stdcall调用形式,只有一个API例外,那就是wsprintf,它使用__cdecl调用规则,同printf函数一样,这是由于它的参数个数是可变的缘故。 ---------------------------------------------------------------------------*/void main(){    HANDLE hHeap=GetProcessHeap();    char *buff=HeapAlloc(hHeap,0,0x10);    char *buff2=HeapAlloc(hHeap,0,0x10);    HMODULE hMsvcrt=LoadLibrary("msvcrt.dll");    printf=(void *)GetProcAddress(hMsvcrt,"printf");    printf("0x%08x\n",hHeap);    printf("0x%08x\n",buff);    printf("0x%08x\n\n",buff2);}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20

执行结果为:

0x001300000x001331000x00133118
  • 1
  • 2

hHeap的值怎么和那个buff的值那么接近呢?其实hHeap这个句柄就是指向HEAP首部的地址。在进程的用户区存着一个叫PEB(进程环境块)的结构,这个结构中存放着一些有关进程的重要信息,其中在PEB首地址偏移0x18处存放的ProcessHeap就是进程默认堆的地址,而偏移0x90处存放了指向进程所有堆的地址列表的指针。windows有很多API都使用进程的默认堆来存放动态数据,如windows 2000下的所有ANSI版本的函数都是在默认堆中申请内存来转换ANSI字符串到Unicode字符串的。对一个堆的访问是顺序进行的,同一时刻只能有一个线程访问堆中的数据,当多个线程同时有访问要求时,只能排队等待,这样便造成程序执行效率下降。

最后来说说内存中的数据对齐所位数据对齐,是指数据所在的内存地址必须是该数据长度的整数倍,DWORD数据的内存起始地址能被4除尽,WORD数据的内存起始地址能被2除尽,x86 CPU能直接访问对齐的数据,当他试图访问一个未对齐的数据时,会在内部进行一系列的调整,这些调整对于程序来说是透明的,但是会降低运行速度,所以编译器在编译程序时会尽量保证数据对齐。同样一段代码,我们来看看用VC、Dev-C++和lcc三个不同编译器编译出来的程序的执行结果:

#include <stdio.h>int main()    {    int a;    char b;    int c;    printf("0x%08x\n",&a);    printf("0x%08x\n",&b);    printf("0x%08x\n",&c);    return 0;}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12

这是用VC编译后的执行结果:

0x0012ff7c0x0012ff7b0x0012ff80
  • 1
  • 2
  • 3

变量在内存中的顺序:b(1字节)-a(4字节)-c(4字节)。

这是用Dev-C++编译后的执行结果:

0x0022ff7c0x0022ff7b0x0022ff74
  • 1
  • 2
  • 3

变量在内存中的顺序:c(4字节)-中间相隔3字节-b(占1字节)-a(4字节)。

这是用lcc编译后的执行结果:

0x0012ff6c0x0012ff6b0x0012ff64
  • 1
  • 2
  • 3

变量在内存中的顺序:同上。

三个编译器都做到了数据对齐,但是后两个编译器显然没VC“聪明”,让一个char占了4字节,浪费内存哦。

基础知识:堆栈是一种简单的数据结构,是一种只允许在其一端进行插入或删除的线性表。允许插入或删除操作的一端称为栈顶,另一端称为栈底,对堆栈的插入和删除操作被称为入栈和出栈。有一组CPU指令可以实现对进程的内存实现堆栈访问。其中,POP指令实现出栈操作,PUSH指令实现入栈操作。CPU的ESP寄存器存放当前线程的栈顶指针,EBP寄存器中保存当前线程的栈底指针。CPU的EIP寄存器存放下一个CPU指令存放的内存地址,当CPU执行完当前的指令后,从EIP寄存器中读取下一条指令的内存地址,然后继续执行。
  • 1
  • 2
原创粉丝点击