背景与核心问题
16 位 Windows(1.x 至 3.x)诞生于 8086 实模式时代,面对当时典型 PC 的 640KB 内存限制,微软设计了一套复杂的分段内存管理系统。这套系统的核心挑战在于:如何在缺乏硬件内存管理单元(MMU)支持的环境下,实现类似虚拟内存的段移动、丢弃与按需加载功能。
与当时主流的 DOS 程序不同,Windows 应用程序需要面对一个根本性的编程范式转变:内存地址不再是静态的。代码段和数据段可能在运行时被操作系统移动甚至丢弃,这要求开发者遵循严格的内存访问规则。
Selector 与 Handle 的抽象映射
Win16 内存管理的基石是 ** 句柄(handle)** 机制。每个内存段分配后返回一个 16 位句柄,而非直接的段地址。这一设计借鉴了 Intel 286 保护模式的 selector 概念 —— 句柄作为段的逻辑标识符,与其物理位置解耦。
应用程序通过GlobalAlloc申请内存时获得句柄,但若要实际访问该内存,必须调用GlobalLock将其转换为可用的段地址:
HANDLE hMem = GlobalAlloc(GMEM_MOVEABLE, size);
// hMem 是句柄,不能直接用于内存访问
LPSTR lpMem = GlobalLock(hMem);
// lpMem 现在包含有效的段:偏移地址
// 可以安全地读写内存...
GlobalUnlock(hMem); // 释放锁定,段可能再次被移动
这种设计引入了 ** 锁计数(lock count)** 机制。每次GlobalLock使计数加一,GlobalUnlock使其减一。当计数归零,Windows 获得移动该段的许可。这一机制在 8086 实模式下完全由软件实现,因为硬件本身不支持内存保护或重定位。
NE 格式的段导向设计
Windows 采用 "New Executable"(NE)格式替代传统的 DOS MZ 格式,这是实现灵活内存管理的关键。与 MZ 格式将整个可执行文件视为单一二进制块不同,NE 格式将代码和数据组织为独立的段(segment),每个段在磁盘上单独存储。
NE 格式的核心特性包括:
- 段表(Segment Table):描述每个段的属性(可移动、可丢弃、预加载等)
- 重定位表(Relocation Table):记录段内的 far 指针引用,支持运行时重定位
- 导入 / 导出表(Import/Export Table):支持动态链接与回调函数导出
当 Windows 需要整理内存碎片时,可以移动可移动段,并通过重定位表更新所有引用该段的 far 指针。对于可丢弃段(主要是代码段和资源),Windows 可直接释放其占用的内存,需要时从原始可执行文件重新加载。
导出函数的 Prolog 修补机制
Win16 编程中最令人困惑的概念之一是导出函数的声明要求。窗口过程必须声明为FAR PASCAL并显式导出,原因在于 Windows 需要修补函数的入口序列(prolog)。
典型的导出函数 prolog 如下:
push ds
pop ax ; 被Windows loader修补为: mov ax, <data_segment_selector>
xchg ax,ax ; NOP占位
inc bp ; 标记far调用
push bp
mov bp,sp
push ds
mov ds,ax ; 加载正确的数据段
Windows loader 在模块加载时会将前三个字节修补为mov ax, <模块默认数据段选择符>。这意味着如果数据段被移动,Windows 只需重新修补这些入口点,而无需遍历整个程序的代码。
inc bp/dec bp指令对则服务于栈遍历需求。Windows 内存管理器需要识别栈中的 far 调用帧以正确处理段引用,BP 的奇数值标记了 far 函数边界。
兼容层实现要点
在现代系统上实现 Win16 兼容层(如 Wine 或虚拟机方案),需要准确模拟以下机制:
1. Handle 到线性地址的映射表
维护句柄到实际内存地址的映射。建议实现为固定大小的数组或哈希表:
| 字段 | 类型 | 说明 |
|---|---|---|
| handle | uint16_t | 句柄值(通常高位字节非零) |
| linear_addr | void* | 实际内存地址 |
| lock_count | uint16_t | 锁定计数 |
| flags | uint16_t | 段属性(可移动 / 可丢弃 / 固定) |
| owner | HMODULE | 所属模块 |
2. 段属性标志位
#define SEG_MOVABLE 0x0001
#define SEG_DISCARDABLE 0x0002
#define SEG_FIXED 0x0004
#define SEG_PRELOAD 0x0040
#define SEG_ITERATED 0x0080
3. 内存压缩策略
当全局堆空闲空间不足时,按以下优先级执行:
- 丢弃所有 lock_count=0 的可丢弃段
- 移动所有 lock_count=0 的可移动段以消除碎片
- 如仍不足,返回 OOM 错误
4. NE 重定位处理
解析 NE 重定位表时,需支持以下重定位类型:
- 内部段引用(INTERNALREF):指向同一模块内的其他段
- 导入名称(IMPORTNAME):通过名称导入 DLL 函数
- 导入序号(IMPORTORDINAL):通过序号导入 DLL 函数
- OSFIXUP:操作系统特定的修复(通常忽略)
调试与验证工具
Windows SDK 提供了专门用于验证内存管理正确性的工具:
- HeapWalker:显示已分配段的详细信息,可模拟低内存条件
- Shaker/Stress:强制移动和丢弃段以暴露潜在的锁定错误
这些工具的重要性在于:在内存充裕的环境中,未正确配对的GlobalLock/GlobalUnlock调用可能不会立即导致崩溃,因为段往往不会被实际移动。只有在内存压力下,这些隐藏的错误才会显现。
结语
Win16 的分段内存模型代表了在受限硬件条件下的精巧工程设计。通过句柄抽象、NE 格式的段导向结构以及编译器与操作系统的紧密协作,Windows 在 8086 实模式上实现了近似保护模式的内存管理能力。对于现代兼容层开发者而言,理解这些机制的细节 —— 特别是句柄生命周期管理、重定位表解析和栈遍历约定 —— 是确保旧版软件正确运行的关键。
参考资料
- OS/2 Museum, "Win16 Memory Management", https://os2museum.com/wp/win16-memory-management/
- Norton, Peter & Yao, Paul. Peter Norton's Windows 3.0 Power Programming Techniques. 1990.
内容声明:本文无广告投放、无付费植入。
如有事实性问题,欢迎发送勘误至 i@hotdrydog.com。