第四章 探索 Windows 2000 的内存管理机制
翻译: Kendiv( fcczj@263.ne t)
更新: 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 内部(