3.7 内存分配
3.7.1 常规内存分配
对于应用层编程,C/C++库提供了malloc函数以及new操作符在堆上分配内存,初学者往往搞不清楚堆与虚拟内存的关系,简单来说,堆内存是基于虚拟内存上更小粒度的分割,这个分割由堆管理器管理。根据开发者的需要,堆管理器会申请一页(或多页)虚拟内存,然后对这块虚拟内存进行更小粒度的内存分割与管理,以满足开发者对内存的需求。
与应用层的堆概念类似,在内核中有一种称为“池(Pool)”的概念,开发者可以从Pool中申请内存,WDK提供了一系列内存分配函数,其中最基本的是ExAllocatePoolWithTag,函数原型如下:
PoolType表示需要申请何种类型的内存,PoolType为POOL_TYPE枚举类型,常用的值是NonPagedPool与PagedPool;其中NonPagedPool表示非分页内存,PagedPool表示分页内存;这里重新强调一下,非分页内存是指这块内存的内容不会被置换到磁盘上,非分页内存非常宝贵,一般用于高IRQL(大于等于DISPATCH_LEVEL)的代码中。当然,非分页内存也可以用于低IRQL(小于DISPATCH_LEVEL)的代码中,但这种做法完全没有必要,对于低IRQL代码,开发者完全可以使用分页内存。开发者必须确认清楚代码所执行的IRQL,选择合适的内存类型。
除了NonPagedPool与PagedPool这两种内存,WDK还定义了其他一系列的内存类型:
从上面的定义可以看到,POOL_TYPE的枚举值较多,但很多是保留给系统使用的,除了上面介绍的NonPagedPool与PagedPool,开发者还需要关心的类型是NonPagedPoolExecute与NonPagedPoolNx。
NonPagedPool类型的内存属性为“可执行”,意味着开发者可以将这块内存写入二进制指令然后执行,这个机制虽然很灵活,但存在一定的安全隐患:对于一些存在漏洞的代码来说,攻击者可以使用“缓存区溢出攻击”技术,在目标内存(缓冲区)中写入可执行指令,由于这块内存具有“可执行”属性,所以攻击者可以成功实施攻击。
从Windows 8开始,微软建议开发者使用NonPagedPoolNx类型的内存来替换不需要“可执行”属性的非分页内存,即使用NonPagedPoolNx来替代NonPagedPool。NonPagedPoolNx类型是指分配出来的非分页内存不具备“可执行”属性。安全起见,如果开发者只需要对非分页内存进行读写而不需要进行代码或指令执行,那么可以使用NonPagedPoolNx类型的内存;反之可使用NonPagedPool或NonPagedPoolExecute。NonPagedPoolExecute类型与NonPagedPool类型等价。
ExAllocatePoolWithTag 函数的NumberOfBytes参数表示需要申请内存大小,单位是字节。
ExAllocatePoolWithTag 函数的Tag参数是一个4个字节的标志,用于标志一块内存的使用者,这个Tag一般用于问题排查,如内存泄露,系统蓝屏等。对于内存泄露的情况,可以通过Windbg或PoolMon等一些小工具,查看系统中各Tag标志对应的内存大小,找到最大的或者持续增长的内存块。
如果开发者不关心Tag标志,可以传递0,或者调用ExAllocatePool函数。
ExAllocatePoolWithTag函数成功执行后返回分配内存的首地址,失败返回NULL。开发者务必对该函数返回值进行判断。
内存使用完毕后需要释放,释放使用ExFreePoolWithTag函数,ExFreePoolWithTag函数原型如下:
其中参数P表示需要释放的内存块地址,Tag对应内存申请时的标记。如果分配内存使用的是ExAllocatePool函数,释放时请使用相应的ExFreePool函数。
内存的分配与释放在操作上比较简单,请读者自行编写代码练习。
3.7.2 旁视列表
在某些场景下,开发者需要高频率从系统Pool中申请大小固定的内存,用于保存大小固定的数据。当然,在这种场景下开发者可以使用前面介绍的ExAllocatePoolWithTag函数进行内存分配,但这种方法的效率并不高,而且高频率的内存分配容易造成“内存碎片”。
针对上述场景,为了提高性能,系统提供了一种被称为“旁视列表”的内存分配方法。“旁视列表”内存分配方式相对于ExAllocatePoolWithTag分配方式来说要稍微复杂一些。“旁视列表”的一般操作顺序是:开发者首先初始化一个“旁视列表”对象,在初始化时,需要设置“旁视列表”中内存块的大小,如开发者每次需要从“旁视列表”对象中申请128字节大小的内存,那么在初始化“旁视列表”时可以指定大小为128;接着开发者在需要使用内存的时候,直接向“旁视列表”对象申请内存,在内存使用完毕后,需要通过“旁视列表”对象来回收这些内存;最后,当不再需要使用“旁视列表”对象时将其删除。
“旁视列表”对象内部会维护内存的使用状态,一块内存使用结束后,会释放回“旁视列表”对象内,但这块内存不会马上被释放到操作系统的Pool中。如果这个时候开发者向“旁视列表”对象申请内存,“旁视列表”对象会把刚才回收的内存块返回给申请者。关于这点,读者可以从下面示例代码的输出日志中确认。通过这种类似“缓存”的机制,“旁视列表”对象对内存进行了二次管理,减少了向系统Pool申请或释放的次数,提高了性能。
根据内存的类型,“旁视列表”可分为分页与非分页两种类型,虽然这两种类型的API不同,但其用法以及参数近乎一致,由于篇幅限制,下面只介绍非分页类型的“旁视列表”操作。
首先,开发者需要初始化一个“旁视列表”对象,初始化通过ExInitializeNPagedLookasideList函数实现,原型如下:
参数Lookaside表示被初始化的“旁视列表”对象的指针,在64位系统下,这个指针必须以16字节对齐。ExInitializeNPagedLookasideList执行后,Lookaside会被初始化。
参数Allocate是一个函数指针,当开发者需要从已初始化的“旁视列表”对象分配内存时,系统会调用开发者设置的Allocate函数,Allocate函数的原型如下:
读者可以发现,Allocate函数原型与ExAllocatePoolWithTag函数原型一致,开发者可以自己实现一个Allocate函数,或者设置Allocate参数为NULL,如果设置为NULL,系统则使用默认的内存分配函数。这里需要提醒一下,如果开发者对内存分配没有特殊要求,则应该设置Allocate参数为NULL。
参数Free也是一个函数指针,与Allocate数指针的操作相反。当开发者删除从“旁视列表”对象中申请出来的内存块时,系统就会调用Free参数指向的函数。Free函数原型如下:
如果开发者没有特殊的释放要求,可以把Free参数设置为NULL,在这种情况下,系统使用默认的释放函数。
参数Flags控制“旁视列表”对象的内存分配行为,这个参数只有在Windows 8以及后续系统中才有意义。可以设置的值有:
POOL_NX_ALLOCATION:表示分配的非分页内存的属性为“不可执行”,类似上一节介绍的NonPagedPoolNx标志。
POOL_RAISE_IF_ALLOCATION_FAILURE:表示如果内存失败,将抛出一个异常。
如果没有特殊要求,可以把Flags参数设置为0。
Size参数表示每次从“旁视列表”对象中申请内存的固定大小,单位是字节,这个值不能小于LOOKASIDE_MINIMUM_BLOCK_SIZE,LOOKASIDE_MINIMUM_BLOCK_SIZE是WDK定义的一个宏,定义如下:
其中,RTL_SIZEOF_THROUGH_FIELD定义如下:
从上面的定义来看,RTL_SIZEOF_THROUGH_FIELD宏计算的是type结构体中field成员距离结构体首地址的偏移大小,加上field成员本身的大小。对于LOOKASIDE_MINIMUM_ BLOCK_SIZE宏来说,计算的是Next成员与SLIST_ENTRY首地址的距离加上Next成员自身的大小,SLIST_ENTRY定义如下:
由上面的定义可知,Next与SLIST_ENTRY首地址的距离为0,Next为一个指针,对于64位系统来说,大小为8字节,所以在64位系统下,LOOKASIDE_MINIMUM_BLOCK_SIZE宏的值为8;
Tag参数表示分配内存时所使用的标记,与ExAllocatePoolWithTag函数中的Tag参数函数一样。
Depth参数是一个保留参数,没有意义,传递0即可;
“旁视列表”对象初始化后,开发者就可以从这个对象中申请内存,申请内存的函数为ExAllocateFromNPagedLookasideList,该函数的原型非常简单:
开发者只需把“旁视列表”对象的地址传入到上述函数,即可分配内存,内存大小为ExInitializeNPagedLookasideList函数所指定的Size。如果ExAllocateFromNPagedLookasideList函数执行成功则返回相应的内存块,否则返回NULL。
ExAllocateFromNPagedLookasideList函数返回的内存块,需要使用ExFreeToNPagedLookasideList函数对其进行释放。ExFreeToNPagedLookasideList函数的原型如下:
其中Lookaside为“旁视列表”对象指针,Entry指针表示需要释放的内存块,也就是ExAllocateFromNPagedLookasideList的返回值。
最后为读者介绍“旁视列表”的删除操作,当一个“旁视列表”不再需要使用时,可以调用ExDeleteNPagedLookasideList函数来对其进行删除,ExDeleteNPagedLookasideList函数的原型如下:
参数Lookaside表示需要删除的“旁视列表”对象。
上述是对非分页类型“旁视列表”操作的介绍,分页类型的“旁视列表”操作与此操作高度雷同,请读者自行查阅WDK帮助文档。
另外,Vista及以后的系统引入了一套Ex版本的“旁视列表”操作函数,该函数为ExInitializeLookasideListEx,原型如下:
从原型上看,这个函数支持分页内存类型与非分页内存类型的“旁视列表”,不再需要使用两套不同的API。该API的用法与上面介绍的非分页“旁视列表”操作非常类似,读者可以自行阅读WDK文档学习。
针对上面介绍的非分页“旁视列表”,下面给出了一个使用例子:
打印的结果为:
读者可以发现,pFirstMemory最开始的值为FFFFD18979B02EC0,使用ExAllocateFrom NPagedLookasideList函数对该内存进行删除,重新申请内存后,pFirstMemory的值还是FFFFD18979B02EC0。