windows 堆的数据结构及管理策略
作者:孙元博
摘 要: 详细讲解windows堆的数据结构及管理策略
关键词: 前堆 后堆 堆分配 堆释放 堆合并
一、引言
最近常看到有人问堆栈的区别是什么?那个在分配内存的效率高?为什么?另外堆栈内部实现原理也是编写高质量、高效率、高性能、高防御性代码及exploit的基础。故总结此文,以便日后学习。
二、堆简介
堆是一种内存的管理器,c++中用new/delete,c中用malloc/free函数来分配和释放内存。当我们无法预知所需要的内存的大小,所需要的内存无法在栈中得到满足的时候,我们都需要使用到堆。除堆可以实现动态内存的分配与释放以外,c运行时,虚拟内存管理器及其他形式的私有内存管理器也可以。但他们的根本都是windows虚拟内存管理器。如下图1windwos内存管理架构所示
当一个进程启动的时候会有默认的进程堆被动态的创建,虽有一些进程使用这个默认的进程堆但是大多数使用的还是C运行时堆(new /delete malloc/free等)来满足对内存的需要。 windows的堆管理器可以进一步划分为前堆和后堆分配器。具体见堆的分配。
三、堆栈的区别
不同的操作系统,其进程的执行区域可能不同但是进程使用的内存按照功能大致可以分为四大区域:
代码区域:被装入执行的二进制代码,处理器会到这个区域取指并执行
数 据 区:存储全局变量
栈 区:动态分配和回收内存
堆 区:动态存储函数之间的调用关系,保证函数在返回的时候能够恢复到母函数去执行
栈空间是程序在设计时已经规定好怎么使用,使用多少内存,栈变量在使用的时候不需要额外的申请,系统会根据函数的申明自动的在函数的栈帧预留空间的,栈的空间是有操作系统维护的,所有的分配回收都是由系统管理的。所以说效率比较高。进程中的每一个线程都有自己的栈空间,win32中线程的栈空间是1M。win32中一个进程的空间有4G,除过系统占用的空间外,用户可以使用的大约有2G,故其最多可创建2000多个线程。
堆是程序运行的时候动态分配内存的,c中的malloc /free;c++中的new/delete;堆内出的分配未必就一定会成功。要根据所分配的内存的大小,运行环境及系统的性能决定的。而且在分配内存时有一定的策略,故效率和栈相比不是很高。
栈是线性变化的,其管理机制只有push和pop两种。而堆往往显的是杂乱无章的。如下图2堆栈内存的比较.
堆内存 | 栈内存 | |
典型用例 | 动态增长的链表数据结构 | 函数局部数据 |
申请方式 | 需要申请,通过函数的返回指针使用。 如:char *p = (char *)malloc(100) | 在程序中直接声明即可。 如: char buffer[1024]; |
释放方式 | new 用delete malloc用free | 函数返回的时候由系统回收 |
管理方式 | 程序员申请 释放 | 申请释放由系统管理,最后达到栈平衡 |
所处位置 | 变化范围很大 | ox0012xxxx |
增长方式 | 由内存低地址向高地址(不考虑碎片)增加 | 由高地址向低地址增加 |
图2.堆栈内存比较
四、堆分配
对于堆的管理系统来讲,响应程序内存的使用申请就是说要在“杂乱”的堆区找到合适的,空闲的,恰当的内存区域,并以指针的形式返回给程序。
要高效率的完成这些要求,就必须设计一套高效率的数据结构和算法。windows操作系统中堆的数据结构包括堆块和堆表两类。
堆块:出于性能的考虑,堆区的内存按不同的大小组织成块,以块为单位进行标识,不是传统的字节,每个堆块又包含块头和块身。块头用来存放该快的标识信息,如:块的大小,块是否占用等。块身即实际的数据。
堆表:堆表一般位于堆区的首位,用来索引堆区中所有堆块的重要信息。包括空闲还是占用,堆大小等。最重要的堆表有两种:单向链表和空闲双向连表。
前面说windows的堆管理器进一步可以分为前端分配器(Front End Allocator)和后端分配器(Back End Allocator)。在windows中有两种前端分配器:Look Aside List,LAL(旁视列表)和Low Fragmentation ,LF(低碎片)前端分配器。LAL是一张堆表,其中有128项。每一项都对应一个单链表,每个单链表都包含了一组固定大小的堆块(oday中说每个单链表的堆块不能大于4)。每一个堆块包含8字节的块头,因此返回给调用者的最小块就是16字节(8字节的块头+8字节的块身),这样LAl没有使用索引为0 的项,因为此项对应的是大小为8字节的空闲块首。例如:如果请求分配16字节的内存,则LAL则会查找大小为24字节的空闲快,因为还要包含8字节的块头。
每个索引都表示一组空闲的堆块,堆块的大小是前一个索引中堆块的大小加上8个字节。最后一个索引(127)包含了大小为1024(不包含8字节的块头的话就是1016字节)字节的空闲堆块。当调用释放函数后,管理内存器将把这块内存标记为空闲。并将其加到相应索引所指向的单向链表中。LaL又叫块表,其是最快的内存分配方式。因为单向链表中从不会发生堆块合并(空闲块的块头被设置为占用太,防止合并)。
接下来看例子如图3所示,索引为1的链表中有3个大小为16字节的堆块(实际可以使用其中的8字节)当我们要访问一块大小为32字节的内存的时候,堆管理器会在请求块的大小上加8个字节然后除以8再减去1(所以从0开始)。如果我们试图分配大小为16字节的内存,则其索引应该为2,但此时索引为2所对应的是空链表,无法分配,此时就要考虑到后端分配器。
同前端分配器一样,后端分配器也有自己的堆表,为空闲列表。空闲列表和单链表的不同有两点:一是空闲链表是双向连表,二是空闲链表[0]的堆块大小大于1016字节小于虚拟内存分配的限值。如下图3空闲列表所示。从图中可以看出空闲堆块的大小等于索引ID与8(字节)的乘积(除空闲空闲链表[0])。
图 3 空闲列表
为了将查找空闲堆块的效率最大化,堆管理器维持一个空闲列表位图。位图中包含了128位,每一位都表示空闲列表中的一个索引。某一位被设置则包含空闲快反之则没有。如图4空闲列表位图。如:索引为2处的值为1,则说明有空闲。
0 | 1 | 2 | 3 | 4 | 5 |
1 | 0 | 1 | 0 | 0 | 0 |
图 4 空闲列表位图
如果堆管理器无法找到某个空闲块的大小和请求的相等,则将使用一种分割(Block Splitting)技术。首先找到一个比所请求的大小更大的空闲块,然后将其对半分割。最后移除这个堆块。如:如果请求8字节,在空闲块中没有找到大小为16(8字节块头+8)字节的块,则查找大小为32字节的块,找到后先将其移出这个堆块并将其对半分割,一个堆块将被其放入表示16字节堆块的空闲堆块中,另一半返回给调用者。然后将32字节的堆块从空闲链表移除。如下图5分割空闲堆块所示。
图 5 分割空闲堆块
五、堆合并
为了减少内存碎片,堆管理器使用了堆合并技术。对合并技术在空闲列表中使用,这也是为什么空闲列表是双向链表的原因。当堆管理器释放一个堆块的时候,首先判断堆块的左右是否有相邻的堆块也是空闲的,如果有则首先将左右的堆块和当前堆块合并,并将合并后的堆块加入空闲列表,同时堆管理器小心的移除左右两个堆块。
六、总结
分配一块内存的步骤:
1.堆管理器首先查看前端分配器的LAL,如果存在可用堆则返回给用户否则进入步骤2
2.堆管理器查看后端分配器的空闲列表。如果找到合适的堆块,则更新堆块的标志表示此块处于占用状态。然后从空闲列表移出返回给用户。如果没有找到,堆管理器将找一个更大的堆块,将其对半,一半加入对应的空闲链表,一半返回给调用者。同时此大堆块将从空闲列表移除。
3.如果空闲列表不能满足要求,那么堆管理器将从堆段中提交更多的内存。并在新提交的内存快分配新块返回给调用者。
释放对内存的步骤:
1.首先查看前端分配器能否处理,不能就进入步骤2
2.堆管理器将判断相邻的堆块是否存在空闲块,如果存在则先合并,步骤如下:
a. 将相邻的空闲堆块从空闲列表中移走
b.将新的大块加入到前端分配器的堆表(单链表)或者后端的堆表(空闲列表)
c.将新的大堆的标志设置为空闲
3.如果不能进行合并操作,这个块将被移入空闲列表或者前端分配器列表。标志设置为空闲
七、参考资料
[1]. Oday 安全:软件分析技术 电子工业出版社出版 failwest编著
[2].Advanced Windows Debugging 聂雪军 等译