读书频道 > 系统 > windows > Windows内核原理与实现
4.2.2 系统地址空间内存管理
2013-05-18 15:43:34     我来说两句 
收藏    我要投稿   

本文所属图书 > Windows内核原理与实现

本书从操作系统原理的角度,详细解析了Windows如何实现现代操作系统的各个关键部件,包括进程、线程、物理内存和虚拟内存的管理,Windows中的同步和并发性支持,以及Windows的I/O模型。在介绍这些关键部件时,本...  立即去当当网订购

在这一节中,我们将讨论系统地址空间中的内存是如何动态管理的。在系统地址空间中,有些部分是供一些特殊模块使用的,比如会话空间是由会话管理器和Windows 子系统使用的;而换页内存池和非换页内存池则是提供给系统内核模块和设备驱动程序使用的。在换页内存池中分配的内存有可能在物理内存紧缺的情况下被换出到外存中;而非换页内存池中分配的内存总是处于物理内存中。为了实现这两种内存池,Windows 使用了两层内存管理。下层是基于页面的内存管理,仅限于执行体内部使用;上层建立在下层的内存管理功能基础之上,对外提供各种粒度的内存服务。两层的结构如图4. 15所示(这里不考虑会话空间的内存管理)。为描述方便起见,下层的内存池称为系统内存池,上层的内存池称为执行体内存池。本节我们将首先讨论系统非换页内存池和换页内存池的管理算法和实现方法,然后介绍执行体内存池的算法和实现原理。下一节再介绍系统PTE 区域的管理算法和实现。


 

(一)  系统非换页内存池的管理算法

在上一节解读 MiInitMachineDependent 函数时,我们曾经提到,该函数调用MiInitializeNonPagedPool 和MiInitializeNonPagedPoolThresholds函数来初始化非换页内存池的管理信息。MiInitializeNonPagedPool 函数位于base\ntos\mm\allocpag.c 文件中,见代码3 399~3 599行。它的主要任务是初始化用于管理内存池的一些全局变量,尤其是用于存放空闲页面链表头的MmNonPagedPoolFreeListHead数组。空闲页面链表中的每一项都是一个MMFREE_POOL_ENTRY结构,其定义如下(见base\ntos\mm\mi.h 头文件):
typedef struct _MMFREE_POOL_ENTRY {
    LIST_ENTRY List;        // maintained free&chk, 1st entry only
    PFN_NUMBER Size;        // maintained free&chk, 1st entry only
    ULONG Signature;        // maintained chk only, all entries
    struct _MMFREE_POOL_ENTRY *Owner; // maintained free&chk, all entries
} MMFREE_POOL_ENTRY, *PMMFREE_POOL_ENTRY; 

在初始化状态下,整个非换页池空间,即从 MmNonPagedPoolStart 开始,一共MmSizeOfNonPagedPoolInBytes 大小的内存,所有的页面均加入第一个页面所在的MMFREE_POOL_ENTRY项。MmNonPagedPoolFreeListHead数组在默认情况下包括4 项:第一个数组项包含所有单个空闲页面的MMFREE_POOL_ENTRY项,第二个数组项包含所有2 个空闲页面的MMFREE_POOL_ENTRY项,第三个数组项包含所有 3 个空闲页面的MMFREE_POOL_ENTRY项,第四个数组项包含所有大于等于4 个空闲页面的MMFREE_POOL_ENTRY项。在初始情况下,只有一个MMFREE_POOL_ENTRY项加入到第四个数组项链表中。

然后,MiInitializeNonPagedPool 函数确定非换页内存池的起始和结束物理页面帧MiStartOfInitialPoolFrame 和MiEndOfInitialPoolFrame,最后,为非换页内存池扩展区建立起系统PTE 。

一旦非换页内存池的结构已建立,接下来系统代码可以通过MiAllocatePoolPages 和MiFreePoolPages函数来申请和归还页面。这两个函数也位于 base\ntos\mm\allocpag.c 文件中,见代码 1 188~3 317行。其中非换页内存的页面申请逻辑位于代码行 1 262~1 828,页面回收的代码逻辑位于2 507~3 093行。这两个函数的其余部分基本上在处理换页内存池的页面申请和回收,其中也包括一部分代码在处理会话空间中的内存池。

非换页内存池有两部分:地址范围MmNonPagedPoolStart 与MmNonPagedPoolEnd0之间的部分是基本内存池,其中所有的页面都已经分配了物理页面;地址范围MmNonPagedPoolExpansionStart 与MmNonPagedPoolEnd之间的部分是扩展区,只有当基本区无法满足内存申请需求时才会分配真正的物理页面。由于非换页内存池的空闲页面已经被映射到物理页面,所以,Windows 充分利用这些页面自身的内存来构建起一组空闲页面链表,换句话说,每个页面都是一个MMFREE_POOL_ENTRY结构,如图4. 16所示。MiInitializeNonPagedPool 函数已经把数组 MmNonPagedPoolFreeListHead初始化成只包含一个完整的空闲内存块,该内存块包括所有的非换页页面。


 

如前所述,在非换页内存池的结构中,每个空闲链表中的每个节点包含1 、2 、3 、4 或4 个以上的页面,在同一个节点上的页面其虚拟地址空间是连续的。第一个页面的List 域构成了链表结构,Size 域指明了这个节点所包含的页面数量,Owner 域指向自己;后续页面的List 和Size 域没有使用,但Owner 域很重要,它指向第一个页面。

当MiAllocatePoolPages 函数从非换页内存池中分配页面时,它根据指定的大小值,换算成对应的页面数,即局部变量 SizeInPages ,然后从 MmNonPagedPoolFreeListHead数组中选择适当的空闲链表开始搜索,直至找到一个节点拥有这么多的页面,将该节点从链表中移除出来。如果该节点除了满足客户所要求的页面数SizeInPages 以外,还有剩余的页面,那么,该节点后端部分的SizeInPages 个页面将作为结果返回给客户,前端部分剩余的那些页面被当做一个新的空闲链表节点加入到适当的空闲链表中。这些页面的首页面中的Size 域需要调整,后续的页面皆指向首页面,无须任何改变。

对于已经确定的SizeInPages 个页面,MiAllocatePoolPages 函数分别找到其起始页面和结束页面在PFN 数据库中的记录,并设置它们的StartOfAllocation和EndOfAllocation位,因此,一次有效的页面分配也会在 PFN 数据库中留下痕迹。关于 PFN 数据库的定义和管理,请参考4.5.1节的介绍。

如果在空闲链表数组中搜索不到满足条件的空闲节点,则MiAllocatePoolPages 函数试图扩展非换页内存池,并满足所要求的页面数。

非换页内存池中的页面回收是通过MiFreePoolPages 函数来完成的,对于一个给定的起始地址 StartingAddress,找到它的 PTE ,并提取出页帧编号,从而定位到 PFN 数据库中起始页面的 PFN 项。凡是通过 MiAllocatePoolPages函数分配得到的连续页面,其首页面的 PFN项的 StartOfAllocation 位必定是 1,沿着此 PFN 项进行搜索,可以找到结束页面,其 PFN项的 EndOfAllocation 位为 1。为了维护非换页内存池中空闲链表的结构,被回收的页面如果有可能的话,还需要跟它们后续的空闲页面或者它们前面的空闲页面合并成一个更大的空闲内存块,并且维护好首页面或中间页面的MMFREE_POOL_ENTRY 结构中的域。如果StartingAddress指示了一个非换页扩展区中的地址,则可能有必要调用 MiFreeNonPagedPool函数来收回它的页面。

在MiAllocatePoolPages 和MiFreePoolPages函数的代码中,当申请或释放单个页面时,我们可以看到MiNonPagedPoolSListHead链表的用法。这基本上算是一种优化,在收回单个页面的时候,如果MiFreePoolPages函数发现,MiNonPagedPoolSListHead链表中的页面数(即 Depth域)小于 MiNonPagedPoolSListMaximum(全局变量,被初始赋值为 4),则将此页面加入到MiNonPagedPoolSListHead 链表中,参见base\ntos\mm\allocpag.c 的2 575~2 582行代码。因此,在MiAllocatePoolPages 的开始处(见 allocpag.c文件的1 264~ 1 286行代码),如果客户要申请单个页面,并且MiNonPagedPoolSListHead链表不为空,则直接从链表头提取一个页面返回给客户。所以,MiNonPagedPoolSListHead链表相当于非换页内存池的一个单页面缓存区。频繁地申请和回收单页面,只相当于在单链表中的插入和移除动作而已,从而避免了在 MmNonPagedPoolFreeListHead数组的单页面链表中相对昂贵的分配和回收操作。

(二)系统换页内存池的管理算法

接下来看系统换页内存池的管理算法。显然,非换页内存池的空闲页面链表的做法不适合于换页内存池,因为换页内存池中的空闲页面并不保证有对应的物理页面,而且,仅仅出于管理的原因而为换页内存池中的空闲页面分配物理页面,并不符合换页内存池的设计意图。换页内存池有两个,一个是系统全局范围的,另一个是会话空间中的。它们在系统地址空间中的起始地址分别对应于全局变量MmPagedPoolStart 和MiSessionPoolStart ,参见上一节中的介绍。换页内存池有一个数据结构来描述其页面分配状态,定义如下:
typedef struct _MM_PAGED_POOL_INFO {
    PRTL_BITMAP PagedPoolAllocationMap;
    PRTL_BITMAP EndOfPagedPoolBitmap;
    PMMPTE FirstPteForPagedPool;
    PMMPTE LastPteForPagedPool;
    PMMPTE NextPdeForPagedPoolExpansion;
    ULONG PagedPoolHint;
    SIZE_T PagedPoolCommit;
    SIZE_T AllocatedPagedPool;
} MM_PAGED_POOL_INFO, *PMM_PAGED_POOL_INFO; 

其中,系统全局换页内存池由全局变量MmPagedPoolInfo 来定义;会话换页内存池则通过会话空间中的成员变量来定义,即全局变量MmSessionSpace中的PagedPoolInfo 成员。

换页内存池中的页面是通过位图来管理的,一个页面的分配与否,是由该页面所对应的位来表达的。在 MM_PAGED_POOL_INFO结构中,PagedPoolAllocationMap 域是基本的位图,用于指明每一个页面的分配状态;EndOfPagedPoolBitmap 域也是一个位图,标明了每个页面是否为一次内存申请的最后一个页面。FirstPteForPagedPool 和LastPteForPagedPool 域规定了内存池的地址范围。NextPdeForPagedPoolExpansion域定义了换页内存池的下次扩展位置。PagedPoolCommit 域记录了换页内存池中有多少个页面已经分到了内存(已提交,未必对应有物理页面),而 AllocatedPagedPool域记录了换页内存池已分配了多少页面。PagedPoolHint域指示了在分配页面时的起始搜索位置。

上一节曾经提到,MmInitSystem调用MiBuildPagedPool 函数来建立起系统的换页内存池。MiBuildPagedPool 函数(见 base\ntos\mm\mminit.c 中3 300~3 786行代码)所做的事情,本质上来讲是填充全局变量MmPagedPoolInfo 中的各个成员,完成系统换页内存池的初始化。
 
全局变量MmSizeOfPagedPoolInBytes 和MmSizeOfPagedPoolInPages 记录了系统换页内存池的大小,最大不超过系统地址空间中划给换页内存池的范围,即MmNonPagedSystemStart-MmPagedPoolStart ,参见图4. 14。MmPagedPoolStart 和MmPagedPoolEnd分别记录了换页池的起始地址和结束地址。因此,MmPagedPoolInfo结构中的FirstPteForPagedPool 和LastPteForPagedPool 域可根据MmPagedPoolStart 和MmPagedPoolEnd的值直接导出,见mminit.c文件的3 592~3 594行代码。MiBuildPagedPool函数分配好换页内存池地址范围的第一个页表,即起始地址的页目录项(PDE )所指的页表,MmPagedPoolInfo的NextPdeForPagedPoolExpansion域值为接下来的页表地址,即换页内存池地址范围的第二个页表的虚拟地址。然后调用MiCreateBitMap 函数创建一个位图,位图的大小等于页面的数量,位图本身的内存是从非换页内存池中分配的。由于第一个页表中的PTE 是有效的,所以位图中最前面的1 024 位(一个页表所能容纳PTE 的数量)清零,其余的位被置位,这表明前1 024 个页面是空闲的。这样,MmPagedPoolInfo的PagedPoolAllocationMap 域已经初始化;结束页面位图,即 EndOfPagedPoolBitmap 成员,也依次创建,所有的位全清零,因为尚未任何有效的页面分配。

然后MiBuildPagedPool 调用InitializePool 函数来初始化执行体换页内存池,参见本节后文关于执行体内存池的介绍。最后,MiBuildPagedPool 函数设置预警阈值MiLowPagedPoolThreshold和MiHighPagedPoolThreshold,以便在适当时候通知系统换页内存池的使用情况。

在MiBuildPagedPool 函数的代码中,我们可以看到,如果系统禁止执行体内存池换页,即全局变量MmDisablePagingExecutive 中的MM_PAGED_POOL_LOCKED_DOWN标志已置上,则换页内存池实际上变成了非换页内存池,在这种情况下,内存池所需要的页面都必须在该函数中分配好。

介绍了MiBuildPagedPool 函数的逻辑以后,我们来看换页内存池的页面分配和回收。如同非换页内存池一样,这两项基本任务也是由MiAllocatePoolPages 和MiFreePoolPages函数来完成的;但是,在这两个函数中,针对换页池和非换页池的实现代码基本上是完全分开的。MiAllocatePoolPages 函数的PoolType 参数指定了在哪个内存池中申请页面;而MiFreePoolPages函数则直接根据待回收页面的起始地址来确定内存池的类型。

我们先来解释 MiAllocatePoolPages 函数中的页面分配过程,其代码逻辑从base\ntos\mm\allocpag.c 文件的1 834 行开始。首先根据 PoolType 参数找到内存池MM_PAGED_POOL_INFO 结构,置于局部变量PagedPoolInfo 中。只有两种可能,要么是系统换页内存池,即全局变量MmPagedPoolInfo,要么是会话空间的换页内存池,即MmSessionSpace->PagedPoolInfo 。由于换页内存池采用位图来管理页面的分配与否,所以,MiAllocatePoolPages 函数只是简单地调用辅助函数RtlFindClearBitsAndSet ,在PagedPoolInfo->PagedPoolAllocationMap 位图中搜索指定数量的连续零位,即换页内存池中对应的连续空闲页面。如果未能找到这么多连续零位,则设法扩展换页内存池,即,使内存池的NextPdeForPagedPoolExpansion成员往后移,当然不能越过换页内存池结束地址的PDE 。所谓扩展一个换页内存池,是指为它后面的内存区分配页表,并非分配真正的物理页面。换页内存池经过扩展以后,再调用RtlFindClearBitsAndSet 函数搜索满足要求的连续零位。


非换页内存池的页面分配,其起始和结束页面直接被标记在PFN 数据库的对应PFN项中,对于换页内存池,这种做法行不通,因为换页内存池中的页面可能会被换出到外存中,所以根本就不存在对应的 PFN 项。因此,为了记录一次页面分配的起始和结束位置,MM_PAGED_POOL_INFO包含一个并行的位图,专门记录每次页面分配的结束页面。结合这样两个位图,当回收页面时,只需给出起始地址,就可以验证该地址的正确性,如果此地址前面的那个页面尚未被分配,则此地址可以认为是一次页面分配的起始处;如果前面的那个页面已被分配,而且结束页面位图并未表明它是一次页面分配的结束页面,则参数StartingAddress 中给出的起始地址是错误的,碰到这种情况,系统会崩溃(bugcheck )。

接下来,除了在结束页面位图中设置好相应的位(见代码 2 269 行)以外,MiAllocatePoolPages 函数还要更新换页内存池结构PagedPoolInfo 中的已分配页面数量,对应于MM_PAGED_POOL_INFO结构的AllocatedPagedPool域。如果内存池的空闲页面数量小于特定阈值(全局变量MiLowPagedPoolThreshold),则发出预警信号(系统全局事件MiLowPagedPoolEvent)。然后检查这些页面是否需要刷新它们的快表(TLB )项,若是,则通过KeFlushSingleTb、KeFlushMultipleTb 或KeFlushEntireTb 函数刷新其快表项。最后,正确地填好已分配页面的PTE ,返回这组连续页面的起始地址。

系统换页内存池的页面回收过程非常直截了当,在 MiFreePoolPages函数中,如果传进来的起始地址StartingAddress 介于MmPagedPoolStart 和MmPagedPoolEnd之间,则说明这是系统换页内存池中的页面。页面回收逻辑从allocpag.c的3 100 行开始,首先利用PagedPoolInfo 中的两个位图来验证起始地址的有效性。通过验证以后,MiFreePoolPages修改这两个位图,使得这一段内存保持未分配的状态,同时也维护好PagedPoolInfo 中的AllocatedPagedPool和PagedPoolCommit 域。

关于MiFreePoolPages函数回收系统换页内存池中的页面,有几点需要说明:

(1)  如果换页内存池被配置成不可换页的(non-pageable),则只需维护位图和相应的全局变量与内存池变量即可,见代码3 182~3 230行。 (2)  如果是单个页面被释放,则有可能加入到一个单链表中,见代码 3 170~3 175行。

此单链表是由全局变量MiPagedPoolSListHead 来记录的。链表中最多存放8 个单页面。因此,如果客户下次申请单个页面,则MiAllocatePoolPages 函数直接从该链表中提取页面即可,参见代码1 862~1 870行。

(3)  系统换页内存池中的内存也遵从换页空间(即系统页面文件中的空间)的管理,因为一旦内存紧缺,则这些页面需要被换到外存中,从而占用外存空间。这是换页内存池与非换页内存池的一个关键区别。我们可以看到,在MiAllocatePoolPages 函数中调用了MiChargeCommitmentCantExpand 函数;而在 MiFreePoolPages函数中,则调用了MiReturnCommitment(实际上,这是一个 C 语言宏)。而且,在MiFreePoolPages函数中,它还调用了MiDeleteSystemPageableVm 函数,以删除可换页的系统地址范围。

关于换页内存池使用的两个位图,这里有必要说明一下位的含义。对于PagedPoolAllocationMap 中的位,置位表明所对应的页面是不可用的,零位表明是可用的。系统换页内存池初始时,第一个 PDE 所指的页表已分配,所以该页表中的 PTE 所对应的页面(1 024 个)已经可以使用了。因而,在位图中,前1 024 位为零位,其余皆为置位状态。随着换页内存池被扩展,即 NextPdeForPagedPoolExpansion不断后移,零位出现的位置也开始后移。但是,NextPdeForPagedPoolExpansion后面对应的位一定是置位的。因此,对于 PagedPoolAllocationMap 中的位,零位表示空闲页面,处于待分配状态;置位表示可能已被分配,或者尚未扩展到该页面。若不考虑PDE 扩展的因素,概括起来可以这样说,MiAllocatePoolPages 做的事情是把连续的零位变成置位;而 MiFreePoolPages函数做的事情是把连续的置位变成零位。EndOfPagedPoolBitmap 位图初始时,所有的位皆为零位。每次分配页面时,最后一个分配的页面所对应的位被置位;MiFreePoolPages函数依据此位图来判断传进来的起始地址的合法性,并且把从该地址开始的已分配连续页面中的最后一个页面所对应的位清零。

(三)  执行体内存池的管理算法

上面介绍的换页内存池和非换页内存池是Windows 操作系统提供的基本动态内存管理手段,它以页面为基本粒度来管理系统中划定的地址范围。显然,页面粒度对于一般的代码逻辑而言太大了,比如,第2 章介绍的对象管理器往往在更小的粒度上工作。所以,为了适应各种内核组件对于内存管理的需要,Windows 内核在系统的非换页内存池和换页内存池的基础上,实现了灵活的、可适应各种大小内存需求的内存池,这便是执行体内存池,其实现代码位于base\ntos\ex\pool.c 文件中。

执行体内存池对象是由数据结构POOL_DESCRIPTOR来描述的,以下是它的定义,位于base\ntos\inc\pool.h文件中。
typedef struct _POOL_DESCRIPTOR {
    POOL_TYPE PoolType;
    ULONG PoolIndex;
    ULONG RunningAllocs;
    ULONG RunningDeAllocs;
    ULONG TotalPages;
    ULONG TotalBigPages;
    ULONG Threshold;
    PVOID LockAddress;
    PVOID PendingFrees;
    LONG PendingFreeDepth;
    SIZE_T TotalBytes;
    SIZE_T Spare0;
    LIST_ENTRY ListHeads[POOL_LIST_HEADS];
} POOL_DESCRIPTOR, *PPOOL_DESCRIPTOR;

此外,有一组全局变量与执行体内存池有关,其定义和声明分别位于 pool.c 和pool.h中,本章我们仅着眼于内存管理,而忽略与同步有关的逻辑,所以下面列出的全局变量中不包含与同步有关的对象:
#if defined(NT_UP)
#define NUMBER_OF_PAGED_POOLS 2
#else
#define NUMBER_OF_PAGED_POOLS 4
#endif 
 
ULONG ExpNumberOfPagedPools = NUMBER_OF_PAGED_POOLS;
 
ULONG ExpNumberOfNonPagedPools = 1;
 
POOL_DESCRIPTOR NonPagedPoolDescriptor;
 
#define EXP_MAXIMUM_POOL_NODES 16
 
PPOOL_DESCRIPTOR ExpNonPagedPoolDescriptor[EXP_MAXIMUM_POOL_NODES];
 
#define NUMBER_OF_POOLS 2
PPOOL_DESCRIPTOR PoolVector[NUMBER_OF_POOLS];
 
PPOOL_DESCRIPTOR ExpPagedPoolDescriptor[EXP_MAXIMUM_POOL_NODES + 1];
 
volatile ULONG ExpPoolIndex = 1;

根据以上定义的全局变量以及它们的赋值情况,我们可以知道,ExpNumberOf- NonPagedPools 代表了非换页内存池的数量,只有一个;而换页内存池的数量,即变量ExpNumberOfPagedPools ,在单处理器系统上是三个,在多处理器系统上有五个(注意,数组的元素个数是EXP_MAXIMUM_POOL_NODES+1)。PoolVector 是一个包含两个数组的元素,第一个元素 PoolVector[0] 指向非换页内存池,即 NonPagedPoolDescriptor的地址;第二个元素 PoolVector[1] 指向换页内存池,即指向第一个换页内存池。数组ExpNonPagedPoolDescriptor 仅用于有多个非换页内存池的情形;数组ExpPagedPool- Descriptor中的每个元素分别指向一个换页内存池。

执行体内存池对象的初始化也是按换页内存池和非换页内存池分开进行的。MiInitMachineDependent 函数在执行了系统非换页内存池的初始化(即调用MiInitializeNonPagedPool 函数)并完成了PFN 数据库的初始化以后,还调用了InitializePool(NonPagedPool, 0) ,这正是初始化执行体非换页内存池对象。另外,在MiBuildPagedPool 函数中,当系统换页内存池的初始化基本完成以后,该函数直接调用InitializePool(PagedPool, 0L) ,以完成执行体换页内存池的初始化。

InitializePool 函数由两个相对独立的部分构成,前面部分执行非换页内存池的初始化,后面部分执行换页内存池的初始化。它所做的事情是,初始化以上这些全局变量,并且为每个执行体内存池对象调用ExInitializePoolDescriptor 函数,填充POOL_DESCRIPTOR结构中的域。从 InitializePool 函数中可以看到,执行体的非换页内存池的POOL_ DESCRIPTOR对象是全局变量NonPagedPoolDescriptor,而执行体的换页内存池的 POOL_ DESCRIPTOR对象则是从执行体的非换页内存池中分配的。这是一个合理的内存分配顺序。当 InitializePool 函数被调用来初始化执行体换页内存池时,执行体的非换页内存池已经被初始化,因而InitializePool 可以向它申请内存了。

InitializePool 和ExInitializePoolDescriptor 函数的细节可以参看 base\ntos\ex\pool.c 文件中的893~1 352、659~756 行,这里不再进一步解释。在POOL_DESCRIPTOR 结构中,PoolType 域为枚举类型 POOL_TYPE 的NonPagedPool 或PagedPool,分别代表执行体非换页内存池和换页内存池。尽管在POOL_TYPE 中还有其他的内存池类型,这里我们只考虑NonPagedPool 和PagedPool两种类型。PoolIndex域是指当前对象在该类型的内存池数组中的索引,对于换页内存池,PoolIndex是它在ExpPagedPoolDescriptor数组中的下标值。ListHeads数组是一个关键数据成员,执行体内存池对于小内存的分配和回收,是通过一组空闲内存块链表来实现的。接下来我们通过解释 ExAllocatePoolWithTag/ ExFreePoolWithTag函数来介绍执行体内存池的管理算法。

在Windows NT的早期,执行体内存池的管理是通过伙伴系统算法(参考4.1.3节)来实现的。在 WRK的执行体内存池实现代码中,我们可以看到,它采用的并非伙伴系统算法,而是用一组称为快查表(lookaside list )的空闲内存块链表分别记录了各种大小的待分配内存。当一个内存池需要更多内存时,它可以向对应的系统内存池申请更多的页面。而当内存释放时,则让待释放的内存与相邻的内存块尽可能地合并,以形成更大的空闲内存块,如果构成一个完整的页面,则将页面交还给系统内存池。对于需要一个页面或以上的内存申请,执行体内存池直接向系统内存池申请足够的页面并交给客户;对于小于一个页面大小的内存申请,执行体内存池使用快查表来管理小粒度的内存块。

现在我们来看ExAllocatePoolWithTag 函数,其代码位于 base\ntos\ex\pool.c 的1 738~ 2 677行。ExAllocatePoolWithTag 函数带三个参数:PoolType 参数指明了要从哪种内存池中分配内存,NumberOfBytes 表示要申请多少字节的内存,Tag 是一个32位整数。根据PoolType 以及前面列出的 PoolVector 全局变量,ExAllocatePoolWithTag 可以确定目标内存池对象,即局部变量PoolDesc 的初始值,见代码1 885~1 890行。

POOL_DESCRIPTOR对象中维护了一组快查表,即它的 ListHeads成员。这些快查表包含了8 字节倍数大小的空闲内存块链表。通过执行体内存池分配的内存块有固定大小的头,即它们的管理开销,下面是一些相关的定义:
typedef struct _POOL_HEADER {
    union {
        struct {
            USHORT PreviousSize : 9;
            USHORT PoolIndex : 7;
            USHORT BlockSize : 9;
            USHORT PoolType : 7;
        };
        ULONG Ulong1;
    };
 
    union {
        ULONG PoolTag;
        struct {
            USHORT AllocatorBackTraceIndex;
            USHORT PoolTagHash;
        };
    };
} POOL_HEADER, *PPOOL_HEADER;
 
#define POOL_PAGE_SIZE  0x1000 
#define POOL_BLOCK_SHIFT 3 
 
#define POOL_OVERHEAD ((LONG)sizeof(POOL_HEADER))
 
#define POOL_FREE_BLOCK_OVERHEAD  (POOL_OVERHEAD + sizeof (LIST_ENTRY))
 
typedef struct _POOL_BLOCK {
    UCHAR Fill[1 << POOL_BLOCK_SHIFT];
} POOL_BLOCK, *PPOOL_BLOCK;
 
#define POOL_SMALLEST_BLOCK (sizeof(POOL_BLOCK))
 
#define POOL_BUDDY_MAX  \
   (POOL_PAGE_SIZE - (POOL_OVERHEAD + POOL_SMALLEST_BLOCK )) 
 
#define POOL_LIST_HEADS (POOL_PAGE_SIZE / (1 << POOL_BLOCK_SHIFT))

因此,每一次内存分配还必须把 POOL_HEADER部分也算上。执行体内存池的基本管理算法是这样的:如果一次内存分配的大小超过了 POOL_BUDDY_MAX,则执行体内存池直接调用底层的系统内存池来申请足够的页面,并直接交给客户,在这种情况下,客户拿到的内存地址是页面对齐的,也就是说,内存块地址的最后12 位为0 ;否则,ExAllocatePoolWithTag 函数检查内存池内部的快查表,以期找到合适大小的空闲内存块,若未能找到满足条件的内存块,则向系统内存池申请一个新的页面,把页面的一部分返回给客户,剩下的加入到适当大小的快查表中。

最小的内存块是 8 个字节,POOL_HEADER 的大小也是8 个字节,所以,POOL_BUDDY_MAX 的值是4 096-16=4 080,而POOL_LIST_HEADS 的值是512 。POOL_DESCRIPTOR中的快查表包含512 项(实际使用510 项),分别对应于8、16、24……4 072和4 080大小的空闲内存块。若客户申请大于4 080而小于4 096的内存块,则直接使用整个页面,因为剩下的空间不足以容纳最小内存块(8 字节)加上管理开销(8字节)。图4. 17显示了执行体内存池的管理结构。


 

POOL_HEADER中的第一个C 联合成员中的各个域的含义如下:PreviousSize记录了当前内存块前面的那个内存块的大小,依据此域可以找到同一个页面中前面的内存块;BlockSize记录了当前内存块的大小,依据此域可以找到同一个页面中后面的内存块;PoolIndex说明了当前内存块属于哪个执行体内存池;PoolType 成员记录了当前内存块所在的内存池的类型,对于空闲内存块,PoolType 成员为 0。这里 PreviousSize和BlockSize都是指实际大小除以 8 之后得到的值,并非原值本身。POOL_HEADER的PoolTag 域记录了当前被分配内存块的一个标记。图4.18 显示了一个受执行体内存池管理的页面的内部结构。


 

我们回到ExAllocatePoolWithTag 函数的代码上。如果NumberOfBytes 参数大于POOL_BUDDY_MAX ,则直接调用系统内存池的内存分配函数,即MiAllocatePoolPages函数,来完成内存分配,参见pool.c 文件的1 907~2 014行代码。否则需要使用内存池对象中的快查表。首先根据NumberOfBytes 参数计算得到快查表在ListHeads数组中的索引,即ListNumber 局部变量,见代码2 042 行。

接下来确定要在哪个内存池对象中分配内存,即确定 POOL_DESCRIPTOR对象,见pool.c 文件的2 046~2 353行代码。对于换页池,在1 至ExpNumberOfPagedPools 之间找到一个尚未被锁住的换页池,从这里也可以看出,Windows 使用多个换页内存池可以增加并发性,允许多个线程甚至多个处理器同时使用不同的换页内存池。对于非换页池,只有一个,则直接使用全局的非换页内存池,此种情形下,局部变量PoolDesc 在1 886行已经被赋以正确的值。

确定了POOL_DESCRIPTOR对象PoolDesc 和快查表数组的下标ListNumber 以后,接下来就可以搜索满足条件的空闲内存块。2 368~2 523行之间的do循环正是完成这一任务。从ListNumber 指定的快查表开始,顺次检查链表头:只要当前链表不空,就能找到满足条件的内存块;如果当前链表为空,则继续找大一号的链表。若找到的内存块大于所要求的内存大小,则把它分成两块,一块作为结果返回给客户,另一块加到适当大小的快查表中。POOL_HEADER结构的PreviousSize如果为0,则说明它是页面的开始处,在这种情况下,取前面部分作为结果内存块(记录在 Entry变量中),后面部分稍后要归还到快查表中(记录在 SplitEntry变量中)。如果POOL_HEADER结构的PreviousSize不为0,则说明这不是页面的起始内存块,在这种情况下取后面部分作为结果内存块返回给客户(记录在Entry变量中),前面部分稍后要归还到快查表中(记录在 SplitEntry变量中)。经过如此切分以后,如果SplitEntry内存块的大小足够容纳一个 POOL_HEADER加上双链表的两个指针域(实际上应理解为 POOL_SMALLEST_BLOCK ),则将 SplitEntry 插入到适当的快查表中。在do 循环体中,不管是否要切分一个内存块,Entry总是指向要返回的内存块,设置其中的 PoolTag,并维护好内存池 POOL_DESCRIPTOR 中的相关成员,擦除内存块中用于快查表的链表指针信息,最后返回内存块中的有效数据区地址。

如果在do循环完成以后,仍然未能找到有效的空闲内存块,则说明内存池中没有大于等于所要求大小的内存块,在这种情况下,有必要向系统内存池申请一个新的页面,并且把新页面的前面部分作为结果内存块返回给客户,后面部分构造一个新的空闲内存块插入到适当的快查表中。申请新页面是通过调用MiAllocatePoolPages函数来完成的,见pool.c文件的2 532 行代码;在新页面中构造内存块结构并切分此内存块的代码见2 587~2 602行;最后,把切分后剩下的空闲内存块加入到快查表中并返回结果内存块的代码见2 628~ 2 676行,此部分代码的逻辑与do循环中的代码类似。

现在转到ExFreePoolWithTag函数,该函数位于base\ntos\ex\pool.c 文件的4 296~5 024行。它的第一个参数 P 指定了要释放的内存块地址,第二个参数 TagToFree指定了被释放的内存块的标记。代码行4 363~4 503 是一些检查和日志记录逻辑。代码行4 510~4 600 针对那些直接通过系统内存池申请到的内存块的释放,即在ExAllocatePoolWithTag 函数中针对大于POOL_BUDDY_MAX 的内存请求而直接调用MiAllocatePoolPages 函数得到的内存块。对应地,内存的释放通过MiFreePoolPages函数来完成。

接下来,ExFreePoolWithTag函数根据参数P ,定位到相应的 POOL_HEADER,置于局部变量Entry 中,并检查其中的成员信息,进一步定位到它后面的内存块的POOL_HEADER,检查后面内存块的PreviousSize是否与当前内存块的 BlockSize相等,这算是对地址P 的有效性检查。所以,如果ExFreePoolWithTag函数接到一个不正确的地址P,除非真的是特意构造的,否则它可以有很大的概率能检测到系统中内存分配与释放的不一致性。经过有效性检查以后,ExFreePoolWithTag函数根据内存块中的PoolType 和PoolIndex信息,找到内存池描述符对象,放在局部变量PoolDesc 中。

然后,ExFreePoolWithTag函数进行系统处理,其核心的思想与我们在系统换页内存池和非换页内存池中看到的相仿。对于小内存块的释放,每个处理器有两个缓存链表,位于KPRCB结构的PPPagedLookasideList 和PPNPagedLookasideList成员中,分别对应于换页内存池和非换页内存池的缓存链表。阈值POOL_SMALL_LISTS 指明了只有小于等于32×8 大小的内存块才使用缓存链表。因此,在ExAllocatePoolWithTag 函数中,当申请一个小内存块时,如果在当前处理器的缓存链表中能找到对应大小的内存块,则直接从缓存链表中摘除一个内存块,快速地返回给客户。对应地,在ExFreePoolWithTag函数中,如果当前处理器的缓存链表尚未达到预定的深度,则将待释放的内存块插入到相应的缓存链表中。Pool.c 文件中的4 725~4 844代码行正是在做这样的工作。ExAllocatePoolWithTag函数中也有相应的逻辑,这里不再讨论。

接下来,从4 880 行代码开始,ExFreePoolWithTag 函数考虑将当前内存块与后面的空闲内存块或者前面的空闲内存块合并在一起,构成更大的空闲内存块,从而减少内存池中的小碎片。4 900~4 929行代码完成与后面的空闲内存块的合并,4 935~4 965行代码完成与前面的空闲内存块的合并。当然,合并的前提条件是,当前待释放的内存块前面或后面的内存块恰好也是空闲的,这可以依据内存块的POOL_HEADER中的PoolType 的值是否为0 来确定。如果合并以后的内存块是一个完整的页面,则将空闲页面归还给系统内存池,这也是通过调用MiFreePoolPages函数来完成的,见代码4 972~4 982行;否则,将新的内存块(可能已经合并了其相邻的内存块)插入到适当的快查表中,见代码4 989~ 5 022行。至此,ExFreePoolWithTag函数完成了参数 P 所指内存块的回收工作。

在base\ntos\ex\pool.c 文件中,我们还可以看到其他一些分配和释放内存的函数,就本质上讲,执行体内存池是通过 ExAllocatePoolWithTag 和ExFreePoolWithTag来工作的,而且它们也是在其他内核模块,甚至设备驱动程序中常常被调用的内存管理函数。ExFreePoolWithTag函数并没有检查内存块的标记,即TagToFree没有实际的用途。内存管理是与系统模块的稳定性息息相关的事项,Windows 除了提供高效的、低碎片的内存管理功能以外,还提供了其他一些用于检测设备驱动程序内存错误的设施,其中包括特殊内存池和内存池跟踪机制。我们在 ExAllocatePoolWithTag 和ExFreePoolWithTag函数中可以看到这两种机制的身影。当全局的池标记变量 ExpPoolFlags 中的特殊内存池标记(EX_ SPECIAL_POOL_ENABLED)打开时,内存分配可能会转到 MmAllocateSpecialPool函数中。
 
特殊内存池机制对于每次内存分配,都需要额外的内存,基本的原理是,用无效页面将内存块分隔开,因此,一旦代码引用到内存块的前面或者后面,将会导致一个内核模式的访问违例,从而使系统崩溃,允许捕捉到有错误的设备驱动程序。另外,特殊内存池机制也允许在驱动程序分配或者释放内存时执行一些额外的检查。

内存池跟踪机制是指根据内存申请和释放时内存块的Tag,来记录相同Tag 的内存使用情况。我们在 ExAllocatePoolWithTag 函数中,看到它调用了 ExpInsertPoolTrackerInline函数,而在ExFreePoolWithTag 函数中,它调用了ExpRemovePoolTrackerInline 函数。Windows 的内存管理器维护了一张Tag 表,其中记录了每种 Tag 的内存使用情况。因此,如果系统的内存池跟踪功能打开的话,当一个驱动程序卸载时,内存管理器可以检查它申请的内存是否已经全部释放,如果还没有全部释放,它可以让系统崩溃,并指明该驱动程序有内存错误。ExpInsertPoolTrackerInline 和ExpRemovePoolTrackerInline 的代码也在base\ntos\ex\pool.c 文件中,请读者自行查看,这里不再解释。

点击复制链接 与好友分享!回本站首页
分享到: 更多
您对本文章有什么意见或着疑问吗?请到论坛讨论您的关注和建议是我们前行的参考和动力  
上一篇:4.2.1 系统地址空间初始化
下一篇:4.2.3 系统PTE 区域的管理
相关文章
图文推荐
3.4.4 进程生命期管
3.4.2 Windows应用商
3.4.1 Windows应用商
3.4 进程生命期管理
排行
热门
文章
下载
读书

关于我们 | 联系我们 | 广告服务 | 投资合作 | 版权申明 | 在线帮助 | 网站地图 | 作品发布 | Vip技术培训
版权所有: 红黑联盟--致力于做最好的IT技术学习网站