栈与堆 (二)

2017-01-14 10:01:48来源:http://www.jianshu.com/p/9f023a955dbf作者:Rock鑫人点击

这篇文章是我整合了网友们的文章,具体来源(哪位大牛写的我也不清楚)不过在此膜拜一下,学过C/C++的看一下或许有帮助。如果只是想了解一下请看上一篇 栈与堆 (一)


六、底层介绍
堆(heap)和栈(stack)是C/C++编程不可避免会碰到的两个基本概念。
首先,这两个概念都可以在讲数据,他们都是基本的数据结构,虽然栈更为简单一些。在具体的C/C++编程框架中,这两个概念并不是并行的。对底层机器代码的研究可以揭示,栈是机器系统提供的数据结构,而堆则是C/C++函数库提供的。具体地说,现代计算机(串行执行机制),都直接在代码底层支持栈的数据结构。这体现在,有专门的寄存器指向栈所在的地址,有专门的机器指令完成数据入栈出栈的操作。这种机制的特点是效率高,支持的数据有限,一般是整数,指针,浮点数等系统直接支持的数据类型,并不直接支持其他的数据结构。因为栈的这种特点,对栈的使用在程序中是非常频繁的。对子程序的调用就是直接利用栈完成的。机器的call指令里隐含了把返回地址推入栈,然后跳转至子程序地址的操作,而子程序中的ret指令则隐含从堆栈中弹出返回地址并跳转之的操作。C/C++中的自动变量是直接利用栈的例子,这也就是为什么当函数返回时,该函数的自动变量自动失效的原因。


和栈不同,堆的数据结构并不是由系统(无论是机器系统还是操作系统)支持的,而是由函数库提供的。基本的malloc/realloc/free函数维护了一套内部的堆数据结构。当程序使用这些函数去获得新的内存空间时,这套函数首先试图从内部堆中寻找可用的内存空间, 如果没有可以使用的内存空间,则试图利用系统调用来动态增加程序数据段的内存大小,新分配得到的空间首先被组织进内部堆中去,然后再以适当的形式返回给调用者。当程序释放分配的内存空间时,这片内存空间被返回内部堆结构中,可能会被适当的处理(比如和其他空闲空间合并成更大的空闲空间),以更适合下一次内存分配申请。这套复杂的分配机制实际上相当于一个内存分配的缓冲池(Cache),


使用这套机制有如下若干原因:1. 系统调用可能不支持任意大小的内存分配。有些系统的系统调用只支持固定大小及其倍数的内存请求(按页分配);这样的话对于大量的小内存分类来说会造成浪费。2. 系统调用申请内存可能是代价昂贵的。系统调用可能涉及用户态和核心态的转换。 3. 没有管理的内存分配在大量复杂内存的分配释放操作下很容易造成内存碎片。


堆和栈的对比:
从以上知识可知,
栈是系统提供的功能,特点是快速高效,缺点是有限制,数据不灵活;
而栈是函数库提供的功能,特点是灵活方便,数据适应面广泛,但是效率有一定降低。栈是系统数据结构,对于进程/线程是唯一的;堆是函数库内部数据结构,不一定唯一。不同堆分配的内存无法互相操作。
栈空间分静态分配和动态分配两种。静态分配是编译器完成的,比如自动变量(auto)的分配。动态分配由alloca函数完成。栈的动态分配无需释放(是自动的),也就没有释放函数。为可移植的程序起见,栈的动态分配操作是不被鼓励的!堆空间的分配总是动态的,虽然程序结束时所有的数据空间都会被释放回系统,但是精确的申请内存/释放内存匹配是良好程序的基本要素。


七、从内存方面介绍 ( sɛɡmənt 部门 stæk 栈 hiːp,堆 嘿普)
iOS 操作系统中应用程序使用的 计算机内存 不是统一分配空间,
运行代码使用的空间在三个不同的内存区域,
分成三个段:“text segment “,“stack segment ”,“heap segment ”。


个人开始的理解:
栈-->客栈,有店家(系统本身)管理,只有一个出口,所以后进来的离门口较近,可以先出去 (后进先出)
堆-->一堆堆垃圾,肯定是我们自己不爱打理造成的,所以需要我们管理。
也有朋友是这样理解的:
堆和栈的区别可以用如下的比喻来看出:
使用栈就象我们去饭馆里吃饭,只管点菜(发出申请)、付钱、和吃(使用),吃饱了就走,不必理会切菜、洗菜等准备工作和洗碗、刷锅等扫尾工作,他的好处是快捷,但是自由度小。
使用堆就象是自己动手做喜欢吃的菜肴,比较麻烦,吃完之后还得清洗、打扫等后续工作(不然用完了不清洗就不好再用),但是比较符合自己的口味,而且自由度大。


段 “text segment”:
是应用程序运行时 <应用程序代码> 存在的内存段。
每一个指令,每一个单个函数、过程、方法 和执行代码都存在这个内存段中直到应用程序退出。


栈 “stack segment”:
当应用开始以后,函数main() 被调用,一些空间分配在”stack” 中。
这是为应用分配的另一个段的内存空间,这是为了函数变量存储需要而分配的内存。
每一次在应用中调用一个函数,“stack ”的一部分会被分配在”stack” 中,称之为”frame” 。
新函数的本地变量分配在这里。
正如名称所示,“stack ”是后进先出(LIFO )结构。
当函数调用其他的函数时,“stack frame ”会被创建;
当其他函数退出后,这个“frame ”会自动被破坏。


堆 “heap segment ”
“heap” 段也称为”data” 段,提供一个保存中介贯穿函数的执行过程,
全局变量和静态变量保存在“heap ”中,直到应用退出。


二者的联系:
为了访问你创建在 heap 中的数据,你最少要求有一个保存在stack中的指针,
因为CPU 是通过 stack 中的指针访问 heap 中的数据。
你可以认为stack 中的一个指针仅仅是一个整型变量,保存了heap 中特定内存地址的数据。
实际上,它有一点点复杂,但这是它的基本结构。
简而言之,操作系统使用stack 段中的指针值访问heap 段中的对象。
如果stack 对象的指针没有了,则heap 中的对象就不能访问。这也是内存泄露的原因!!!


在iOS 操作系统的stack 段和heap 段中,你都可以创建数据对象。


stack 对象的优点主要有两点:
一是创建速度快,二是管理简单,它有严格的生命周期。
stack 对象的缺点是: 它不灵活。
创建时长度是多大就一直是多大,创建时是哪个函数创建的,它的owner 就一直是它。
不像heap 对象那样有多个owner ,其实多个owner 等同于引用计数。
只有heap 对象才是采用“引用计数”方法管理它。


stack 对象的创建
只要栈的剩余空间大于stack 对象申请创建的空间,操作系统就会为程序提供这段内存空间,否则将报异常提示栈溢出。
heap 对象的创建
操作系统对于内存heap 段是采用 链表 进行管理的。
操作系统有一个记录空闲内存地址的链表,当收到程序的申请时,会遍历链表,
寻找第一个空间大于所申请的heap 节点,然后将该节点从空闲节点链表中删除,并将该节点的空间分配给程序。


例如:
NSString 的对象就是stack 中的对象,创建时分配的内存长度固定且不可修改
NSMutableString 的对象就是heap 中的对象。分配内存长度是可变的,可有多个owner, 适用于计数管理内存管理模式。


两类对象的创建方法也不同:
前者直接创建“NSString str1=@"welcome";
而后者需要先分配再初始化“ NSMutableString
mstr1=[[NSMutableString alloc] initWithString:@"welcome"]; ”。


##再补充一点,这里说的是操作系统的堆和栈。操作系统的堆和栈是指对内存进行操作和管理的一些方式,在“数据结构”中,接触到的堆和栈的概念和这个操作系统中的堆和栈不是一回事的。“数据结构“的堆实际上指的就是(满足堆性质的)优先Queue 的一种数据结构,第1 个元素有最高的优先权;栈实际上就是满足先进后出(后进先出)的性质的数据或数据结构。


 八,代码案例:
1.代码分析三者之间的不同:
例一:静态存储区与栈区
char* p = “Hello World1”;
char a[] = “Hello World2”;
p[2] = ‘A’;
a[2] = ‘A’;
char* p1 = “Hello World1;”

这个程序是有错误的,错误发生在p[2] = ‘A’这行代码处,因为数据“Hello World2”存在于数组中,所以,此数据存储于栈区,对它修改是没有任何问题的。因为指针变量p仅仅能够存储某个存储空间的地址,数据“Hello World1”为字符串常量,所以存储在静态存储区。虽然通过p[2]可以访问到静态存储区中的第三个数据单元,即字符‘l’所在的存储的单元。但是因为数据“Hello World1”为字符串常量,不可以改变,所以在程序运行时,会报告内存错误。并且,如果此时对p和p1输出的时候会发现p和p1里面保存的地址是完全相同的。换句话说,在数据区只保留一份相同的数据。


 例二:栈区与堆
char* f1()
{
char* p = NULL;
char a;
p = &a;
return p;
}
char* f2()
{
char* p = NULL:
p =(char*) new char[4];
return p;
}

这两个函数都是将某个存储空间的地址返回,二者有何区别呢?f1()函数虽然返回的是一个存储空间,但是此空间为临时空间。也就是说,此空间只有短暂的生命周期,它的生命周期在函数f1()调用结束时,也就失去了它的生命价值,即:此空间被释放掉。所以,当调用f1()函数时,如果程序中有下面的语句:
char p ;
p = f1();
p = ‘a’;
此时,编译并不会报告错误,但是在程序运行时,会发生异常错误。因为,你对不应该操作的内存(即,已经释放掉的存储空间)进行了操作。但是,相比之下,f2()函数不会有任何问题。因为,new这个命令是在堆中申请存储空间,一旦申请成功,除非你将其delete或者程序终结,这块内存将一直存在。也可以这样理解,堆内存是共享单元,能够被多个函数共同访问。如果你需要有多个数据返回却苦无办法,堆内存将是一个很好的选择。
但是不能用临时变量存放堆内存的首地址,因为临时变量一旦被释放,这块内存将不可能不调用,造成内存泄露。


2.
//main.cpp
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放在常量区,这个函数的作用是将"123456" 这串字符串复制一份放在p1申请的10个字节的堆区域中。
// p3指向的"123456"与这里的"123456"可能会被编译器优化成一个地址。
}
strcpy函数
原型声明:extern char strcpy(chardest, const char *src);
功能:把从src地址开始且含有NULL结束符的字符串复制到以dest开始的地址空间。
简单的说 就是堆是无序的,你可以任意取任意插入操作,而栈对任意元素的操作都是有秩序的有约束的,


 九.存取效率的比较
char s1[] = "aaa";
char *s2 = "bbb";
aaa是在运行时刻赋值的;
而bbb是在编译时就确定的;
但是,在以后的存取中,在栈上的数组比指针所指向的字符串(例如堆)快。
比如:
void main()
{
char a = 1;
char c[] = "1234567890";
char *p ="1234567890";
a = c[1];
a = p[1];
return;
}
对应的汇编代码
10: a = c[1];
00401067 8A 4D F1 mov cl,byte ptr [ebp-0Fh]
0040106A 88 4D FC mov byte ptr [ebp-4],cl
11: 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

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



十.队列和栈有什么区别?
答:队列和栈是两种不同的数据容器。
从”数据结构”的角度看,它们都是线性结构,即数据元素之间的关系相同。
队列是一种先进先出的数据结构,它在两端进行操作,一端进行入队列操作,一端进行出列队操作。
栈是一种先进后出的数据结构,它只能在栈顶进行操作,入栈和出栈都在栈顶操作。


栈(Stack)是限定只能在表的一端进行插入和删除操作的现行表。
1>队列先进先出,栈先进后出。
2>对插入和删除的操作的“限定”。栈是限定只能在表的一端进行插入和删除操作的线性表。
3>遍历数据速度不同。队列遍历数据的速度要快得多。


一位网友用10个字总结了堆和栈的区别
栈是吃了吐 堆是吃了拉
虽然不是很文雅,凑合着理解,凑合着看吧!


注:这里的堆和栈与数据结构中的堆和栈不是一回事。


十一.编程语言书籍中经常解释值类型被创建在栈上,引用类型被创建在堆上,但是并没有本质上解释这堆和栈是什么?
我们或许理解什么是栈,但是它们到底是什么,在哪儿呢(站在实际的计算机物理内存的角度上看)?
在通常情况下由操作系统(OS)和语言的运行时(runtime)控制吗?
它们的作用范围是什么?它们的大小由什么决定?哪个更快?
答案一
栈是为执行线程留出的内存空间。当函数被调用的时候,栈顶为局部变量和一些 bookkeeping 数据预留块。当函数执行完毕,块就没有用了,可能在下次的函数调用的时候再被使用。栈通常用后进先出(LIFO)的方式预留空间;因此最近的保留块(reserved block)通常最先被释放。这么做可以使跟踪堆栈变的简单;从栈中释放块(free block)只不过是指针的偏移而已。


堆(heap)是为动态分配预留的内存空间。和栈不一样,从堆上分配和重新分配块没有固定模式;你可以在任何时候分配和释放它。这样使得跟踪哪部分堆已经被分配和被释放变的异常复杂;有许多定制的堆分配策略用来为不同的使用模式下调整堆的性能。


每一个线程都有一个栈,但是每一个应用程序通常都只有一个堆(尽管为不同类型分配内存使用多个堆的情况也是有的)。


答案: 1. 当线程创建的时候,操作系统(OS)为每一个系统级(system-level)的线程分配栈。通常情况下,操作系统通过调用语言的运行时(runtime)去为应用程序分配堆。 2. 栈附属于线程,因此当线程结束时栈被回收。堆通常通过运行时在应用程序启动时被分配,当应用程序(进程)退出时被回收。 3. 当线程被创建的时候,设置栈的大小。在应用程序启动的时候,设置堆的大小,但是可以在需要的时候扩展(分配器向操作系统申请更多的内存)。 4. 栈比堆要快,因为它存取模式使它可以轻松的分配和重新分配内存(指针/整型只是进行简单的递增或者递减运算),然而堆在分配和释放的时候有更多的复杂的 bookkeeping 参与。另外,在栈上的每个字节频繁的被复用也就意味着它可能映射到处理器缓存中,所以很快(译者注:局部性原理)。


答案二
Stack:
和堆一样存储在计算机 RAM 中。
在栈上创建变量的时候会扩展,并且会自动回收。
相比堆而言在栈上分配要快的多。
用数据结构中的栈实现。
存储局部数据,返回地址,用做参数传递。
当用栈过多时可导致栈溢出(无穷次(大量的)的递归调用,或者大量的内存分配)。
在栈上的数据可以直接访问(不是非要使用指针访问)。
如果你在编译之前精确的知道你需要分配数据的大小并且不是太大的时候,可以使用栈。
当你程序启动时决定栈的容量上限。

Heap:
和栈一样存储在计算机RAM。
在堆上的变量必须要手动释放,不存在作用域的问题。数据可用 delete, delete[] 或者 free 来释放。
相比在栈上分配内存要慢。
通过程序按需分配。
大量的分配和释放可造成内存碎片。
在 C++ 中,在堆上创建数的据使用指针访问,用 new 或者 malloc 分配内存。
如果申请的缓冲区过大的话,可能申请失败。
在运行期间你不知道会需要多大的数据或者你需要分配大量的内存的时候,建议你使用堆。
可能造成内存泄露。
举例:



 C++
int foo()
{
char *pBuffer; //<--nothing allocated yet (excluding the pointer itself, which is allocated here on the stack).
bool b = true; // Allocated on the stack.
if(b)
{
//Create 500 bytes on the stack
char buffer[500];
//Create 500 bytes on the heap
pBuffer = new char[500];
}//<-- buffer is deallocated here, pBuffer is not
}//<--- oops there's a memory leak, I should have called delete[] pBuffer;
int foo()
{
char *pBuffer; //<--nothing allocated yet (excluding the pointer itself, which is allocated here on the stack).
bool b = true; // Allocated on the stack.
if(b)
{
//Create 500 bytes on the stack
char buffer[500];
//Create 500 bytes on the heap
pBuffer = new char[500];
}//<-- buffer is deallocated here, pBuffer is not
}//<--- oops there's a memory leak, I should have called delete[] pBuffer;


答案三
堆和栈是两种内存分配的两个统称。可能有很多种不同的实现方式,但是实现要符合几个基本的概念:
1.对栈而言,栈中的新加数据项放在其他数据的顶部,移除时你也只能移除最顶部的数据(不能越位获取)。
如图:



栈结构.png

2.对堆而言,数据项位置没有固定的顺序。你可以以任何顺序插入和删除,因为他们没有“顶部”数据这一概念。
如图:



堆结构.png

上面上个图片很好的描述了堆和栈分配内存的方式。


在通常情况下由操作系统(OS)和语言的运行时(runtime)控制吗?
如前所述,堆和栈是一个统称,可以有很多的实现方式。计算机程序通常有一个栈叫做调用栈,用来存储当前函数调用相关的信息(比如:主调函数的地址,局部变量),因为函数调用之后需要返回给主调函数。栈通过扩展和收缩来承载信息。实际上,程序不是由运行时来控制的,它由编程语言、操作系统甚至是系统架构来决定。


堆是在任何内存中动态和随机分配的(内存的)统称;也就是无序的。内存通常由操作系统分配,通过应用程序调用 API 接口去实现分配。在管理动态分配内存上会有一些额外的开销,不过这由操作系统来处理。


它们的作用范围是什么?
调用栈是一个低层次的概念,就程序而言,它和“作用范围”没什么关系。如果你反汇编一些代码,你就会看到指针引用堆栈部分。就高级语言而言,语言有它自己的范围规则。一旦函数返回,函数中的局部变量会直接直接释放。你的编程语言就是依据这个工作的。


在堆中,也很难去定义。作用范围是由操作系统限定的,但是你的编程语言可能增加它自己的一些规则,去限定堆在应用程序中的范围。体系架构和操作系统是使用虚拟地址的,然后由处理器翻译到实际的物理地址中,还有页面错误等等。它们记录那个页面属于那个应用程序。不过你不用关心这些,因为你仅仅在你的编程语言中分配和释放内存,和一些错误检查(出现分配失败和释放失败的原因)。


它们的大小由什么决定?
依旧,依赖于语言,编译器,操作系统和架构。栈通常提前分配好了,因为栈必须是连续的内存块。语言的编译器或者操作系统决定它的大小。不要在栈上存储大块数据,这样可以保证有足够的空间不会溢出,除非出现了无限递归的情况(额,栈溢出了)或者其它不常见了编程决议。


堆是任何可以动态分配的内存的统称。这要看你怎么看待它了,它的大小是变动的。在现代处理器中和操作系统的工作方式是高度抽象的,因此你在正常情况下不需要担心它实际的大小,除非你必须要使用你还没有分配的内存或者已经释放了的内存。


哪个更快一些?
栈更快因为所有的空闲内存都是连续的,因此不需要对空闲内存块通过列表来维护。只是一个简单的指向当前栈顶的指针。编译器通常用一个专门的、快速的寄存器来实现。更重要的一点事是,随后的栈上操作通常集中在一个内存块的附近,这样的话有利于处理器的高速访问(译者注:局部性原理)。


答案四
你问题的答案是依赖于实现的,根据不同的编译器和处理器架构而不同。下面简单的解释一下:
栈和堆都是用来从底层操作系统中获取内存的。
在多线程环境下每一个线程都可以有他自己完全的独立的栈,但是他们共享堆。并行存取被堆控制而不是栈。
堆:
堆包含一个链表来维护已用和空闲的内存块。在堆上新分配(用 new 或者 malloc)内存是从空闲的内存块中找到一些满足要求的合适块。这个操作会更新堆中的块链表。这些元信息也存储在堆上,经常在每个块的头部一个很小区域。
堆的增加新快通常从地地址向高地址扩展。因此你可以认为堆随着内存分配而不断的增加大小。如果申请的内存大小很小的话,通常从底层操作系统中得到比申请大小要多的内存。
申请和释放许多小的块可能会产生如下状态:在已用块之间存在很多小的空闲块。进而申请大块内存失败,虽然空闲块的总和足够,但是空闲的小块是零散的,不能满足申请的大小,。这叫做“堆碎片”。
当旁边有空闲块的已用块被释放时,新的空闲块可能会与相邻的空闲块合并为一个大的空闲块,这样可以有效的减少“堆碎片”的产生。


栈:
栈经常与 sp 寄存器(译者注:”stack pointer”,了解汇编的朋友应该都知道)一起工作,最初 sp 指向栈顶(栈的高地址)。
CPU 用 push 指令来将数据压栈,用 pop 指令来弹栈。当用 push 压栈时,sp 值减少(向低地址扩展)。当用 pop 弹栈时,sp 值增大。存储和获取数据都是 CPU 寄存器的值。
当函数被调用时,CPU使用特定的指令把当前的 IP (译者注:“instruction pointer”,是一个寄存器,用来记录 CPU 指令的位置)压栈。即执行代码的地址。CPU 接下来将调用函数地址赋给 IP ,进行调用。当函数返回时,旧的 IP 被弹栈,CPU 继续去函数调用之前的代码。
当进入函数时,sp 向下扩展,扩展到确保为函数的局部变量留足够大小的空间。如果函数中有一个 32-bit 的局部变量会在栈中留够四字节的空间。当函数返回时,sp 通过返回原来的位置来释放空间。
如果函数有参数的话,在函数调用之前,会将参数压栈。函数中的代码通过 sp 的当前位置来定位参数并访问它们。
函数嵌套调用和使用魔法一样,每一次新调用的函数都会分配函数参数,返回值地址、局部变量空间、嵌套调用的活动记录都要被压入栈中。函数返回时,按照正确方式的撤销。
栈要受到内存块的限制,不断的函数嵌套/为局部变量分配太多的空间,可能会导致栈溢出。当栈中的内存区域都已经被使用完之后继续向下写(低地址),会触发一个 CPU 异常。这个异常接下会通过语言的运行时转成各种类型的栈溢出异常。(译者注:“不同语言的异常提示不同,因此通过语言运行时来转换”我想他表达的是这个含义)


*函数的分配可以用堆来代替栈吗?
不可以的,函数的活动记录(即局部或者自动变量)被分配在栈上, 这样做不但存储了这些变量,而且可以用来嵌套函数的追踪。


堆的管理依赖于运行时环境,C 使用 malloc ,C++ 使用 new ,但是很多语言有垃圾回收机制。
栈是更低层次的特性与处理器架构紧密的结合到一起。当堆不够时可以扩展空间,这不难做到,因为可以有库函数可以调用。但是,扩展栈通常来说是不可能的,因为在栈溢出的时候,执行线程就被操作系统关闭了,这已经太晚了。


十二.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;
}
编译后的执行结果是:
0x0012ff78
0x0012ff7c
0x0012ff80
0x004068d0
0x004068d4
0x004068d8
0x004068dc
0x004068e0
0x004068e4

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


 ├———————┤低端内存区域
│ …… │
├———————┤
│ 动态数据区 │
├———————┤
│ …… │
├———————┤
│ 代码区 │
├———————┤
│ 静态数据区 │
├———————┤
│ …… │
├———————┤高端内存区域

堆栈是一个先进后出的数据结构,栈顶地址总是小于等于栈的基地址。我们可以先了解一下函数调用的过程,以便对堆栈在程序中的作用有更深入的了解。不同的语言有不同的函数调用规定,这些因素有参数的压入规则和堆栈的平衡。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",¶m1); //打印出各个变量的内存地址
printf("0x%08x/n",¶m2);
printf("0x%08x/n/n",¶m3);
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;
}
编译后的执行结果是:
0x0012ff78
0x0012ff7c
0x0012ff80
0x0012ff68
0x0012ff6c
0x0012ff70

├———————┤<—函数执行时的栈顶(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-34,每个int变量占用4个字节);接着就初始化本地变量的内存空间。由于“__stdcall”调用由被调函数调整堆栈,所以在函数返回前要恢复堆栈,先回收本地变量占用的内存(ESP=ESP+34),然后取出返回地址,填入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”
聪明的读者看到这里,差不多就明白缓冲溢出的原理了。先来看下面的代码:
#include <stdio.h>
#include <string.h>
void __stdcall func()
{
char lpBuff[8]="/0";
strcat(lpBuff,"AAAAAAAAAAA");
return;
}
int main()
{
func();
return 0;
}

编译后执行一下回怎么样?哈,“"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填入数据的结束
│ …… │
├———————┤<—高端内存区域

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;
}
程序执行结果为:
0x004107d0
0x0012ff04
0x004068c0

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


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

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


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

其中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);
}


执行结果为:
0x00130000
0x00133100
0x00133118


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;
}
这是用VC编译后的执行结果:
0x0012ff7c
0x0012ff7b
0x0012ff80
变量在内存中的顺序:b(1字节)-a(4字节)-c(4字节)。
这是用Dev-C++编译后的执行结果:
0x0022ff7c
0x0022ff7b
0x0022ff74
变量在内存中的顺序:c(4字节)-中间相隔3字节-b(占1字节)-a(4字节)。
这是用lcc编译后的执行结果:
0x0012ff6c
0x0012ff6b
0x0012ff64
变量在内存中的顺序:同上。
三个编译器都做到了数据对齐,但是后两个编译器显然没VC“聪明”,让一个char占了4字节,浪费内存哦。

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




相关文章

    无相关信息

最新文章

123

最新摄影

微信扫一扫

第七城市微信公众平台