Skip to content
Published at:

第12章:内存管理基础

基本概念

早期的 Intel/AMD 处理器在内存管理方面存在显著限制。最初的 8086/8088 处理器仅支持 1MB 物理内存,采用段地址(Segment Address)与偏移量(Offset)组合的方式寻址:16 位段寄存器中的值乘以 0x10,再加上偏移量,产生 20 位地址。这种工作模式被称为实模式(Real Mode),至今仍是现代处理器启动时的初始运行模式。

随着 80386 处理器的诞生,虚拟内存(Virtual Memory)机制被引入——每次内存访问都需要翻译为物理地址。在保护模式(Protected Mode)下,直接访问物理内存是不可能的,所有访问必须经过操作系统内存管理器准备和维护的虚拟到物理地址映射表。在 64 位系统中,这一机制被称为长模式(Long Mode),本质上是保护模式向 64 位地址空间的扩展。

可以这样类比:实模式就像一栋小楼,每个住户通过"楼层号+房间号"的组合找到自己的住所,地址范围有限;而虚拟内存(保护模式/长模式)则像现代城市管理系统,每个住户只知道自己家的"虚拟门牌号",实际的"物理位置"由城市管理中心的映射表决定,住户之间互不干扰,也无法直接闯入别家。

虚拟地址到物理地址的映射以及内存块的分配管理,都是以页面(Page)为粒度进行的。下表列出了不同架构支持的页面大小:

架构小页(普通页)大页(Large Page)超大页(Huge Page)
x864KB2MBN/A
x644KB2MB1GB
ARM4KB4MBN/A
ARM644KB2MBN/A

默认的"小页"大小在所有架构上都是 4KB。不同大小的页面在使用时会明确冠以"Large"或"Huge"前缀。

进程地址空间

每个进程都拥有自己独立的线性虚拟私有地址空间(Virtual Address Space),从地址零开始,扩展到由操作系统位宽(32 位或 64 位)和进程位宽决定的最大值。其核心特征是私有性(Privacy)——地址 0x100000 在每个进程中都存在,但可能映射到完全不同的物理地址、文件或根本不映射任何实际存储。

可以这样理解:进程地址空间就像每个用户在自己设备上看到的"我的电脑"视图。虽然每个用户都可能看到相同的"桌面"路径,但桌面上的内容对每个用户来说是完全独立的。进程只能在其自己的地址空间内直接访问内存——"这意味着一个进程不能仅仅通过操作指针就意外或恶意地读写另一个进程的地址空间"。跨进程访问需要调用 ReadProcessMemoryWriteProcessMemory 等函数,并且必须拥有足够权限的句柄(Handle)。

这种地址空间被称为虚拟地址空间(Virtual Address Space)——它是潜在内存映射的空间。在初始状态下,进程使用的空间非常有限:首先是可执行文件和 NtDll.dll 被映射到地址空间,然后加载器(Loader)分配基本数据结构,如默认进程堆(Default Process Heap)、PEB(Process Environment Block,进程环境块)和第一个线程的 TEB(Thread Environment Block,线程环境块)。

页面状态

虚拟内存中的每个页面都处于以下三种状态之一:

  • 空闲(Free):未映射的页面,访问它们会引发访问违规异常(Access Violation)。进程的地址空间大部分在初始时处于空闲状态。
  • 已提交(Committed):已映射的页面,可能映射到 RAM 或文件。除非存在冲突的保护属性,否则访问通常成功。如果页面不在 RAM 中,CPU 会触发页面错误(Page Fault),内存管理器会从磁盘加载所需页面,更新翻译表,然后重试访问——这一切对调用线程是透明的。
  • 保留(Reserved):介于空闲和已提交之间。访问保留页面会引发访问违规异常(因为没有后备存储),但它能阻止常规分配使用该地址范围。线程栈就是保留页面的典型用例——线程栈在虚拟内存中必须连续且可以增长。

可以将这三种状态类比为空地(Free)、已建房(Committed)和已圈地但未建房(Reserved)。在空地上走动会收到警告(访问违规),在已建好的房子里可以正常生活(正常访问),而已圈的地虽然不能居住,但别人也占不了这块位置。

页面状态含义访问结果
Free(空闲)未分配访问违规
Committed(已提交)已分配成功(假设没有保护限制)
Reserved(保留)未分配但保留供将来使用访问违规

从技术层面来看,"访问空闲页面同样会触发页面错误……内存管理器判断出给定地址后方没有任何内容,从而引发访问违规异常。"

地址空间布局

LARGEADDRESSAWARE 是存储在 PE 头部(PE Header)中的一个链接器标志。如果可执行文件经过了数字签名,修改该标志会使签名失效。最早,32 位进程只能获得 2GB 的地址空间。有些开发者会利用最高位(MSB,Most Significant Bit)始终为零的特性(地址<2GB),将该位用于其他目的。而设置 LARGEADDRESSAWARE 标志就表明该进程没有这样的假设。

"这个标志只影响可执行文件(EXE),不影响动态链接库(DLL)。DLL 必须始终正确工作,绝不能对接收到的地址值做任何假设。"

在 Visual Studio 中,该设置位于: 项目属性(Project Properties) → 链接器(Linker) → 系统(System)。32 位平台默认值为"否",64 位平台默认值为"是"。对于 32 位可执行文件,设置为"是"几乎没有什么副作用——除非程序存在内存泄漏,这种情况下更大的地址空间意味着更多的潜在浪费。

可以使用 dumpbin /headers 命令或 PE Explorer V2 等图形化工具来检查该标志。

下表汇总了不同系统和进程位宽下,有无 LARGEADDRESSAWARE 标志时的地址空间大小:

操作系统类型进程类型有 LARGEADDRESSAWARE无 LARGEADDRESSAWARE
32 位启动(无 UVA)32 位2GB2GB
32 位启动(有 UVA)32 位2GB–3GB2GB
64 位(Win 8.1+)32 位4GB2GB
64 位(Win 8.1+)64 位128TB2GB
64 位(Win 8 及更早)32 位4GB2GB
64 位(Win 8 及更早)64 位8TB2GB

32 位系统

在 32 位系统上,总共 4GB 的地址空间中,高 2GB 是系统空间(System Space),也称为内核空间(Kernel Space)——操作系统内核和内核设备驱动程序驻留的地方。"系统空间是唯一的——只有一个系统,只有一个内核。这意味着系统空间中的地址是绝对的,而不是相对的。"

如果系统以"增大用户虚拟地址"选项引导,系统空间会缩减到仅 1GB,从而使用户进程能够使用 2GB 到 3GB 的地址范围(任何超过 2GB 的地址都需要 LARGEADDRESSAWARE 标志)。启用方法:

c:\>bcdedit /set increaseuserva 3072

其中数字是用户地址空间的大小(单位 MB,范围 2048 到 3072)。要禁用它:

c:\>bcdedit /deletevalue increaseuserva

64 位系统

64 位的理论上限是 16EB(Exabyte)——远超当前硬件能力。目前大多数现代处理器仅支持 48 位虚拟地址和物理地址,理论最大为 256TB。在 Windows 实际实现中,每个进程获得 128TB 的虚拟地址空间,另外 128TB 用于系统空间。

可以这样类比:32 位系统就像一个只有 4 平方公里的小镇,其中 2 平方公里是政府用地(系统空间),居民可用面积只有 2 平方公里。而 64 位系统则是广袤的陆地,每个居民都拥有 128 万平方公里的土地,系统的另一半同样归政府所有。

Intel 的 Sunny Cove 微架构已支持 57 位虚拟地址和 52 位物理地址——每个进程可达 64PB(Petabyte),系统空间同样为 64PB。

64 位过渡之所以非常平滑,是因为通过 WOW64 可以无修改地运行 32 位 x86 二进制文件。"一个设置了 LARGEADDRESSAWARE 标志的 32 位可执行文件,在 64 位系统上可以获得 4GB 地址空间。"Visual Studio 的 devenv.exe 就是一个典型例子。

但 64 位进程可能发生的事情也比较危险:泄漏内存甚至可能导致系统崩溃,因为"地址空间几乎是无限的——在地址空间耗尽之前,RAM 加页面文件的组合会先被填满。"

地址空间使用情况

有两个函数可以揭示可用的地址范围:

cpp
VOID GetSystemInfo(_Out_ LPSYSTEM_INFO lpSystemInfo);
VOID GetNativeSystemInfo(_Out_ LPSYSTEM_INFO lpSystemInfo);

这两个函数返回 SYSTEM_INFO 结构体:

cpp
typedef struct _SYSTEM_INFO {
    union {
        DWORD dwOemId;        // 已过时,请勿使用
        struct {
            WORD wProcessorArchitecture;
            WORD wReserved;
        };
    };
    DWORD dwPageSize;
    LPVOID lpMinimumApplicationAddress;
    LPVOID lpMaximumApplicationAddress;
    DWORD_PTR dwActiveProcessorMask;
    DWORD dwNumberOfProcessors;
    DWORD dwProcessorType;     // 已过时
    DWORD dwAllocationGranularity;
    WORD wProcessorLevel;
    WORD wProcessorRevision;
} SYSTEM_INFO, *LPSYSTEM_INFO;

GetSystemInfo 会考虑调用进程的位宽。例如,一个 32 位进程运行在 64 位系统上,只能"看到" 32 位范围内的值。而 GetNativeSystemInfo 会揭示真实的值。

以下是 sysinfo 示例应用中用于显示系统信息的辅助函数:

cpp
const char* GetProcessorArchitecture(WORD arch) {
    switch (arch) {
        case PROCESSOR_ARCHITECTURE_AMD64: return "x64";
        case PROCESSOR_ARCHITECTURE_INTEL: return "x86";
        case PROCESSOR_ARCHITECTURE_ARM: return "ARM";
        case PROCESSOR_ARCHITECTURE_ARM64: return "ARM64";
    }
    return "Unknown";
}

void DisplaySystemInfo(const SYSTEM_INFO* si, const char* title) {
    printf("%s\n%s\n", title, std::string(::strlen(title), '-').c_str());
    printf("%-24s%s\n", "Processor Architecture:", GetProcessorArchitecture(si->wProcessorArchitecture));
    printf("%-24s%u\n", "Number of Processors:", si->dwNumberOfProcessors);
    printf("%-24s0x%llX\n", "Active Processor Mask:", (DWORD64)si->dwActiveProcessorMask);
    printf("%-24s%u KB\n", "Page Size:", si->dwPageSize >> 10);
    printf("%-24s0x%p\n", "Min User Space Address:", si->lpMinimumApplicationAddress);
    printf("%-24s0x%p\n", "Max User Space Address:", si->lpMaximumApplicationAddress);
    printf("%-24s%u KB\n", "Allocation Granularity:", si->dwAllocationGranularity >> 10);
}

对于 WOW64 进程,程序额外调用 GetNativeSystemInfo 并显示原生系统信息:

cpp
BOOL isWow = FALSE;
if (sizeof(void*) == 4 && ::IsWow64Process(::GetCurrentProcess(), &isWow) && isWow) {
    ::GetNativeSystemInfo(&si);
    printf("\n");
    DisplaySystemInfo(&si, "Native System information");
}

WOW64 检测相关的辅助函数:

cpp
BOOL IsWow64Process(_In_ HANDLE hProcess, _Out_ PBOOL Wow64Process);
BOOL IsWow64Process2(_In_ HANDLE hProcess, _Out_ USHORT* pProcessMachine, _Out_opt_ USHORT* pNativeMachine);

在 64 位系统上运行 64 位应用时的输出示例:

System information
------------------
Processor Architecture: x64
Number of Processors: 16
Active Processor Mask: 0xFFFF
Page Size: 4 KB
Min User Space Address: 0x0000000000010000
Max User Space Address: 0x00007FFFFFFEFFFF
Allocation Granularity: 64 KB

最低可用地址是 0x10000——第一个 64KB 是不可用的(传统上用于捕获 NULL 指针引用)。同样,系统空间之前的最高 64KB 也是不可用的。

当同一应用以 32 位编译并在 64 位系统上运行时(WOW64),会显示"原生系统信息"一栏,其中最大用户空间地址为 0xFFFEFFFF(即约 4GB,因为有 LARGEADDRESSAWARE)。在所有架构和版本中,分配粒度(Allocation Granularity)都是 64KB。

内存计数器

任务管理器(Task Manager)的性能选项卡 → 内存,展示了多项系统内存信息,包括:

  • 使用中(In Use):当前实际使用的物理 RAM。
  • 已压缩(Compressed):自 Windows 10 起,系统将后台 UWP 应用的内存压缩以节省 RAM。"当前不需要的内存会被压缩,而不是写入页面文件",这对后台 UWP 应用非常有用。从版本 1607 开始,一个名为"Memory Compression"的特殊进程负责管理压缩内存。
  • 已提交/提交限制(Committed/Commit Limit):已提交内存量及其上限。
  • 内存组成(Memory Composition):包含已修改(Modified)、空闲(Free)、已缓存(Cached)、可用(Available)等类别。
  • 分页池/非分页池(Paged pool/Non-paged pool):内核使用的分页和非分页内存。

物理页面通过多种列表进行管理:

  • 备用页(Standby Pages):页面内容已写入磁盘后备存储,但仍与所属进程关联,可以快速恢复。
  • 已修改页(Modified Pages):页面内容尚未写入后备存储,不能直接复用。
  • 零页(Zeroed Pages):只包含零的页面。一个名为"零页线程"(Zero Page Thread)的特殊线程以优先级 0 运行,持续将空闲页面清零——这是出于安全考虑:"已分配的内存绝不能包含属于另一个进程的数据,即使该进程已不再存在。"
  • 空闲页(Free Pages):包含垃圾数据的页面,需要清零后才能分配。

内存优先级(Memory Priority)使用八个基于优先级的备用列表(默认优先级为 5)。进程或线程进入后台模式(参见第 6 章和第 10 章)时,CPU 优先级和内存优先级都会降低。设置与获取函数:

cpp
BOOL SetProcessInformation(_In_ HANDLE hProcess, _In_ PROCESS_INFORMATION_CLASS ProcessInformationClass, _In_ LPVOID ProcessInformation, _In_ DWORD ProcessInformationSize);
BOOL SetThreadInformation(_In_ HANDLE hThread, _In_ THREAD_INFORMATION_CLASS ThreadInformationClass, _In_ LPVOID ThreadInformation, _In_ DWORD ThreadInformationSize);

内存优先级的枚举值为 ProcessMemoryPriorityThreadMemoryPriority,取值范围 1–5(只允许降低优先级)。示例:

cpp
DWORD priority = 2;
::SetThreadInformation(::GetCurrentThread(), ThreadMemoryPriority, &priority, sizeof(priority));

对应的获取函数是对原生 API NtQueryInformationProcessNtQueryInformationThread 的薄层封装。

进程内存计数器

判断进程内存消耗的正确指标是提交大小(Commit Size),在 Process Explorer 和性能监视器(Performance Monitor)中被称为私有字节(Private Bytes)——它度量的是进程的私有已提交内存。相比之下,工作集(Working Set)计数器(私有物理内存)会随着进程的活动水平而波动,可能具有误导性。

"总结来说:要判断进程的内存消耗,请使用任务管理器中的'提交大小'列。"

虚拟大小(Virtual Size)统计所有非空闲页面(已提交 + 保留)——本质上是地址空间占用。对于 64 位进程来说,这影响不大,但对于 32 位进程,大量保留区域可能耗尽可用地址空间,即使已提交内存并不多。

从 Windows 8.1 开始,保留内存的开销进一步减小。有些进程显示的约 2TB 虚拟大小很大程度上源于控制流保护(CFG,Control Flow Guard),这是 Windows 10 的一项安全特性。

GlobalMemoryStatusEx 函数返回全局内存信息以及特定于调用进程的数据:

cpp
typedef struct _MEMORYSTATUSEX {
    DWORD dwLength;
    DWORD dwMemoryLoad;
    DWORDLONG ullTotalPhys;
    DWORDLONG ullAvailPhys;
    DWORDLONG ullTotalPageFile;
    DWORDLONG ullAvailPageFile;
    DWORDLONG ullTotalVirtual;
    DWORDLONG ullAvailVirtual;
    DWORDLONG ullAvailExtendedVirtual; // 始终为零
} MEMORYSTATUSEX, *LPMEMORYSTATUSEX;

BOOL GlobalMemoryStatusEx(_Inout_ LPMEMORYSTATUSEX lpBuffer);

关键成员说明:

  • dwMemoryLoad:物理内存使用百分比(0–100)。
  • ullTotalPhys:总物理 RAM 字节数。
  • ullAvailPhys:可用物理内存(备用页、空闲页和零页列表之和)。
  • ullTotalPageFile:系统提交限制与调用进程提交大小二者中的较小值。
  • ullAvailPageFile:调用进程最多还能提交的字节数。
  • ullTotalVirtual:调用进程的虚拟地址空间大小。
  • ullAvailVirtual:调用进程中空闲地址空间的大小。

GetPerformanceInfo(来自 <psapi.h>)返回纯系统范围的信息——内存相关值以页面为单位,而非字节:

cpp
typedef struct _PERFORMANCE_INFORMATION {
    DWORD cb;
    SIZE_T CommitTotal;
    SIZE_T CommitLimit;
    SIZE_T CommitPeak;
    SIZE_T PhysicalTotal;
    SIZE_T PhysicalAvailable;
    SIZE_T SystemCache;
    SIZE_T KernelTotal;
    SIZE_T KernelPaged;
    SIZE_T KernelNonpaged;
    SIZE_T PageSize;
    DWORD HandleCount;
    DWORD ProcessCount;
    DWORD ThreadCount;
} PERFORMANCE_INFORMATION, *PPERFORMANCE_INFORMATION;

BOOL GetPerformanceInfo(PPERFORMANCE_INFORMATION pPerformanceInformation, DWORD cb);

进程内存映射

一个进程的地址空间包含:可执行文件的代码和数据、各种 DLL 的代码和数据、线程栈(Thread Stacks)、进程堆(Heaps),以及任何已提交或保留的内存区域。Sysinternals 工具集中的 VMMap 工具提供了进程地址空间的完整视图。

VMMap 展示了三个顶层计数器:

  • 已提交内存(Committed Memory):总已提交量,包括私有和共享。
  • 私有字节(Private Bytes):已提交的私有内存。
  • 工作集(Working Set):实际使用的物理内存。

地址空间中的区域类型包括:

  • Image(映像):可执行文件和 DLL 对应的映射区域。
  • Mapped File(映射文件):非 PE 格式的文件映射。
  • Shareable(可共享):标记为可共享的内存。
  • Heap(堆):通过堆管理器分配的私有内存。
  • Managed Heap(托管堆):.NET 运行时管理的堆。
  • Stack(栈):每个线程的调用栈。
  • Private Data(私有数据):通过 VirtualAlloc 分配的私有内存。
  • Unusable(不可用):小于 64KB 分配粒度的块,无法被正常分配使用。
  • Free(空闲):未分配的地址空间。

GetMappedFileName 函数用于获取映射到某个地址的文件名:

cpp
DWORD GetMappedFileName(_In_ HANDLE hProcess, _In_ LPVOID lpv, _Out_ LPTSTR lpFilename, _In_ DWORD nSize);

该函数返回 NT 设备格式的路径(如 \Device\harddiskVolume3\...),可以通过额外 API 转换为 Win32 格式(如 C:\...)。

GetProcessMemoryInfo 提供了一个进程内存信息的摘要视图:

cpp
BOOL GetProcessMemoryInfo(HANDLE Process, PPROCESS_MEMORY_COUNTERS ppsmemCounters, DWORD cb);

存在两个结果结构体——基本的 PROCESS_MEMORY_COUNTERS 和扩展的 PROCESS_MEMORY_COUNTERS_EX(多了两个成员):

cpp
typedef struct _PROCESS_MEMORY_COUNTERS_EX {
    DWORD cb;
    DWORD PageFaultCount;
    SIZE_T PeakWorkingSetSize;
    SIZE_T WorkingSetSize;
    SIZE_T QuotaPeakPagedPoolUsage;
    SIZE_T QuotaPagedPoolUsage;
    SIZE_T QuotaPeakNonPagedPoolUsage;
    SIZE_T QuotaNonPagedPoolUsage;
    SIZE_T PagefileUsage;       // 当前提交大小(Win8+)
    SIZE_T PeakPagefileUsage;
    SIZE_T PrivateUsage;        // 与 PagefileUsage 相同(Win7 及更早)
} PROCESS_MEMORY_COUNTERS_EX;

关于内核池的概念:内核分页池(Kernel Paged Pool)是可以被分页到磁盘的内存,而非分页池(Non-paged Pool)始终驻留在 RAM 中,永不换出。用户态进程虽然不直接分配这些池,但会间接影响它们的消耗——例如,每个内核对象句柄在 64 位系统上消耗大约 16 字节的分页池。运行 testlimit -h 创建约 1670 万个句柄,会消耗约 256MB 的分页池。

页面保护

每个已提交的页面都具有保护(Protection)标志,通过 VirtualAllocVirtualProtect 函数设置。以下是主要的保护常量:

保护标志描述
PAGE_NOACCESS页面不可访问
PAGE_READONLY只允许读访问
PAGE_READWRITE允许读写访问
PAGE_WRITECOPY写时复制(Copy-on-Write)访问
PAGE_EXECUTE只允许执行访问
PAGE_EXECUTE_READ允许执行和读访问
PAGE_EXECUTE_READWRITE允许所有可能的访问
PAGE_EXECUTE_WRITECOPY允许执行和写时复制

可选的保护修饰常量:

保护标志描述
PAGE_GUARD保护页(Guard Page)——任何访问都会引发保护页异常
PAGE_NOCACHE不可缓存——仅在需要内核驱动访问时使用
PAGE_WRITECOMBINE针对特定内核驱动程序的优化选项
PAGE_TARGETS_INVALID(Win10+)页面是无效的 CFG 目标
PAGE_TARGETS_NO_UPDATE(Win10+)通过 VirtualProtect 更改保护时不更新 CFG 信息

关于 PAGE_GUARD 的一个重要行为:"当访问保护页引发异常后,PAGE_GUARD 标志会被自动移除,因此对同一页面的再次访问不会再次引发异常。"保护页常用于线程栈的增长检测——线程栈底部放置一个保护页,当栈使用量接近保护页时触发异常,通知系统扩展栈空间。

枚举地址空间区域

用于查询虚拟地址空间的核心函数:

cpp
SIZE_T VirtualQuery(_In_opt_ LPCVOID lpAddress, _Out_ PMEMORY_BASIC_INFORMATION lpBuffer, _In_ SIZE_T dwLength);
SIZE_T VirtualQueryEx(_In_ HANDLE hProcess, _In_opt_ LPCVOID lpAddress, _Out_ PMEMORY_BASIC_INFORMATION lpBuffer, _In_ SIZE_T dwLength);

VirtualQueryEx 需要目标进程的 PROCESS_QUERY_INFORMATION 访问权限。lpAddress 会被向下舍入到最近的页面边界。函数返回一个 MEMORY_BASIC_INFORMATION 结构体:

cpp
typedef struct _MEMORY_BASIC_INFORMATION {
    PVOID BaseAddress;
    PVOID AllocationBase;
    DWORD AllocationProtect;
    SIZE_T RegionSize;
    DWORD State;
    DWORD Protect;
    DWORD Type;
} MEMORY_BASIC_INFORMATION, *PMEMORY_BASIC_INFORMATION;

各成员含义:

  • BaseAddress:此内存块的起始地址。
  • AllocationBaseVirtualAlloc 原始分配区域的基地址——VMMap 用它来分组显示。
  • AllocationProtectVirtualAlloc 调用时指定的原始页面保护属性。
  • RegionSize:此块的大小(在 State、Protect 和 Type 都相同的连续页面上延伸)。
  • StateMEM_COMMITMEM_FREEMEM_RESERVED
  • Protect:当前的保护标志。
  • TypeMEM_IMAGE(PE 映像如 DLL/EXE)、MEM_MAPPED(非 PE 映射文件)或 MEM_PRIVATE(私有数据)。

Type 和 Protect 仅在 State 为 MEM_COMMITTED 时有意义。当 State 为 MEM_FREE 时,AllocationProtectAllocationBase 均无意义。

简单的 VMMap 应用程序

SimpleVMMap 控制台应用程序演示了使用 VirtualQueryEx 枚举进程地址空间的基本方法。main 函数接受一个 PID 参数,若未提供则使用当前进程:

cpp
int main(int argc, const char* argv[]) {
    DWORD pid;
    if (argc == 1) {
        printf("No PID specified, using current process...\n");
        pid = ::GetCurrentProcessId();
    }
    else {
        pid = atoi(argv[1]);
    }
    // ...
}

打开目标进程并显示内存映射:

cpp
HANDLE hProcess = ::OpenProcess(PROCESS_QUERY_INFORMATION, FALSE, pid);
if (!hProcess)
    return Error("Failed to open process");
printf("Memory map for process %d (0x%X)\n\n", pid, pid);
ShowMemoryMap(hProcess);
::CloseHandle(hProcess);

核心循环按区域迭代遍历地址空间:

cpp
void ShowMemoryMap(HANDLE hProcess) {
    BYTE* address = nullptr;
    MEMORY_BASIC_INFORMATION mbi;
    DisplayHeaders();
    for (;;) {
        if (0 == ::VirtualQueryEx(hProcess, address, &mbi, sizeof(mbi)))
            break;
        DisplayBlock(hProcess, mbi);
        address += mbi.RegionSize;
    }
}

VirtualQueryEx 返回 0 时,表示已到达合法地址空间的末端。

DisplayBlock 函数显示每个区域的详细信息:

cpp
void DisplayBlock(HANDLE hProcess, MEMORY_BASIC_INFORMATION& mbi) {
    printf("%s", mbi.AllocationBase == mbi.BaseAddress ? "*" : " ");
    printf("0x%16p", mbi.BaseAddress);
    printf(" %11llu KB", mbi.RegionSize >> 10);
    printf(" %-10s", StateToString(mbi.State));
    printf(" %-17s", mbi.State != MEM_COMMIT ? "" : ProtectionToString(mbi.Protect).c_str());
    printf(" %-17s", mbi.State == MEM_FREE ? "" : ProtectionToString(mbi.AllocationProtect).c_str());
    printf(" %-8s", mbi.State == MEM_COMMIT ? MemoryTypeToString(mbi.Type) : "");
    printf(" %s\n", GetDetails(hProcess, mbi).c_str());
}

输出中用星号 * 标记分配的起始位置(当 AllocationBase 等于 BaseAddress 时)。

GetDetails 函数负责获取映射文件的名称:

cpp
std::string GetDetails(HANDLE hProcess, MEMORY_BASIC_INFORMATION& mbi) {
    if (mbi.State != MEM_COMMIT)
        return "";
    if (mbi.Type == MEM_IMAGE || mbi.Type == MEM_MAPPED) {
        char path[MAX_PATH];
        if (::GetMappedFileNameA(hProcess, mbi.BaseAddress, path, sizeof(path)) > 0)
            return path;
    }
    return "";
}

重要警告:不要使用 32 位进程去枚举 64 位进程的地址空间——因为 MEMORY_BASIC_INFORMATION 中包含 32 位的指针和大小的字段,无法容纳 64 位地址。为此,存在专门的变体结构体 MEMORY_BASIC_INFORMATION32MEMORY_BASIC_INFORMATION64 来支持跨位宽枚举。

更多地址空间信息

QueryWorkingSetEx 函数提供了逐页的工作集详细信息:

cpp
BOOL QueryWorkingSetEx(_In_ HANDLE hProcess, _Out_ PVOID pv, _In_ DWORD cb);

pv 参数指向一个或多个 PSAPI_WORKING_SET_EX_INFORMATION 结构体:

cpp
typedef struct _PSAPI_WORKING_SET_EX_INFORMATION {
    PVOID VirtualAddress;
    PSAPI_WORKING_SET_EX_BLOCK VirtualAttributes;
} PSAPI_WORKING_SET_EX_INFORMATION, *PPSAPI_WORKING_SET_EX_INFORMATION;

PSAPI_WORKING_SET_EX_BLOCK 是一个联合体(Union),包含下列关键字段:

  • Valid:如果页面在进程工作集中,该位被设置。
  • Shared:指示页面是否可共享(如果清零,则页面是私有的)。
  • ShareCount:如果 Shared 被设置,表示共享计数(最大值 7——并非精确值)。
  • Win32Protection:基本保护标志(与 VirtualQuery(Ex) 返回的一致)。
  • Node:页面所属的 NUMA 节点。
  • Locked:页面被锁定在物理内存中。
  • LargePage:这是一个大页。
  • Bad:页面存在硬件级别的问题。

SimpleVMMap2 应用程序是增强版。其 DisplayBlock 函数为已提交区域增加了工作集详情的查询:

cpp
if (mbi.State == MEM_COMMIT)
    DisplayWorkingSetDetails(hProcess, mbi);

DisplayWorkingSetDetails 函数遍历块中的每一页,将具有相同属性的连续页面分组显示:

cpp
void DisplayWorkingSetDetails(HANDLE hProcess, MEMORY_BASIC_INFORMATION& mbi) {
    auto pages = mbi.RegionSize >> 12;
    PSAPI_WORKING_SET_EX_INFORMATION info;
    ULONG attributes = 0;
    void* address = nullptr;
    SIZE_T size = 0;
    for (decltype(pages) i = 0; i < pages; i++) {
        info.VirtualAddress = (BYTE*)mbi.BaseAddress + (i << 12);
        if (!::QueryWorkingSetEx(hProcess, &info, sizeof(PSAPI_WORKING_SET_EX_INFORMATION))) {
            printf(" <<<Unable to get working set information>>>\n");
            break;
        }

        if (attributes == 0) {
            address = info.VirtualAddress;
            attributes = (ULONG)info.VirtualAttributes.Flags;
            size = 1 << 12;
        }
        else if (attributes == (ULONG)info.VirtualAttributes.Flags) {
            size += 1 << 12;
        }

        if (attributes != (ULONG)info.VirtualAttributes.Flags || i == pages - 1) {
            printf(" Address: %16p (%10llu KB) Attributes: %08X %s\n",
                address, size >> 10, attributes,
                AttributesToString(*(PSAPI_WORKING_SET_EX_BLOCK*)&attributes).c_str());
            size = 1 << 12;
            attributes = (ULONG)info.VirtualAttributes.Flags;
            address = info.VirtualAddress;
        }
    }
}

属性转字符串的辅助函数:

cpp
std::string AttributesToString(PSAPI_WORKING_SET_EX_BLOCK attributes) {
    if (!attributes.Valid)
        return "(Not in working set)";

    std::string text;
    if (attributes.Shared)
        text += "Shareable, ";
    else
        text += "Private, ";

    if(attributes.ShareCount > 1)
        text += "Shared, ";

    if (attributes.Locked)
        text += "Locked, ";
    if (attributes.LargePage)
        text += "Large Page, ";
    if (attributes.Bad)
        text += "Bad, ";

    return text.substr(0, text.size() - 2);
}

共享内存

DLL(动态链接库)是共享内存的经典案例。可以这样理解:如果每个进程都在物理内存中拥有自己的一份 DLL 副本,物理内存很快就会被耗尽。由于代码段是只读的,可以安全地在进程之间共享。典型的共享 DLL 在所有进程中通常位于相同的虚拟地址——这是因为并非所有代码都是地址无关的(Position-independent)。

那全局变量呢?假设一个可执行文件中定义了全局变量 int x;,其初始值为 0。第一个实例启动后将 x 递增为 1,而第二个实例启动时看到的 x 仍然是 1(各自独立)。DLL 中的全局变量行为相同——每个进程都会获得自己独立的实例。

写时复制(Copy on Write,PAGE_WRITECOPY)——所有共享同一可写变量的进程最初映射到同一个物理页面。当其中一个进程(例如进程 A)修改该变量时,会触发异常,导致内存管理器为该进程创建该页面的私有副本,并移除写时复制保护。流程可归纳为:读取时共享同一物理页 → 写入时触发异常 → 系统创建私有副本 → 后续读写都操作私有副本。

对于需要显式跨进程共享数据的场景,可以通过创建带有 RWS(Read、Write、Share)属性的新数据段来实现:

cpp
#pragma data_seg("shared")
int x = 0;
#pragma data_seg()
#pragma comment(linker, "/section:shared,RWS")

变量必须显式初始化。data_seg 编译指示在 PE 文件中创建一个新的段(Section),而链接器指令则赋予该段 RWS 属性。关键在于 S——镜像在映射时不会使用写时复制保护,从而使得所有使用同一 PE 文件的进程都能共享该段中的数据。

"这类变量是共享的,意味着可能存在并发访问。例如,你可能需要使用互斥体(Mutex)来保护对这些变量的访问。"

SimpleShare 演示程序展示了这一机制。多个实例通过上述机制共享一个 SharedValue 整数——在一个实例中递增,变化会反映到所有实例中。一个 1 秒定时器周期性地读取并显示当前值,所有实例看到的始终是最新的值。

一种更通用的跨进程共享技术是内存映射文件(Memory Mapped Files),将在第 14 章中详细介绍。

页面文件

处理器只能访问物理 RAM 中的代码和数据。当进程的线程处于空闲状态时(例如最小化的应用),Windows 可能将该进程占用的 RAM 重新分配给其他进程使用。当应用被恢复时,代码可以直接从可执行文件本身重新加载——可执行文件和 DLL 通过内存映射文件机制充当自己的后备存储(Backing Store)。

对于数据而言,如果某块内存长时间未被访问或 RAM 资源紧张,内存管理器会将其写入页面文件(Page File)。页面文件负责为私有的、已提交的内存提供后备存储。"并非必须使用页面文件;Windows 可以在没有页面文件的情况下正常运行。但这会减少可以提交的内存总量。"

Windows 最多支持 16 个页面文件,每个文件名为 pagefile.sys,位于对应分区的根目录下(默认隐藏属性)。它们必须位于不同的磁盘分区上。基于 ARM 的 Windows 设备最多只支持 2 个页面文件。在 Windows 8 及以上版本中,还存在一个 Swapfile.sys 专门用于 UWP 进程。

提交限制(Commit Limit)等于物理 RAM 加上所有页面文件大小之和。每个页面文件具有初始大小和最大大小。当系统达到提交限制时,页面文件会扩展至其最大配置大小,从而提高提交上限。如果已提交内存降回原始限制以下,页面文件也会随之收缩。

可以通过以下路径配置页面文件:系统属性 → 高级 → 性能设置 → 高级 → 虚拟内存 → 更改。Windows 10 推荐的做法是自动管理:"系统会追踪过去 14 天中的已提交内存使用情况,并据此调整页面文件大小,这显然与实际用户的使用模式相关。"

页面文件配置存储在注册表中:HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Memory Management\PagingFiles。页面文件的最大大小为 16TB(ARM 上为 4GB)。

WOW64

Windows on Windows 64(WOW64) 是一个软件层,使得 32 位 x86 可执行文件能够在 64 位(x64)系统上无修改地运行。

可以这样类比:WOW64 就像中文翻译员,让只会说中文的 32 位程序能在只说英文的 64 位操作系统世界中正常工作和交流。

在 x64 系统上,原生(64 位)系统映像位于 System32 目录(如 c:\Windows\System32),而 32 位映像位于 SysWow64 目录(如 c:\Windows\SysWow64)。这种分离是必要的,因为"32 位进程不能加载 64 位 DLL,反之亦然"——指针大小和地址范围不兼容。唯一的例外是仅包含资源(字符串、位图等)的 DLL。

操作系统的内核始终是 64 位的,因此所有系统调用必须以 64 位模式通过 64 位的 NtDll 进行。WOW64 中的 32 位 NtDll 并不直接发起系统调用——它调用辅助的翻译 DLL,后者负责处理必要的转换(调整指针大小和其他参数),然后再调用真正的 64 位 NtDll。

从内核的角度来看,32 位和 64 位的 DLL 被加载到同一个进程空间中——并不存在所谓的"真正的 32 位进程"。使用 Process Explorer 可以看到两个版本的 NtDll 同时被加载:一份来自 System32,位于高地址区域(4GB 以上);另一份来自 SysWow64,位于 2GB 以下。

每个 WOW64 线程拥有两套栈和两个 TEB 结构——一套用于 32 位模式,另一套用于通过翻译 DLL 切换到 64 位模式时使用。某些 API 在 WOW64 进程中不可用,包括地址窗口扩展(AWE,Address Windowing Extension)、ReadFileScatterWriteFileGather 等。

WOW64 重定向

当一个 32 位 WOW64 进程调用 GetSystemDirectory 或尝试从 c:\Windows\System32\ws2.dll 加载 DLL 时会遇到问题——它无法加载 64 位 DLL。Windows 为此提供了文件系统重定向(File System Redirection)机制:"对 System32 目录的任何访问都会被自动、透明地重定向到 Syswow64 目录。"类似的,Program Files 会被重定向到 Program Files (x86)

线程可以临时禁用此重定向:

cpp
BOOL Wow64DisableWow64FsRedirection(_Out_ PVOID* OldValue);

OldValue 是一个不透明的值,用于重新启用重定向:

cpp
BOOL Wow64RevertWow64FsRedirection(_In_ PVOID OldValue);

"但禁用重定向只会影响当前线程。"如果需要在不关闭重定向的情况下访问真正的 System32 目录,可以使用虚拟路径 c:\Windows\Sysnative。类似的,某些注册表项也存在重定向机制(将在第 17 章介绍)。

虚拟地址转换

CPU 将地址视为虚拟地址而非物理地址(在保护模式/长模式下)。它会检查预先准备好的翻译表(Translation Tables)来定位对应的物理页面。如果页面不在 RAM 中(即有效位 Valid bit 为零),CPU 会触发页面错误(Page Fault),由内存管理器负责处理。

地址翻译的基本流程:

  • 地址的低 12 位(页面内偏移量,Page Offset)直接透传,不参与翻译。
  • 每个进程拥有一个始终驻留在 RAM 中的根结构(Root Structure):32 位系统上称为页目录指针表(Page Directory Pointer Table),64 位系统上称为第四级页映射(Page Map Level 4,Intel 术语)。
  • 从根结构开始,依次遍历更多层级的结构:页目录(Page Directories),最终到达页表(Page Table,翻译树的叶节点)。
  • 页表项(PTE,Page Table Entry)在有效位被设置时,指向物理页面的地址。
  • 当页面被移至页面文件时,内存管理器将对应 PTE 标记为无效。下次访问时触发页面错误,内存管理器再将其从磁盘换回。

可以这样类比:虚拟地址转换就像图书馆的图书检索系统。读者只记得书名(虚拟地址),图书馆的检索系统(翻译表)通过多级目录结构找到对应的索书号(物理地址)和书架位置。如果书当前不在书架上(页面不在 RAM 中),图书馆员会从仓库取来(页面错误处理)。

转译后备缓冲区(TLB,Translation Lookaside Buffer)是最近翻译过的页面的缓存,能够避免逐级遍历多层翻译结构。"这个缓存虽然相对较小,但从实际角度看极其重要。"在相近时间内反复访问相同内存地址范围,有利于充分利用 TLB 缓存,从而显著提升性能。

总结

本章系统介绍了虚拟内存和物理内存的核心概念,涵盖了以下主要内容:

  • 进程地址空间:每个进程拥有独立、私有的虚拟地址空间,不同位宽的系统和进程有不同的地址空间范围。
  • 页面状态:空闲(Free)、已提交(Committed)、保留(Reserved)三种状态及其访问行为。
  • 地址空间布局:32 位和 64 位系统下的地址空间划分,以及 LARGEADDRESSAWARE 标志对可用空间的影响。
  • 内存计数器:任务管理器中的各项指标,物理页面的各种列表(备用、已修改、零页、空闲),以及 GlobalMemoryStatusExGetPerformanceInfo 的用法。
  • 进程内存映射:通过 VirtualQueryExVMMapGetProcessMemoryInfo 了解进程的完整内存布局。
  • 页面保护:各种保护标志(PAGE_READONLY、PAGE_READWRITE、PAGE_WRITECOPY 等)以及保护页(PAGE_GUARD)的特殊行为。
  • 枚举地址空间:使用 VirtualQueryExMEMORY_BASIC_INFORMATION 遍历地址空间区域,以及使用 QueryWorkingSetEx 获取逐页工作集详情。
  • 共享内存:写时复制(Copy-on-Write)机制和通过共享数据段(RWS 属性)实现的显式数据共享。
  • 页面文件:作为私有已提交内存的后备存储,页面文件的配置和提交限制机制。
  • WOW64:32 位 x86 可执行文件在 64 位系统上无修改运行的软件层,以及文件系统和注册表的透明重定向机制。
  • 虚拟地址转换:多级页表(Page Table)的翻译过程,页面错误(Page Fault)的处理机制,以及 TLB 缓存的关键作用。

下一章将深入介绍与内存相关的 API 的实际用法。