内存管理
这一节我们讨论内存管理。Windows 2000采用多种方式分割虚拟地址空间。一种方式是基于安全性和完整性,有两种地址:用户模式地址和内核模式地址。另一种方式基于处理器的分页能力,有两种内存:分页内存和非分页内存。全部用户模式地址和某些内核模式地址使用分页内存,内存管理器可以在分页内存页帧和磁盘扇区间交换内容,而另一些内核模式地址总是引用物理内存中固定的页帧。由于Windows 2000允许驱动程序的某些部分存在于分页内存中,所以我将阐述如何在编译时和运行时控制驱动程序的分页属性。
Windows 2000提供了多种内存管理方法。我将描述其中的两个基本服务函数,ExAllocatePool和ExFreePool,你可以用它们从堆上分配和释放任意大小的内存块。我还将描述把内存块组织成结构链表的原语。最后,我将描述lookaside(后援式)链表的概念,它可以使你高效率地分配和释放相同大小的内存块。
用户模式地址空间与内核模式地址空间
Windows NT和Windows 98都是运行在支持虚拟地址空间的计算机上,虚拟地址空间或者映射到一段真实的物理内存,或者映射到交换文件中的页帧。为了简化问题,可以认为虚拟地址空间由两部分组成:内核模式部分和用户模式部分。见图3-7。
图3-7. 地址空间中用户模式部分和内核模式部分
每个用户模式进程都有自己的地址上下文,它把用户模式的虚拟地址映射成一组唯一的物理页帧。这意味着,当Windows NT调度器把控制从一个进程的当前线程切换到另一个进程的某个线程时,与进程相对应的虚拟地址空间也被更换。线程切换的一个步骤就是改变处理器当前使用的页表,以便它能引用新线程的进程上下文。
如果你熟悉Alpha,那么你可能不认同我的叙述。Alpha没有页表概念,但Alpha中有一个称为转换缓冲区(translation buffers)的部件,该部件把虚拟页地址映射到物理页地址。对我来说,这与PC没有什么不同,这就象说:《奥德修》是由另一个叫荷马的人写的,而不是由历史学家以前认为的那个荷马写的。
一般情况下,WDM驱动程序不太可能执行在I/O请求发起者的线程上下文中。我们之所以说“运行在任意线程上下文”是因为我们不知道当前用户模式地址上下文到底属于哪个进程。我们不能简单地使用属于用户模式中的虚拟地址,因为我们没有办法知道它指向的是哪块物理内存。由于这种不确定性,在编写驱动程序时我们要遵守下面原则:
决不(或几乎从不)直接引用用户模式的内存地址
即,不使用用户模式应用程序提供的任何地址。我将在以后章节中讨论用户模式数据缓冲区的访问技术。现在,我们所要知道的就是,无论何时我们需要访问计算机内存,都要使用内核模式的虚拟地址。
一页有多大?
在虚拟内存系统中,操作系统以固定大小的页帧组织物理内存和交换文件。在WDM驱动程序中,常量PAGE_SIZE指出页的大小。在某些Windows NT计算机中,一页有4096字节;在另一些计算机中,一页有8192字节。有一个相关常量PAGE_SHIFT,你可以从下面语句中看出它的值:
PAGE_SIZE == 1 << PAGE_SHIFT |
下面预处理宏可以简化页大小的使用:
- ROUND_TO_PAGES 把指定值舍入为下一个页边界。例如,在4KB页的计算机上,ROUND_TO_PAGES(1)的结果为4096,ROUND_TO_PAGES(4097)的结果为8192。
- BYTES_TO_PAGES 得出给定的字节量需要多少页来保存。例如,BYTES_TO_PAGES(42)在所有平台上都等于1,而BYTES_TO_PAGES(5000)在4KB页的平台上为2,在8KB页的平台上为1。
- BYTE_OFFSET 返回虚拟地址的字节偏移部分。例如,在4KB页的计算机上,BYTE_OFFSET(0x12345678)的结果为0x678。
- PAGE_ALIGN 把虚拟地址舍向上一个页边界。例如,在4KB页的计算机上,PAGE_ALIGN(0x12345678)的结果为0x12345000。
- ADDRESS_AND_SIZE_TO_SPAN_PAGES 返回从指定虚拟地址开始的指定字节数所跨过的页数。例如,在4KB的计算机上, ADDRESS_AND_SIZE_TO_SPAN_PAGES(0x12345FFF, 2)的结果为2,因为这两个字节跨过了页边界。
分页和非分页内存
虚拟内存系统的特征就是能使软件有一个比物理内存大得多的虚拟内存空间。为了做到这一点,内存管理器需要在物理内存和磁盘文件间交换页帧。但操作系统的某些部分是不能被分页的,这些内存用来支持内存管理器本身。最明显的例子就是,用于处理页故障的代码和数据结构必须常驻内存。
Windows NT把内核模式地址空间分成分页内存池和非分页内存池。(用户模式地址空间总是分页的) 必须驻留的代码和数据放在非分页池;不必常驻的代码和数据放在分页池中。Windows NT为决定代码和数据是否需要驻留非分页池提供了一个简单规则。我将在下一章详细说明这个规则,但在这里先稍提一下:
执行在高于或等于DISPATCH_LEVEL级的代码不可以引发页故障。
在驱动程序的checked版中,你可以使用PAGED_CODE预处理宏(在wdm.h中声明)来帮助发现有违背这个规则的代码。例如:
NTSTATUS DispatchPower(PDEVICE_OBJECT fdo, PIRP Irp) { PAGED_CODE() ... } |
PAGED_CODE包含条件编译语句。在checked-build方式中,如果当前IRQL太高,它就打印出一行信息并生成一个断言失败。在free-build方式中,它不做任何事。如果测试驱动程序时包含DispatchPower代码的页正好在内存中,那么你不会发现已经在一个提升的IRQL上调用了DispatchPower函数。即使这样,PAGED_CODE仍能查出问题。如果该页碰巧不在内存中,系统将产生一个bug check。
编译时控制分页能力
有时,驱动程序的某些部分必须驻留内存而另一些可以被分页,这就需要一种能控制代码和数据是否分页的方法。通过指导编译器的段分配可以实现这个目的。在运行时,装入器通过检查驱动程序中的段名把段放到你指定的内存池中。此外在运行时调用内存管理器的例程也能实现这个目的。
Win32执行文件,包括内核模式驱动程序,在内部都是由一个或多个段组合而成。段可以包含代码或数据,通常还会有诸如可读性、可写性、共享性、执行性,等等附加属性。段是指定分页能力的最小单元。当装载一个驱动程序映像时,操作系统把以“page”或“.eda(.edata)”为段名开头的段放到分页池中,除非HKLM/System/CurrentControlSet/Control/Session Manager/Memory Management中的 DisablePagingExecutive值被设置(在这种情况下,驱动程序占用的内存不被分页)。在Windows 2000中运行Soft-ICE需要用这种方式禁止内核分页。但这使得把驱动程序代码或数据误放到分页池中所造成的错误特别难以查找。如果你使用这种调试器,我推荐你最好使用PAGED_CODE宏和驱动程序检查器。
使编译器把代码放到特定段的传统方法是使用alloc_text编译指示。但不是每种编译器都支持这个编译指示,判断DDK中是否定义了ALLOC_PRAGMA可以帮助决定能否使用alloc_text编译指示。这个编译指示可以把驱动程序的单独例程放到特定段中:
#ifdef ALLOC_PRAGMA #pragma alloc_text(PAGE, AddDevice) #pragma alloc_text(PAGE, DispatchPnp) ... #endif |
上面语句把AddDevice和DispatchPnp函数的代码放到分页池中。
Microsoft的C/C++编译器在alloc_text的使用上加了两个讨厌的限制:
- 该编译指示必须跟在函数声明后面而不能在前面。你可以把驱动程序中的所有函数集中到一个头文件中,并在包含该头文件的源文件中,在#include语句的后面使用alloc_text。
- 该编译指示仅能用于有C连接形式的函数。即,它不能用于类成员函数或C++源文件中未用extern "C"声明的函数。
控制数据变量的布置需要使用另外一个编译指示:
#ifdef ALLOC_DATA_PRAGMA #pragma data_seg("PAGE") #endif |
data_seg编译指示使所有在其后声明的静态数据变量进入分页池。这个编译指示与alloc_text完全不同。一个分页段可以从#pragma data_seg("PAGE")出现的地方开始到#pragma data_seg()出现的地方结束。而Alloc_text仅应用于单个函数。
在把数据放到分页段前应该仔细考虑,因为这有可能把事情搞得更坏。最小的页单位大小为PAGE_SIZE长。仅把一点字节放到分页段可能很蠢,因为整个页的内存都会被占用。另外,一个脏页(从磁盘读取后被改变过)在其物理页帧能被重用之前需要写回硬盘。
运行时控制分页能力
表3-2列出了一些服务函数,你可以在运行时使用它们调整驱动程序的分页布局。这些函数的功能是释放被不再需要的代码和数据所占用的物理内存。在第八章中,我将讲述如何向电源管理器寄存你的设备,这样,在一段不活动时期后设备可以自动掉电。掉电期间是释放锁定内存页的最佳时期。
表3-2. 动态锁定和解锁驱动程序占用内存页的例程
服务函数 | 描述 |
---|---|
MmLockPagableCodeSection | 锁定含有给定地址的代码段 |
MmLockPagableDataSection | 锁定含有给定地址的数据段 |
MmLockPagableSectionByHandle | 用MmLockPagableCodeSection返回的句柄锁定代码段(仅用于Windows 2000) |
MmPageEntireDriver | 解锁所有属于某驱动程序的页 |
MmResetDriverPaging | 恢复整个驱动程序的编译时分页属性 |
MmUnlockPagableImageSection | 为一个锁定代码段或数据段解锁 |
下面我要描述一种用这些函数实现代码分页的方法,其它方法可参考DDK文档。首先,把驱动程序的某些例程放到单独命名的代码段,例如:
#pragma alloc_text(PAGEIDLE, DispatchRead) #pragma alloc_text(PAGEIDLE, DispatchWrite) ... |
即,定义一个以“PAGE”开头加任意四个字母做后缀为段名的段。然后用alloc_text编译指示把一些例程放到该段。你可以有任意多个专用分页段,但那会带来许多麻烦的维护问题。
在初始化期间(即,在DriverEntry),按下面方式锁定分页段:
PVOID hPageIdleSection; NTSTATUS DriverEntry(...) { hPageIdleSection = MmLockPagableCodeSection((PVOID) DispatchRead); } |
调用MmLockPagableCodeSection时,你可以指定任何一个要锁定段中的地址。在DriverEntry中调用该函数的真正目的是为了获得其返回的句柄,该句柄被保存到全局变量hPageIdleSection中。在后面,当这个段不再需要存在于内存中时会用到该句柄:
MmUnlockPagableImageSection(hPageIdleSection); |
该调用将解锁包含有PAGEIDLE段的页并允许它们按需要进出内存。之后,如果你需要它出现在内存中时,可以调用:
MmLockPagableSectionByHandle(hPageIdleSection); |
该调用完成后,PAGEIDLE段将再次进入非分页内存(可以是与上一次不同的物理内存)。注意该函数仅在windows 2000中有效,并且必须使用ntddk.h文件代替wdm.h文件。对于其它系统,只能使用MmLockPagableCodeSection函数。
把数据对象放入分页段与上面类似:
PVOID hPageDataSection; #pragma data_seg("PAGE") ULONG ulSomething; #pragma data_seg() hPageDataSection = MmLockPagableDataSection((PVOID) &ulSomething); MmUnlockPagableImageSection(hPageDataSection); MmLockPagableSectionByHandle(hPageDataSection); |
这些内存管理器服务函数后面的主要思想是:你一开始先锁定包含一个或多个页的段,并获得其句柄,而后面的调用将用到该句柄。调用MmUnlockPagableImageSection并传递同样的句柄你还可以解锁该段占用的页,再次调用MmLockPagableSectionByHandle又可以锁定该段。
如果你确信全部驱动程序都不必驻留,可以调用MmPageEntireDriver把驱动程序的所有段都变为分页式。相反,调用MmResetDriverPaging将把整个驱动程序恢复到编译时的分页属性布局。调用这些函数仅需要一个存在于驱动程序中的某个地址。例如:
MmPageEntireDriver((PVOID) DriverEntry); ... MmResetDriverPaging((PVOID) DriverEntry); |
如果设备使用中断,那么你在练习使用这些内存管理器例程时需要格外小心。有时会发生假中断,这将导致系统调用不存在的ISR,这种随机系统崩溃的原因特别难于发现。DDK推荐的规则是:不要把ISR和与其相关的DPC代码所占用的内存置成分页式。
堆分配符
内核模式中的基本堆分配函数是ExAllocatePool。调用方式如下:
PVOID p = ExAllocatePool(type, nbytes); |
type参数是表3-3中列出的POOL_TYPE枚举常量,nbytes是要分配的字节数。返回值是一个内核模式虚拟地址指针,指向已分配的内存块。如果内存不足,则返回一个NULL指针。如果指定的内存池类型为“must succeed”类型,即NonPagedPoolMustSucceed或NonPagedPoolCacheAlignedMustS,那么内存不足将导致一个代码为MUST_SUCCEED_POOL_EMPTY的bug check。
驱动程序不应该分配“must succeed”类型内存。驱动程序不应使系统在低内存状态下崩溃。另外,整个系统中仅存在有限的“must succeed”内存。实际上,Microsoft希望他们从来就没有公布过“must succeed”内存类型。
表3-3. ExAllocatePool的内存池类型参数
内存池类型 | 描述 |
---|---|
NonPagedPool | 从非分页内存池中分配内存 |
PagedPool | 从分页内存池中分配内存 |
NonPagedPoolMustSucceed | 从非分页内存池中分配内存,如果不能分配则产生bugcheck |
NonPagedPoolCacheAligned | 从非分页内存池中分配内存,并确保内存与CPU cache对齐 |
NonPagedPoolCacheAlignedMustS | 与NonPagedPoolCacheAligned类似,但如果不能分配则产生bugcheck |
PagedPoolCacheAligned | 从分页内存池中分配内存,并确保内存与CPU cache对齐 |
调用ExAllocatePool时的最基本原则是被分配内存块是否可以交换出内存。这取决于驱动程序的哪一部分需要访问这块内存。如果在大于或等于DISPATCH_LEVEL级上使用该内存块,那么必须从非分页池中分配内存。如果你总是在低于DISPATCH_LEVEL级上使用内存块,那么既可以从非分页池中分配内存也可以从分页池中分配内存。
你获得的内存块至少是按8字节边界对齐的。如果把某结构的实例放到分配的内存中,那么编译器赋予结构成员的4或8字节偏移在新内存中也将是4或8字节偏移。但在某些RISC平台上,结构成员可能以双字和四字对齐。出于性能上的考虑,希望内存块能适合处理器cache行的最少可能数,使用XxxCacheAligned类型代码可以达到这个要求。如果请求的内存多于一页,那么内存块将从页的边界开始。
释放内存块
调用ExFreePool可以释放由ExAllocatePool分配的内存块:
ExFreePool((PVOID) p); |
你确实需要记录分配的内存以便在该内存不再需要时释放它,因为没有人为你做这些事。例如,在AddDevice函数中,有一个IoRegisterDeviceInterface调用,该函数存在副作用:它分配了一块内存以保存接口名。你有责任在以后释放该内存。
不用说,访问从内核模式内存池中分配来的内存必须格外小心。因为驱动程序代码可能执行在处理器的最高特权模式下,在这里,系统对内存数据没有任何保护。
ExAllocatePoolWithTag
调用ExAllocatePool是从内核模式堆中分配内存的标准方式。另一个函数ExAllocatePoolWithTag,与ExAllocatePool稍有不同,它提供了一个有用的额外特征。当使用ExAllocatePoolWithTag时,系统在你要求的内存外又额外地多分配了4个字节的标签。这个标签占用了开始的4个字节,位于返回指针所指向地址的前面。调试时,如果你查看分配的内存块会看到这个标签,它帮助你识别有问题的内存块。例如:
PVOID p = ExAllocatePoolWithTag(PagedPool, 42, 'KNUJ'); |
在这里,我使用了一个32位整数常量作为标签值。在小结尾的计算机如x86上,组成这个标签的4个字节的顺序与正常拼写相反。
WDM.H中声明的内存分配函数受一个预处理宏POOL_TAGGING控制。WDM.H(NTDDK.H中也是)中无条件地定义了POOL_TAGGING,结果,无标签的函数实际上是宏,它真正执行的是有标签函数并加入标签‘ mdW’(指明为WDM的内存块)。如果在未来版本的DDK中没有定义POOL_TAGGING,那么带标签函数将成为无标签函数的宏。Microsoft现在还没打算改变POOL_TAGGING的设置。
由于POOL_TAGGING宏的存在,当你在程序中调用ExAllocatePool时,最终被调用的将是ExAllocatePoolWithTag。如果你关闭了该宏,自己去调用ExAllocatePool,但ExAllocatePool内部仍旧调用ExAllocatePoolWithTag并带一个‘enoN’(即None)的标签。因此你无法避免产生内存标签。所以你应该明确地调用ExAllocatePoolWithTag并加上一个你认为有意义的标签。实际上,Microsoft强烈鼓励你这样做。
ExAllocatePool的其它形式
尽管ExAllocatePoolWithTag函数是分配堆内存时应该使用的函数,但在某些特殊场合你也可以使用该函数的另外两种形式:
- ExAllocatePoolWithQuota 分配一块内存并充入当前线程的调度配额中,该函数仅用于顶层驱动程序,如文件系统驱动程序或其它运行在非任意线程上下文中的驱动程序。
- ExAllocatePoolWithQuotaTag 同上,但加入一个标签。
链表
Windows NT广泛使用链表,用链表把一些相似的数据结构组织起来。在这章中,我将讨论管理双链表和单链表的一些基本服务函数。另一些服务函数可以使多线程和多处理器共享链表;我将在下一章解释这些函数。
通常,不管你是用单链表还是用双链表组织数据,都需要把一个子结构(用于连接链表的连接元素)——或者是LIST_ENTRY,或者是SINGLE_LIST_ENTRY——嵌入到你自己的数据结构中。另外你还需要在某处保存一个链表头,它与连接元素有相同的结构。下面是一个例子:
typedef struct _TWOWAY { ... LIST_ENTRY linkfield; ... } TWOWAY, *PTWOWAY; LIST_ENTRY DoubleHead; typedef struct _ONEWAY { ... SINGLE_LIST_ENTRY linkfield; ... } ONEWAY, *PONEWAY; SINGLE_LIST_ENTRY SingleHead; |
当调用任何一个链表管理函数时,应该总是使用连接域或链表头——决不直接使用包含它的数据结构本身。所以,假设你得到了一个TWOWAY结构的指针(pdElement),为了把这个结构加入到链表中,应该象下面这样引用嵌入的连接域:
InsertTailList(&DoubleHead, &pdElement->linkfield); |
类似地,当你要从链表中提取一个元素时,真正使用的地址是嵌入连接域的地址。为了得到外层数据结构的地址,可以使用CONTAINING_RECORD宏。(见图3-8)
图3-8. CONTAINING_RECORD宏
所以,如果要丢弃一个单链表中的所有元素,应该象下面这样:
PSINGLE_LIST_ENTRY psLink = PopEntryList(&SingleHead); while (psLink) { PONEWAY psElement = (PONEWAY) CONTAINING_RECORD(psLink, ONEWAY, linkfield); ... ExFreePool(psElement); psLink = PopEntryList(&SingleHead); } |
在开始循环前和每次循环中间,我们都调用PopEntryList来获得当前链表的第一个元素。PopEntryList返回ONEWAY结构中内嵌连接域的地址,如果链表为空,则返回NULL。注意,不要不加选择地使用CONTAINING_RECORD宏,应该先检查PopEntryList返回的连接域地址是否为NULL。
双链表
双链表以两个方向把元素连接起来并形成一个环形拓扑。见图3-9。即从任何一个元素开始,以两个相反的方向前进,最终都能回到原来的地方。双链表的关键特征是可以在链表的任何地方添加或删除元素。
图3-9. 双链表的拓扑结构
表3-4列出了管理双链表的服务函数。
表3-4. 双链表服务函数
服务函数或宏 | 描述 |
---|---|
InitializeListHead | 初始化链表头中的LIST_ENTRY结构 |
InsertHeadList | 在起始处插入一个元素 |
InsertTailList | 在结尾处插入一个元素 |
IsListEmpty | 判断链表是否为空 |
RemoveEntryList | 删除元素 |
RemoveHeadList | 删除第一个元素 |
RemoveTailList | 删除最后一个元素 |
下面例子程序演示了这些函数的用法:
typedef struct _TWOWAY { ... LIST_ENTRY linkfield; ... } TWOWAY, *PTWOWAY; LIST_ENTRY DoubleHead; InitializeListHead(&DoubleHead); <--1 ASSERT(IsListEmpty(&DoubleHead)); PTWOWAY pdElement = (PTWOWAY) ExAllocatePool(PagedPool, sizeof(TWOWAY)); InsertTailList(&DoubleHead, &pdElement->linkfield); <--2 ... if (!IsListEmpty(&DoubleHead)) <--3 { PLIST_ENTRY pdLink = RemoveHeadList(&DoubleHead); <--4 pdElement = CONTAINING_RECORD(pdLink, TWOWAY, linkfield); ... ExFreePool(pdElement); } |
- InitializeListHead 初始化一个LIST_ENTRY结构并使其指向自身。这种情况代表链表为空。
- InsertTailList 把一个元素添加到链表尾部。注意,要用嵌入连接域的地址而不是TWOWAY结构的地址。调用InsertHeadList可以把新元素插入到链表头部。实际上,如果给出链表中某个TWOWAY结构的内嵌连接域地址,你可以把新元素插入该结构的前面或后面。
- 空的双链表仅有一个指向自身的链表头。而IsListEmpty就是做这样的检测。注意,RemoveXxxList的返回值永远都不会为NULL!
- RemoveHeadList 断开该元素与链表的连接,并返回该元素内嵌连接域的地址。RemoveTailList与其相反。
我们应该了解RemoveHeadList和RemoveTailList的实现细节,这样可以避免一些错误。例如,观察下面语句:
if (<some-expr>) pdLink = RemoveHeadList(&DoubleHead); |
很明显,上面语句的意图是有条件地从链表中删除第一个元素。但是,当调试这段代码时,你会发现第一个链表元素总是神秘地消失,pdLink会在if表达式为TRUE时被更改,但是RemoveHeadList看起来好象在if表达式为FALSE时也被调用。
天哪!怎么回事?实际上RemoveHeadList是一个宏,在预编译后被扩展成多个语句。下面是编译器真正看到的程序:
if (<some-expr>) pdLink = (&DoubleHead)->Flink; {{ PLIST_ENTRY _EX_Blink; PLIST_ENTRY _EX_Flink; _EX_Flink = ((&DoubleHead)->Flink)->Flink; _EX_Blink = ((&DoubleHead)->Flink)->Blink; _EX_Blink->Flink = _EX_Flink; _EX_Flink->Blink = _EX_Blink; }} |
啊!现在链表元素神秘消失的原因终于变得明了了。if语句的TRUE分支仅有pdLink = (&DoubleHead)->Flink语句,它把一个指针放到第一个元素中。而删除链表元素的语句块却跑到了if语句外面,所以这些语句总是被执行。RemoveHeadList和RemoveTailList都被翻译成一个表达式加上一个复合语句的形式,因此你不能在单表达式或单语句的地方使用它们。:-(
其它链表操作函数(宏)没有这个问题。
单链表
单链表以一个方向连接元素,如图3-10。Windows NT用单链表实现下推堆栈,表3-5列出了下推堆栈的服务函数。与双链表相同,这些“函数”在wdm.h中以宏实现。PushEntryList和PopEntryList也生成多条语句,所以你只能把它们用在等号右边。
表3-5. 单链表服务函数
服务函数或宏 | 描述 |
---|---|
PushEntryList | 向链表顶加入元素 |
PopEntryList | 删除最上面的元素 |
图3-10. 单链表的拓扑结构
下面代码演示了单链表的使用方法:
typedef struct _ONEWAY { ... SINGLE_LIST_ENTRY linkfield; } ONEWAY, *PONEWAY; SINGLE_LIST_ENTRY SingleHead; SingleHead.Next = NULL; <--1 PONEWAY psElement = (PONEWAY) ExAllocatePool(PagedPool, sizeof(ONEWAY)); PushEntryList(&SingleHead, &psElement->linkfield); <--2 SINGLE_LIST_ENTRY psLink = PopEntryList(&SingleHead); <--3 if (psLink) { psElement = CONTAINING_RECORD(psLink, ONEWAY, linkfield); ... ExFreePool(psElement); } |
- 与双链表中的表头初始化不同,单链表仅把表头的Next域置为NULL。同样,测试单链表是否为空时也仅是测试Next域本身。
- PushEntryList 把一个元素放到链表头部,链表头部是唯一可以直接访问的链表部分。同样应该仅使用内嵌连接域的地址而不是使用ONEWAY结构的地址。
- PopEntryList 断开第一个元素与链表的连接并返回该元素中的连接域地址。不同于双链表,单链表用NULL来代表空链表。单链表没有与双链表IsListEmpty函数对应的函数。
Lookaside(后援式)链表
即使用最好的算法,堆管理器仍不时地需要一些处理器时间来处理随机出现的内存空洞。图3-11显示了这种情况,当内存块B返回堆时,块A和C已经空闲,之后,堆管理器可以把块A、B、C组合成一个更大的单块内存。而这个内存块可以满足比单个A、B、C都大的内存申请。
图3-11. 接合邻近的空闲内存块
如果你总是使用固定大小的内存块,你可以制定一个更有效的方案来管理堆。例如,你可以预先分配一个大内存块,然后再把这块内存细分成多块固定大小的小块内存。然后再设计一个方案来确定哪一块内存是空闲的,哪一块是使用的,就象图3-12那样。向这样的堆返回内存块仅仅是把被返回块置成空闲,而不必接合临近空闲块,因为你从不提供随机大小的内存块。
仅仅分配一个大内存块然后再细分成多个小内存块并不是实现固定大小堆的最佳办法。通常,我们难于猜测应预先分配多少内存。如果太大就会浪费内存,如果太小,你的算法会因内存不足而失败,或者频繁地向随机堆管理器申请更多内存。为此,Microsoft创建了lookaside链表对象,以及一组相应的算法来克服这些缺点。
图3-12. 仅接受固定大小内存请求的堆
图3-13显示了lookaside链表的概念。假设有一个可以在水池中直上直下平衡的玻璃杯子。这个杯子就代表lookaside链表对象。当初始化该对象时,你告诉系统需要多大的内存块(杯中的水滴)。在早期版本的Windows NT中,你还要指出杯子的容量,但现在的操作系统可以自动适应。为了满足一个内存分配请求,系统首先尝试着从链表中取出(删除)一块内存(从杯子中取出一滴水)。如果连一块内存也没有,系统就从外面内存池中取。相反,释放内存时,系统首先尝试着放到链表上(向杯子中加入一滴水)。但是,如果链表满了,那么这个内存块就返回到外界的内存池中(杯子中的水溢出到水池)。
图3-13. Lookaside链表
系统根据实际的使用量周期性调整lookaside链表的深度。算法的细节并不重要,而且还会变化。基本上,在当前发行的操作系统中,对于那些最近不常使用的lookaside链表,系统将降低其深度。但这个深度从不低于4,这也是新链表的初始深度。
表3-6列出了8个lookaside链表服务函数。实际上它们分成两组,每组4个函数,一组管理利用分页内存的lookaside链表(ExXxxPagedLookasideList),另一组管理利用非分页内存的lookaside链表(ExXxxPagedLookasideList)。首先要做的是为一个PAGED_LOOKASIDE_LIST或NPAGED_LOOKASIDE_LIST对象保留非分页内存。这两个对象十分相似,但分页类型的链表使用FAST_MUTEX来同步,而非分页类型的链表要使用自旋锁(下一章将讨论这些同步对象)。PAGED_LOOKASIDE_LIST对象所指向的内存块将出现在分页池中,但它自身应出现在非分页内存中,因为系统需要在提升的IRQL级上访问它。
表3-6. lookaside链表的服务函数
服务函数 | 描述 |
---|---|
ExInitializeNPagedLookasideList ExInitializePagedLookasideList | 初始化lookaside链表 |
ExAllocateFromNPagedLookasideList ExAllocateFromPagedLookasideList | 分配一个固定大小的内存块 |
ExFreeToNPagedLookasideList ExFreeToPagedLookasideList | 将一个内存块释放回lookaside链表 |
ExDeleteNPagedLookasideList ExDeletePagedLookasideList | 删除lookaside链表 |
为lookaside链表对象保留完存储后,应调用相应的初始化函数:
PPAGED_LOOKASIDE_LIST pagedlist; PNPAGED_LOOKASIDE_LIST nonpagedlist; ExInitializePagedLookasideList(pagedlist, Allocate, Free, 0, blocksize, tag, 0); ExInitializeNPagedLookasideList(nonpagedlist, Allocate, Free, 0, blocksize, tag, 0); |
(这两个函数仅在函数名和第一个参数的拼写上不相同)
这两个函数的第一个参数都指向[N]PAGED_LOOKASIDE_LIST对象,我们已经为该对象保留了存储。Allocate和Free指向由你写的可以在随机堆中分配和释放内存的例程。如果把它们指定为NULL,系统将使用ExAllocatePoolWithTag和ExFreePool来分配和释放内存。blocksize参数是该链表能分配的内存块大小,tag是一个32位的标签值,将放到每个这样块的前面。两个0值的地方用于与以前版本的Windows NT兼容,现在的系统可以自己决定这些值;它们分别是用于控制分配类型的标志和lookaside链表深度。
从链表上分配一个内存块,调用AllocateFrom函数:
PVOID p = ExAllocateFromPagedLookasideList(pagedlist); PVOID q = ExAllocateFromNPagedLookasideList(nonpagedlist); |
向链表返回一个内存块,调用FreeTo函数:
ExFreeToPagedLookasideList(pagedlist, p); ExFreeToNPagedLookasideList(nonpagedlist, q); |
删除链表,调用Delete函数:
ExDeletePagedLookasidelist(pagedlist); ExDeleteNPagedLookasideList(nonpagedlist); |
经常犯的错误是忘记删除lookaside链表。应该在lookaside链表的有效范围内删除它。例如,如果在AddDevice函数中创建了lookaside链表,应该把链表对象放到设备对象中并在调用IoDeleteDevice前删除它。如果在DriverEntry函数中创建了lookaside链表,应该把链表对象放到全局变量中,并在DriverUnload例程返回前删除它(在DriverUnload例程的尾部)。