第四章 探索Windows 2000的内存管理机制
翻译:Kendiv(fcczj@263.net)
更新:Sunday, February 13, 2005
声明:转载请注明出处,并保证文章的完整性,本人保留译文的所有权利。
内存管理对于操作系统来说是非常重要的。本章将全面的纵览Windows 2000的内存管理机制以及4GB线性地址空间的结构。针对此部分内容,将解释Intel i386 CPU家族的虚拟内存寻址及其分页能力,重点将在于Windows 2000的内核是如何使用它们的。为了帮助我们对内存的探索,本章提供了一对程序:一个内核模式的驱动程序,该驱动用来收集系统相关的信息,另一个是用户模式的应用程序,该程序将通过设备I/O控制来查询来自驱动程序的数据,并在控制台窗口中进行显示。在剩余的章节中将重复使用“Spy Driver”模块来完成其他几个非常有趣的任务(这些任务都需要在内核模式下执行代码)。请坚持阅读完本章的第一部分,因为它将直接面对CPU硬件。不过,我仍然希望你不要跳过它,因为虚拟内存管理是一个非常令人兴奋的话题,理解它是如何工作的,将帮助你洞察复杂操作系统(如Windows 2000)采用的机制。
Intel i386内存管理机制
Windows 2000内核大量使用Intel i386 CPU系列提供的保护模式下的虚拟内存管理机制。为了更好的理解Windows 2000如何管理它的主内存,最低限度的熟悉i386 CPU的架构某些特点就显得尤为重要。Windows 2000是针对Pentium以上CPU设计的。不过,这些新的处理器采用的内存管理模型仍源自针对80386 CPU的设计,不过当然会加入了一些重要的增强。因此,微软通常标注Windows NT和Windows 2000的版本为Intel处理器“i386”或者“x86”。不要对这些感到困惑,不管你在本书的什么地方遇到86或386,请记住,这只是表示特定的CPU架构,而不是特定的处理器版本。
基本的内存布局
Windows 2000为应用程序和系统代码提供了非常简单的内存布局。由32位的Intel CPU提供的4GB虚拟内存空间被分割为相等的两部分。低于0x80000000的内存地址由用户模式下的模块使用,这包括Win32子系统,剩余的2GB保留给了系统内核。Windows 2000 Advanced Server还支持通常称为4GT RAM Tuning的另一种内存模型,该模型随Windows NT 4.0 Server的企业版引入。该模型可提供3GB的用户地址空间,另1GB保留给内核,通过在boot.ini中添加/3GB选项来启用该模型。
Windows 2000 Advanced Server和DataCenter支持称为:物理地址扩展(Physical Address Extension, PAE)的内存选项,通过在boot.ini中加入
/PAE就可允许这种内存方式。该选项采用了某些Intel CPU的特性(如,Pentium Pro处理器)以允许大于4GB的物理内存映射到32位的地址空间上。在本章中,我将忽略这种特殊的设置。你可阅读微软的基本知识文章Q171793(微软2000c)、Intel的Pentium手册(Intel 1999a,1999b,1999c)以及Windows 2000 DDK文档(微软2000f)来获取更多此方面的信息。
内存分段和请求式分页
在深入i386架构的技术细节之前,想让我们回到1978年,那一年Intel发布了PC处理器之母:8086。我想将讨论限制到这个有重大意义的里程碑上。如果你打算知道更多,阅读Robert L.的80486程序员参考(Hummel 1992)将是一个很棒的开始。现在看来这有些过时了,因为它没有涵盖Pentium处理器家族的新特性;不过,该参考手册中仍保留了大量i386架构的基本信息。尽管8086能够访问1MB RAM的地址空间,但应用程序还是无法“看到”整个的物理地址空间,这是因为CPU寄存器的地址仅有16位。这就意味着应用程序可访问的连续线性地址空间仅有64KB,但是通过16位段寄存器的帮助,这个64KB大小的内存窗口就可以在整个物理空间中上下移动,64KB逻辑空间中的线性地址作为偏移量和基地址(由16位的段寄存器给处)相加,从而构成有效的20位地址。这种古老的内存模型仍然被最新的Pentium CPU支持,它被称为:实地址模式,通常叫做:实模式。
80286 CPU引入了另一种模式,称为:受保护的虚拟地址模式,或者简单的称之为:保护模式。该模式提供的内存模型中使用的物理地址不再是简单的将线性地址和段基址相加。为了保持与8086和80186的向后兼容,80286仍然使用段寄存器,但是在切换到保护模式后,它们将不再包含物理段的地址。替代的是,它们提供了一个选择器(selector),该选择器由一个描述符表的索引构成。描述符表中的每一项都定义了一个24位的物理基址,允许访问16MB RAM,在当时这是一个很不可思议的数量。不过,80286仍然是16位CPU,因此线性地址空间仍然被限制在64KB。
1985年的80386 CPU突破了这一限制。该芯片最终砍断了16位寻址的锁链,将线性地址空间推到了4GB,并在引入32位线性地址的同时保留了基本的选择器/描述符架构。幸运的是,80286的描述符结构中还有一些剩余的位可以拿来使用。从16位迁移到32位地址后,CPU的数据寄存器的大小也相应的增加了两倍,并同时增加了一个新的强大的寻址模型。真正的32位的数据和地址为程序员带了实际的便利。事实上,在微软的Windows平台真正完全支持32位模型是在好几年之后。Windows NT的第一个版本在1993年7月26日发布,实现了真正意义上的Win32 API。但是Windows 3.x程序员仍然要处理由独立的代码和数据段构成的64KB内存片,Windows NT提供了平坦的4GB地址空间,在那儿可以使用简单的32位指针来寻址所有的代码和数据,而不需要分段。在内部,当然,分段仍然在起作用,就像我在前面提及的那样。不过管理段的所有责任都被移给了操作系统。
80386的另一个新特性是在硬件上支持分页,确切的来说是:请求式分页的虚拟内存。这种技术允许一个不同于RAM的存储介质----硬盘来为内存提供支持,例如,在允许分页时,CPU通过将最近最少访问的内存数据置换到备份存储器中,从而为新的数据腾出空间,这样就能访问比可用物理内存更大的内存空间。理论上来说,可以使用此种方式访问4GB的连续线性地址空间,提供的备份介质必须足够的大---即使只安装了非常少的物理内存。当然,分页并不是访问内存的最快方式,最好还是能提供尽可能多的物理内存。但是,这是处理大量数据的最好办法,即使这些数据超过了可用物理内存。例如,图形和数据库程序都需要一大块工作内存,如果没有分页机制的话,其中的某些程序就无法在低档的PC系统中运行。
80386分页的模式是将内存划分为4KB或4MB大小的页。操作系统的设计者可以在二者之间自由的选择,也可混合使用这两个大小的页面。稍后,我会介绍Windows 2000采用的混合大小方案:由操作系统使用4MB的页面,而4KB页面由剩余的代码和数据使用。这些页面由分层结构的页表树管理,该页表树记录当前位于物理内存中的页,同时还记录了每个页是否实际的位于物理内存中。如果指定页已被置换到了硬盘上,而某些模块触及了位于这些页中的地址,CPU就会产生一个缺页中断(这与外围硬件产生的中断类似)。接下来,位于操作系统内核中的缺页中断处理例程会试图将该页再次调入物理内存,这可能需要将另一块内存中的数据写入硬盘以腾出空间。通常,系统采用最近最少(LRU)算法来确定哪个页可以被置换出去。现在可以很清楚地看到为什么有时将这个过程称为----请求式分页(demand paging):即,由软件提出请求,然后根据操作系统和应用程序使用的内存的统计数据,将物理内存中的数据移动到后备存储设备中。
由页表提供的间接寻址方式蕴含着很有趣的两件事。第一,程序所使用的地址和CPU使用的物理地址总线上的地址之间并没有预设的关系。如果你知道你的程序所使用的数据结构位于某一地址,如,0x00140000,你可能仍然不想知道任何有关这些数据的物理地址的信息,除非你要检查页表树(page-table tree)。这需要操作系统来决定这些地址之间的映射关系。甚至当前有效的地址转换都是无法预测的,部分的来看,这是分页机制所固有的随机性导致的。幸运的是,在大多数应用程序中,并不需要有关物理地址的知识。不过,对于开发硬件驱动程序的人员来说还是需要某些这方面的知识。分页的另一个隐晦之处是:地址空间并不必须是连续的。实际上,根据页表的内容,4GB的空间可以包含大量的“空洞”,这些“空洞”既没有映射到物理内存也没有映射到后备存储器中。如果一个应用程序试图读取或写入这样的一个地址,它将立即被系统中止掉。稍后,我会详细的说明Windows 2000是如何将可用内存扩展到4GB地址空间的。
80486和Pentium CPU使用的分段和分页机制与80386很相似,但一些特殊的寻址特性除外,如Pentium Pro采用的物理地址扩展(Physical Address Extension, PAE)机制。随同更高的时钟频率一起,Pentium CPU的另一特性就是其采用的双重指令流水线,这一特性允许它在同一时刻执行两个操作(只要这两个指令不互相依赖)。例如,如果指令A修改一个寄存器的值,而与其相邻的指令B需要这个修改后的值来进行计算,在A完成之前,B将无法执行。但是如果指令B使用另一个寄存器,CPU就可同时执行这两个指令。Pentium系列CPU采用的多种优化方式为编译器的优化提供了广阔的空间。如果你对这方面的话题很感兴趣,请参考Rick的《Inner Loops》(Booth 1997)。
在i386的内存管理中,有三类地址非常有名,它们的术语---逻辑、线性和物理地址出现在Intel的系统编程手册(Intel 1999c)。
1. 逻辑地址:这是内存地址的精确描述,通常表示为16进制:xxxx:YYYYYYYY,这里xxxx为selector,而YYYYYYYY是针对selector所选择的段地址的线性偏移量。除了指定xxxx的具体数值外,还可使用具体的段寄存器的名字来替代之,如CS(代码段),DS(数据段),ES(扩展段),FS(附加数据段#1),GS(附加数据段#2)和SS(堆栈段)。这些符号都来自旧的“段:偏移量”风格,在8086实模式下使用此种方式来指定“far pointers”(远指针)。
2. 线性地址:大多数应用程序和内核驱动程序都忽略虚拟地址。它们只对虚拟地址的偏移量部分感兴趣,而这一部分通常称为线性地址。此种类型的地址假定了一种默认的分段模型,这种模型由CPU的当前段寄存器确定。Windows 2000使用flat segmentation(平滑段),此时CS、DS、ES和SS寄存器都指向相同的线性地址空间;因此,程序可以认为所有的代码、数据和堆栈指针都可安全的相互转化。例如,在任何时候,堆栈中的一个地址都可以转化为一个数据指针,而不需要关心相应段寄存器的值。
3. 物理地址:仅当CPU工作于分页模式时,此种类型的地址才会变得非常“有趣”。本质上,一个物理地址是CPU插脚上可测量的电压。操作系统通过设立页表将线性地址映射为物理地址。Windows 2000所用页表的布局的某些属性,对于调试软件开发人员非常有用,本章稍后将讨论之。
虚拟地址和线性地址的差别多少有些人为的痕迹,在一些文档中会交替的使用这两个词。我会尽力保证使用这一术语的一致性。特别需要注意的是,Windows 2000假定物理地址有64位宽。而Intel i386系统通常只有一个32位的地址总线。不过,某些Pentium系统支持大于4GB的物理内存。例如,使用PAE模式的Pentium Pro CPU,这种CPU可以将物理地址扩展到36位,这样就可访问多大64GB的物理内存(Intel 1999c)。因此,Windows 2000 的API函数通常使用数据类型PHYSICAL_ADDRESS来表示物理地址,PHYSICAL_ADDRESS实际是LARGE_INTEGER结构的别名,如列表4-1所示。这两种类型都定义在DDK头文件ntdef.h中。LARGE_INTEGER实际上是64位有符号整数的结构化表示,它可以被解释为一对32位数(LowPart和HighPart)或一个完整的64位数(QuadPart)。LONGLONG类型等价于Visual C/C++的原生类型__int64,该类型的无符号表示叫做ULONGLONG或DWORDLONG,它们都依赖基本的无符号类型__int64。
图4-1给出了i386内存的分段模型,同时说明了逻辑地址和线性地址的关系。为了更清晰些,我将描述符表(descriptor table)和段(segment)画的比较小。实际上,32位的操作系统通常采用图4-2所示的分段方案,这就是所谓的平滑内存模型(flat memory model),它采用一个4GB大小的段。这种方案的不足是,描述符表变成了段的一部分,从而可以被有足够权限的代码访问到。
typedef LARGE_INTEGER PHYSICAL_ADDRESS, *PPHYSICAL_ADDRESS;
typedef union _LARGE_INTEGER
{
struct
{
ULONG LowPart;
LONG HighPart;
};
LONGLONG QuadPart;
} LARGE_INTEGER, *PLARGE_INTEGER;
列表4-1. PHYSICAL_ADDRESS和LARGE_INTEGER结构的定义
图4-1. i386的内存分段
图4-2给出的内存模型被Windows 2000作为标准的代码、数据和堆栈段,这意味着,所有的逻辑地址将包括CS、DS、ES和SS段寄存器。FS和GS的处理方式有所不同。Windows 2000并不使用GS寄存器,而FS寄存器被专门用来保存位于线性地址空间中的系统数据区域的基地址。因此,FS的基地址远大于0,其大小不会超过4GB。有趣的是,Windows 2000为用户模式和内核模式分别维护两个不同的FS段。稍后我们将详细讨论这一问题。
图4-2. 平滑的4GB内存段
在图4-1和图4-2中,逻辑地址的selector指向描述符表,该描述符表由名为GDTR的寄存器指定。这是CPU的全局描述符表寄存器,该寄存器可由操作系统设置为任何适当的线性地址。GDT(全局描述符表)的第一项是保留的,该项对应的selector叫做“null segment selector”。Windows 2000将其GDT保存在0x80036000。GDT可容纳多达8,19264位的条目,即其最大值为64KB。Windows 2000仅使用开始的128个项,并将GDT的大小限制为1,024字节。随GDT一起,i386 CPU还提供了一个本地描述符表(Local Descriptor Table,LDT)和一个中断描述符表(Interrupt Descriptor Table,IDT),这两个表的起始地址分别保存在LDTR和IDTR这两个寄存器中。GDTR和IDTR的值是唯一的,CPU执行的每个任务都采用相同的值,而LDTR的值则是任务相关的,LDTR可容纳一个16位的selector。
图4-3示范了复杂的线性地址与物理地址的转换机制,如果在4KB分页模式下,并允许请求式分页,i386的内存管理单元就会采用此种转换机制。图中左上角的页目录基址寄存器(Page-Directory Base Register,PDBR)包含页目录的物理地址。PDBR由i386的CR3寄存器保存。仅用该寄存器的高20位来寻址。因此,页目录也是以页为边界的。PDBR的剩余位作为标志位或保留以便将来扩展使用。页目录占用一个完整的4KB页,由包含1024个页目录项(Page-Directory Entry)的数组构成,每个页目录项均为32位。和PDBR类似,每个PDE被划分为一个20位的页帧计数器(Page-Frame Number,PFN)和一个标志数组。PFN用来寻址页表。每个页表都是按页对齐的,包含1024个页表项(Page-Table Entry,PTE)。每个PTE的高20位作为一个指针指向一个4KB的数据页。通过将线性地址分为三段来实现地址转换:高10位用来选择一个PDE(属于页目录),接下来的10个位选择前面所选的PDE中的某个PTE,最后剩下的12个位用来指定在数据页中的偏移量,该数据页由前面所选的PTE确定。
图4-3. 两层间接模型(采用4KB页)
在4MB分页模式下,事情就变得很简单了,这是因为消除了一个间接层,如图4-4所示。此时,PDBR仍然指向页目录,但仅使用了每个PDE的高10位,这是因为目标地址采用4MB对齐。因为没有使用页表,这个地址同样也是4MB数据页的基地址。所以,此时的线性地址只包含两个部分:10个位用来选择PDE,其余的22位作为偏移量。4MB内存方案的开销没有4KB那么大,这是因为仅页目录需要附加的内存。这1024个PDE中的每个都可寻址一个4MB页。这足够覆盖整个4GB地址空间了。所以,4MB分页的优势就是可以降低内存管理的开销,但结果就是寻址粒度较大。
4KB和4MB分页模型各有优缺点。幸运的是,操作系统的设计人员不必非要在二者之中选择一个,可以混合使用这两种模型。例如,Windows 2000在内存范围0x80000000 --- 0x9FFFFFFF使用4MB大小的页,内核模块hal.dll和ntoskrnl.exe均被加载到该地址范围内。剩余的线性地址采用4KB页来管理。Intel大力推荐采用这种混合设计,以改进系统性能,这也因为4KB和4MB的页项(Page Entry)都会被高速缓存到不同的转换后备缓冲区(Translation Lookaside Buffers,TLBs)中,该TLB位于i386 CPU内部(Intel 1999c,pp.3-22f)。操作系统的内核通常比较大,而且需要常驻内存,因此,如果将它们保存在多个4KB页中将会永久性的耗尽宝贵的TLB空间。
图4-4. 一层间接模型(采用4MB页)
注意,地址转换的所有步骤都在物理内存中进行。PDBR和所有的PDE、PTE包含的都是物理地址指针。在图4-3和图4-4中可找到的线性地址位于左下角,该线性地址将转化为物理页中的偏移量。另一方面,应用程序却必须使用线性地址,它们对物理地址一无所知。不过,通过将页目录和其下属的所有页表映射到线性地址空间可以填补这一不足。在Windows 2000和Windows NT 4.0中,在线性地址范围0xC0000000----0xC03FFFFF可访问所有的PDE和PTE,这是一个采用4MB页的线性内存区域。可以简单的通过线性地址的高20位来查找与其相关联的PTE,这个高20位作为32位PTE数组的索引,PTE数组起始于0xC0000000。例如,地址0x00000000表示的PTE位于0xC0000000。假定有一线性地址0x80000000,通过将该地址右移12位,可得到0x80000(即该地址的高20位),因为每个PTE占用4个字节,所以目标PTE的地址为:0xC0000000+(4*0x80000)=0xC0200000。这样的结果看起来很有趣,线性地址将4GB地址空间划分为相等的两部分,又映射为一个PTE的地址,从而将PTE数组也划分为了相等的两部分。
现在,让我们更进一步,通过 PTE自身来计算数据项在PTE数组中的地址。常规的映射公式为:((LinearAddress >> 12)*4)+0xC0000000。LinearAddress取值范围为:0xC0000000----0xC0300000。位于线性地址0xC0300000的数据项指向PTE数组在物理内存中的起始位置。现在回去看一下图4-3,开始于地址0xC0300000的1024个数据项肯定是页目录!这种特殊的PDE、PTE排列方式被多个内存管理函数使用,这些函数由ntoskrnl.exe导出。例如,有文档记载的API函数MmIsAddressValid()和MmGetPhysicalAddress()使用32位的线性地址来查找其PDE,如可用,还会查找其PTE,并会检查它们的内容。MmIsAddressValid()简单的检验目标页是否位于物理内存中。如果测试失败,就意味着线性地址或者无效或者该地址引用的页已经被置换到了后备存储器(由系统页面文件集表示)中。MmGetPhysicalAddress()首先从线性地址中提取相应的页帧计数器(PFN),该PFN就是与其相关的物理内存页(该页将按照页大小进行划分)的基地址。接下来,它通过线性地址中剩余的12个位,来计算在物理页中的偏移量,最后将PFN指出的物理页基地址和前面算出的偏移量相加即可得到该线性地址对应的物理地址。
更彻底的检查MmGetPhysicalAddress()的实现方式,会发现Windows 2000内存布局的另一个有趣的特性。MmGetPhysicalAddress()函数在开始之前,首先测试线性地址是否位于0x80000000-----0x9FFFFFFF。就像前面提到的,这里存放着hal.dll和ntoskrnl.exe,而且这也是Windows 2000使用4MB页的地址块。这个有趣的特性是,如果给定的线性地址位于这一范围,MmGetPhysicalAddress()将不会关心所有的PDE或PTE。替代的是,该函数简单的将线性地址的高3位设为零,然后加上字节偏移量,最后将得到地址作为物理地址返回。这意味着,物理地址范围:0x00000000----0x1FFFFFFF将按照1:1的比例映射到线性地址0x80000000----0x9FFFFFFF!要知道ntoskrnl.exe总是被加载到线性地址0x80400000,这意味着Windows 2000的内核总位于物理地址0x00400000,这种情况发生在第二个4MB页的基地址位于物理内存中。事实上,通过检查这些内存区域可以证明上面的假定是正确的。本章提供的Memory SPY将使你有机会看到这一点。
补充:
这部分内容选择自《Windows环境下32位汇编语言程序设计》
x86的内存分页机制
当x86 CPU工作在保护模式和虚拟8086模式时,可以使用全部32根地址线访问4GB的内存。因为80386的所有通用寄存器都是32位的,所以用任何一个通用寄存器来间接寻址,不必分段就可以访问到4GB的内存地址。
但这并不意味着,此时段寄存器就不再有用了。实际上,段寄存器更加有用了,虽然在寻址上没有分段的限制了,但在保护模式下,一个地址空间是否可以被写入,可以被多少优先级的代码写入,是不是允许执行等等涉及保护的问题就出来了。要解决这些问题,必须对一个地址空间定义一些安全上的属性。段寄存器这时就派上了用场。但是设计属性和保护模式下段的其他参数,要表示的信息太多了,要用64位长的数据才能表示。我们把这64位的属性数据叫做段描述符(Segment Descriptor)。
80386的段寄存器是16位的,无法放下保护模式下64位的段描述符。如何解决这个问题呢?方法是把所有段的段描述符顺序存放在内存中的指定位置,组成一个段描述符表(Descriptor Table);而段寄存器中的16位用来做索引信息,指定这个段的属性用段描述符表中的第几个描述符来表示。这时,段寄存器中的信息不再是段地址了,而是段选择器(Segment Selector)。可以通过它在段描述符表中“选择”一个项目已得到段的全部信息。
那么段描述符表存放在哪里呢?80386引入了两个新的寄存器来管理段描述符表。一个是48位的全局描述符表寄存器GDTR,一个是16位的局部描述符表寄存器LDTR。那么,为什么有两个描述符表寄存器呢?
GDTR指向的描述符表为全局描述符表GDT(Global Descriptor Table)。它包含系统中所有任务都可用的段描述符,通常包含描述操作系统所使用的代码段、数据段和堆栈段的描述符及各任务的LDT段等。全局描述符表只有一个。
LDTR指向局部描述符表LDT(Local Descriptor Table)。80386处理器设计成每个任务都有一个独立的LDT。它包含每个任务私有的代码段、数据段和堆栈段的描述符,也包含该任务所使用的一些门描述符,如任务门和调用门描述符等。
不同任务的局部描述符分别组成不同的内存段,描述这些内存段的描述符当作系统描述符放在全局描述符表中。和GDTR直接指向内存地址不同,LDTR和CS、DS等段选择器一样只存放索引值,指向局部描述符内存段对应的描述符在全局描述符表中的位置。随着任务的切换,只要改变LDTR的值,系统当前的局部描述符表LDT也随之切换,这样便于个任务之间数据的隔离。但GDT并不随着任务的切换而切换。
16位的段选择器如何使用全局描述符表和局部描述符表这两个表呢?实际上,段选择器中只有高13位表示索引值。剩下的3个数据位中,第0,1位表示程序的当前优先级RPL;第2位TI位用来表示在段描述符的位置;TI=0表示在GDT中,TI=1表示在LDT中。
80386处理器把4KB大小的一块内存当作一“页”内存,每页物理内存可以根据“页目录”和“页表”,随意映射到不同的线性地址上。这样,就可以将物理地址不连续的内存的映射连到一起,在线性地址上视为连续。在80386处理器中,除了与CR3(保存当前页目录的地址)相关的指令使用的是物理地址外,其他所有指令都是使用线性地址寻址的。
是否启用内存分页机制是由80386处理器新增的CR0寄存器中的位31(PG位)决定的。如果PG=0,则分页机制不启用,这时所有指令寻址的地址(线性地址)就是系统中实际的物理地址;当PG=1的时候,80386处理器进入内存分页管理模式,所有的线性地址要经过页表的映射才得到最后的物理地址。
…………待续…………