← 返回信息流
AI 资讯Hacker News·1 天前

Win16内存管理机制解析

原标题:Win16 Memory Management

速览

本文深入探讨了Windows 16位操作系统中的内存管理策略。内容涵盖了实模式下的内存限制及保护模式下的初步尝试。这一机制为后续Windows NT及现代操作系统的内存管理奠定了基础。

AI 深度解读

Win16 内存管理深度解读

背景

这篇文章源于对 16 位 Windows(Windows 1.x 至 Windows 3.x)内存管理机制的深入探索。尽管该主题并非完全未公开文档化,但其文档质量极低,且长期以来被开发者忽视。在 Windows 3.0 出现之前,业界普遍假设应用程序开发者将主要使用高级语言,其开发工具会自动处理底层的内存细节。

此外,针对初学者的 Windows 开发资料大多聚焦于可见的编程元素,如窗口、图标和菜单等,而内存管理这一对于编写复杂 Windows 应用程序至关重要的底层机制往往被一笔带过。

Win16 的内存管理细节和机制根植于 Windows 1.x 和 2.x 时代的 8086 实模式历史。即使从 Windows 3.1 开始,Windows 仅运行在保护模式下,许多复杂性依然得以保留。除非另有说明,本文中的“Windows”特指 16 位版本的 Microsoft 产品,而非 Windows NT。

核心内容

Windows 内存管理的本质:高级覆盖管理器

理解 Win16 内存管理的关键在于认识到,Windows 从诞生之初就是一个“花哨的覆盖管理器(overlay manager)”。在早期 PC 内存极其有限的年代,Windows 系统本身过于庞大,无法完全装入物理 RAM。因此,Windows 需要一种机制,仅将最活跃的内存段保留在物理内存中,并在需要时按需丢弃和重新加载较少使用的段。

由于 8086 和 80286 系统不支持分页(Paging),且 Windows 3.0 之前这些系统是安装基础的主流,因此分页技术并未被采用。

透明性与 FAR 指针的挑战

在最简单的情况下(应用程序仅包含一个代码段和一个数据段),Windows 段的移动性对应用程序几乎是透明的。当应用程序运行时,CS(代码段)寄存器指向代码段,DS(数据段)和 SS(堆栈段)寄存器指向数据段。只要应用程序仅在其代码段内使用近调用/跳转(near calls/jumps),并使用指向数据/堆栈段的近指针(near pointers),它就不关心段在内存中的确切位置。Windows 可以自由移动这些段,程序仍能正常工作。

然而,即使是编写“Hello World”级别的初学者也会很快发现情况并非如此简单。Windows 的窗口过程(Window Procedure)必须声明为 FAR PASCAL,以符合 Windows 的调用约定。更重要的是,它必须从应用程序的可执行文件中导出,否则程序无法正常运行。这一概念对于非 Windows 开发者来说非常陌生。

NE 格式与导入/导出机制

为了支持其内存管理方案,Windows 采用并扩展了“New Executable”(NE)格式。该格式最初由“DOS 4”(即 Multitasking DOS 4.0,与 PC DOS 和 MS-DOS 4.0/4.01 显著不同)使用。与 DOS 的 MZ 可执行格式(应用程序本质上是一个单一的二进制块)不同,NE 格式是基于段的,每个段在磁盘上单独存储。这使得 Windows 能够加载(或重新加载)单个段并在内存中移动它们。

NE 格式还支持导入(Imports)和导出(Exports):

  • 导入:当应用程序需要调用外部代码(如操作系统本身)时使用。
  • 导出:用于供外部调用的应用程序代码。

窗口过程就是一种需要被导出的外部调用代码。Windows 利用这一机制对窗口过程的前导代码(entry sequence)进行修复,使其在调用时能将应用程序自己的数据段加载到 DS 寄存器中。

内存段与句柄(Handles)

Win16 内存管理的核心围绕“段”展开,段是大小不超过 64KB 的连续内存块。

  • 在标准的 8086 编程中,每个段由其段地址标识,该地址直接对应其在物理内存中的位置。
  • 在 Windows 中,由于大多数段可以移动或丢弃,它们通过**句柄(Handle)**来标识。句柄是一个 16 位值,通常应被视为不透明值(opaque),即使它实际上可能只是某个表中的简单索引。

对于熟悉 x86 保护模式的程序员来说,Windows 段句柄类似于保护模式下的选择子(Selector):它是一个 16 位值,唯一标识一个内存段,但独立于该段在系统内存中的位置。这种相似性并非巧合。Windows 1.0 内存管理的设计者 Steve Wood 在设计时参考了 Intel 286 保护模式(286 于 1982 年发布,Windows 开发始于 1983 年)。

锁定与解锁机制

由于 8086 不支持保护模式,近似实现保护模式功能需要大量的额外工作和纪律。因为句柄不是段地址,所以它不能直接用作远指针(far 16:16 pointer)的段部分。为了访问另一个段中的内存,应用程序必须构建一个远指针。

具体操作流程如下:

  1. GlobalLock:应用程序调用此 API,返回一个段地址并锁定该段(增加其锁定计数)。在锁定期间,段不会被移动,其段地址保持有效。
  2. 访问内存:在锁定状态下访问内存。
  3. GlobalUnlock:访问结束后,应用程序调用此 API 减少段的锁定计数。当计数降为零时,段可以被再次移动。

潜在陷阱:调用 GlobalUnlock 后,GlobalLock 返回的段地址必须被视为无效。这是一个隐蔽的 bug 来源:解锁后,段通常不会立即移动,应用程序可能会错误地访问已解锁的段而未引发明显错误。然而,一旦段解锁,Windows 可能在任何时候移动或丢弃它们(除非 Windows 认为该段很快会被再次使用)。

段属性:固定/可移动与可丢弃/不可丢弃

Windows 段具有几个重要属性,决定了内存管理器对其的处理方式:

  1. 固定(Fixed) vs 可移动(Movable)

    • 可移动段:只要未被锁定,Windows 就可以随意移动它们。理想情况下,应用程序的大部分代码和数据段应为可移动,以便 Windows 高效管理内存。移动段的能力对于内存碎片整理至关重要,因为释放或丢弃段会在内存中产生“空洞”,Windows 需要通过移动段来合并空闲内存。
    • 固定段:保持不变。例如,包含中断处理程序的段必须是固定的,以确保中断向量表的有效性。
  2. 可丢弃(Discardable) vs 不可丢弃(Non-discardable)

    • 可丢弃段:通常是代码段和资源段,因为它们只读。如果未使用的代码段被移除,稍后需要时 Windows 可以轻松地从原始可执行文件中重新加载。
    • 不可丢弃段:通常是数据段,因为它们通常是可写的,一旦修改就无法直接从磁盘重新加载。不过,如果应用程序愿意在段被丢弃后重新创建其内容,也可以将可写数据段标记为可丢弃。

关键要点

  • 历史根源:Win16 内存管理是为了解决 8086/80286 实模式下物理内存极度匮乏的问题,本质上是一个覆盖管理器,而非现代意义上的虚拟内存分页系统。
  • NE 格式的重要性:New Executable (NE) 格式允许将代码和数据分割成独立的段,这是实现按需加载和内存移动的基础,区别于传统的 DOS MZ 格式。
  • 句柄与地址分离:Windows 使用 16 位句柄来标识段,而非直接的物理地址。这模拟了保护模式选择子的概念,允许段在内存中自由移动。
  • 显式锁定机制:由于缺乏硬件保护,开发者必须手动使用 GlobalLockGlobalUnlock API 来管理段的稳定性。解锁后的段地址立即失效,继续使用会导致难以调试的错误。
  • 内存碎片化挑战:由于段可以被丢弃和移动,内存碎片化是一个主要问题。Windows 通过移动可移动段来压缩内存,将空闲空间合并。
  • 段属性分类
    • 代码和资源通常是可移动可丢弃
查看原文 →os2museum.com