Skip to content
Published at:

第13章:内存操作 ​

内存 API ​

Windows 提供多组内存操作 API,从低级到高级排列,形成一个层次化的内存管理体系。最底层的是虚拟 API(Virtual API),与内存管理器最为接近,以页(Page)为粒度操作;最上层是 C/C++ 运行时函数,如 malloc 和 free,它们内部调用堆 API,再由堆 API 调用虚拟 API。图 13-1 展示了这些 API 组及其依赖关系。

核心 API 组自上而下包括:

  1. C/C++ 运行时函数(CRT):malloc、free、new、delete 等,对开发者最为友好
  2. 堆 API(Heap API):HeapAlloc、HeapFree 等,高效管理小内存分配
  3. 本地/全局 API(Local/Global API):LocalAlloc、GlobalAlloc 等,主要为兼容 16 位 Windows
  4. 虚拟 API(Virtual API):VirtualAlloc、VirtualFree 等,功能最强大,直接与内存管理器交互

VirtualAlloc 系列函数 ​

VirtualAlloc(Virtual Memory Allocation)是最底层的虚拟内存 API,具有以下核心特性:

  • 功能最强大,几乎能完成所有虚拟内存操作
  • 始终以页为单位进行操作,操作的地址必须是页边界(Page Boundary)
  • 被更高级别的 API(堆 API、CRT 等)所使用

VirtualAlloc ​

VirtualAlloc 用于保留(Reserve)和/或提交(Commit)内存。其函数原型接收可选地址、大小、分配类型和保护标志:

cpp
LPVOID VirtualAlloc(
    _In_opt_ LPVOID lpAddress,
    _In_     SIZE_T dwSize,
    _In_     DWORD  flAllocationType,
    _In_     DWORD  flProtect
);

第一个参数 lpAddress 是可选指针,指定操作的目标位置。新分配通常传 NULL,让内存管理器自动寻找空闲地址区域。传入的地址会被向下舍入到最近的页边界(通常 4KB),新的保留操作则舍入到分配粒度(Allocation Granularity,当前为 64KB),可通过 GetSystemInfo 获取。

dwSize 是要保留或提交的大小。当 lpAddress 为 NULL 时,大小会向上舍入到页边界。例如,请求 1KB 实际得到 4KB(1 个页),请求 50KB 实际得到 52KB(13 个页)。

flAllocationType 的常见标志为 MEM_RESERVE(保留地址空间)和 MEM_COMMIT(提交物理存储)。可以组合使用这两个标志,在一次调用中同时保留并提交内存:

cpp
auto p = ::VirtualAlloc(nullptr, 1 << 20, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);

书中提到一个历史遗留问题:从技术上讲,可以仅使用 MEM_COMMIT 来同时提交和保留内存,这在严格意义上并不正确,但由于许多开发人员依赖此行为,微软决定不修复这个"漏洞"。

任何已提交的页面保证被填充为零(Zero-filled),这是出于安全要求——防止进程意外看到其他进程残留的内存数据。

其他分配类型标志:

标志说明
MEM_RESET表明内存不再需要,页面内容无需写入页面文件(Page File)
MEM_RESET_UNDOMEM_RESET 的反向操作
MEM_LARGE_PAGES使用大页面(Large Pages)分配
MEM_PHYSICAL用于地址窗口化扩展(AWE)
MEM_TOP_DOWN优先从高地址开始分配
MEM_WRITE_WATCH跟踪对已提交区域的写入操作

VirtualAllocEx ​

VirtualAllocEx 可在不同进程中操作内存,多了一个进程句柄参数,该句柄需要 PROCESS_VM_OPERATION 访问掩码:

cpp
LPVOID VirtualAllocEx(
    _In_ HANDLE hProcess,
    _In_opt_ LPVOID lpAddress,
    _In_ SIZE_T dwSize,
    _In_ DWORD flAllocationType,
    _In_ DWORD flProtect
);

VirtualAllocFromApp ​

VirtualAllocFromApp 是 Windows 10 为 UWP(Universal Windows Platform)进程增加的变体。在 UWP 进程中,VirtualAlloc 会被内联定义为调用此函数,以施加额外的安全限制。

VirtualAllocExNuma ​

VirtualAllocExNuma 用于 NUMA(Non-Uniform Memory Access,非统一内存访问)架构,允许指定首选 NUMA 节点进行分配。

VirtualAlloc2 ​

VirtualAlloc2 在 Windows 10 1803 引入,整合了多种 VirtualAlloc 变体的功能,在后续章节专门讨论。

取消提交与释放内存 ​

VirtualFree 和 VirtualFreeEx 是 VirtualAlloc 的反向操作函数。dwFreeType 仅支持两个标志:

  • MEM_DECOMMIT — 取消提交,将已提交页面恢复为保留状态。页面内容被丢弃,再次访问时页面被清零
  • MEM_RELEASE — 完全释放区域,取消提交并释放地址空间

使用 MEM_RELEASE 时有重要限制:lpAddress 必须是最初保留区域的基地址,并且 dwSize 必须为零。这意味着不能部分释放一个保留区域——必须整体释放。

cpp
BOOL VirtualFree(
    _In_ LPVOID lpAddress,
    _In_ SIZE_T dwSize,
    _In_ DWORD dwFreeType
);

使用 MEM_DECOMMIT 时,可以取消提交区域中的部分页面,lpAddress 和 dwSize 指定要取消提交的范围。

保留和提交内存 ​

对于大内存分配,VirtualAlloc 是不错的选择,因为它以页粒度操作。小分配(几十到几百字节)则更推荐使用堆函数,因为堆的效率更高且避免浪费地址空间。

提交内存并不意味着立即分配物理 RAM——它增加系统的总提交量(Commit Charge),保证在实际访问时内存一定可用。具体流程如下:

  1. 提交内存时,系统在页面文件中预留空间(或确认有足够的物理内存)
  2. 进程实际访问某页面时,系统将该页面放入 RAM(物理内存)
  3. 如果物理内存不足,系统可能将其他不常用的页面换出(Swap Out)到磁盘

这种"按需分配"(Demand Paging)机制使得进程可以提交远超实际物理内存大小的虚拟内存。

微型 Excel 应用程序 ​

书中展示了一个使用保留/提交策略的示例——微型 Excel 应用程序。该示例的核心思路是:

  1. 先保留 1GB 的地址空间(不提交物理存储)
  2. 根据用户输入的单元格坐标计算对应地址
  3. 按需提交对应页面(每次提交一个页面 4KB,覆盖 4 个单元格,每单元格 1KB)
  4. 通过结构化异常处理(SEH,Structured Exception Handling)捕获访问冲突,自动提交所需内存

首先,保留大块地址空间:

cpp
void* m_Address = ::VirtualAlloc(nullptr, TotalSize, MEM_RESERVE, PAGE_READWRITE);

计算给定坐标单元格的地址:

cpp
void* CMainDlg::GetCell(int& x, int& y, bool reportError) const {
    x = GetDlgItemInt(IDC_CELLX);
    y = GetDlgItemInt(IDC_CELLY);
    if (x < 0 || x >= SizeX || y < 0 || y >= SizeY) {
        if (reportError)
            AtlMessageBox(*this, L"Indices out of range",
                IDR_MAINFRAME, MB_ICONEXCLAMATION);
        return nullptr;
    }
    return (BYTE*)m_Address + CellSize * ((size_t)x + SizeX * y);
}

写操作使用 __try/__except 捕获访问冲突异常:

cpp
__try {
    ::wcscpy_s((WCHAR*)p, CellSize / sizeof(WCHAR), text);
}
__except (FixMemory(p, GetExceptionCode())) {
    // 此代码永远不会执行
}

修复函数尝试提交内存并让 CPU 重试引发异常的指令:

cpp
int CMainDlg::FixMemory(void* address, DWORD exceptionCode) {
    if (exceptionCode == EXCEPTION_ACCESS_VIOLATION) {
        ::VirtualAlloc(address, CellSize, MEM_COMMIT, PAGE_READWRITE);
        return EXCEPTION_CONTINUE_EXECUTION;
    }
    return EXCEPTION_CONTINUE_SEARCH;
}

FixMemory 返回 EXCEPTION_CONTINUE_EXECUTION 后,CPU 会重新执行引发异常的那条指令,此时内存已提交,操作成功。每次提交一个页面(4KB),覆盖 4 个单元格(每单元格 1KB)。通过 VMMap 等工具可以直观地查看大保留区域内已提交的"洞"。应用程序还使用定时器和 VirtualQuery 遍历所有已提交页面来计算总提交大小。

工作集(Working Sets) ​

工作集(Working Set)指进程可在不引发页面错误(Page Fault)的情况下访问的内存页面集合。当进程访问某个虚拟地址时,如果对应的物理页面已在工作集中,访问成功;否则触发页面错误,系统需要从磁盘或其他位置加载数据。

长时间未访问的内存可能被内存管理器移出工作集,但内存管理器有复杂的算法,物理页面保留的时间可能比实际需要的时间更长。如果页面已被移出工作集但仍存在于物理内存中(例如在备用列表 Standby List 中),再次访问时触发软页面错误(Soft Page Fault),开销很小;如果页面已被写入磁盘,则触发硬页面错误(Hard Page Fault),开销显著。

通过 GetProcessMemoryInfo 可以获取当前和峰值工作集大小:

cpp
PROCESS_MEMORY_COUNTERS_EX counters;
::GetProcessMemoryInfo(::GetCurrentProcess(),
    (PROCESS_MEMORY_COUNTERS*)&counters, sizeof(counters));
// counters.WorkingSetSize       — 当前工作集大小
// counters.PeakWorkingSetSize   — 峰值工作集大小

工作集限制 ​

进程有最小工作集(Minimum Working Set)和最大工作集(Maximum Working Set)。默认情况下,这些值是软限制(Soft Limit)——物理内存充足时工作集可以超过最大值,内存紧张时工作集可以低于最小值。可以通过 SetProcessWorkingSetSizeEx 设置为硬限制。

通过 GetProcessWorkingSetSize 查询,SetProcessWorkingSetSize 修改:

cpp
SIZE_T minWS, maxWS;
::GetProcessWorkingSetSize(hProcess, &minWS, &maxWS);
::SetProcessWorkingSetSize(hProcess, newMin, newMax);

进程句柄需要 PROCESS_SET_QUOTA 访问掩码。若设置值高于当前最大工作集,调用者需要 SE_INC_WORKING_SET_NAME 特权(默认所有用户都拥有此特权)。

一些重要数值:最小工作集的最小值是 20 页(80KB),最大工作集的最小值是 13 页(52KB)。将两个值都传 (SIZE_T)-1 会触发尽可能多地移除页面(Trim Working Set),EmptyWorkingSet 也有相同效果。

SetProcessWorkingSetSizeEx 支持硬/软限制标志:

标志说明
QUOTA_LIMITS_HARDWS_MIN_ENABLE (1)最小工作集硬限制
QUOTA_LIMITS_HARDWS_MIN_DISABLE (2)最小工作集软限制
QUOTA_LIMITS_HARDWS_MAX_ENABLE (4)最大工作集硬限制
QUOTA_LIMITS_HARDWS_MAX_DISABLE (8)最大工作集软限制

工作集应用程序 ​

书中展示了一个 WTL SDI 应用程序("Working Set App"),用于显示所有进程的内存计数器,包括工作集大小、峰值、最小/最大值等,每秒自动刷新。该应用通过 Toolhelp 函数(CreateToolhelp32Snapshot、Process32First、Process32Next)枚举所有进程,使用 OpenProcess 打开句柄(请求 PROCESS_QUERY_LIMITED_INFORMATION 权限),对无法打开的进程(如果启用了"仅显示可访问进程"选项)则跳过。

在视图刷新函数中,为每个进程填充 ProcessInfo 结构体,包含进程 ID、映像名称、工作集限制、标志和 PROCESS_MEMORY_COUNTERS_EX 计数器。清空工作集通过 SetProcessWorkingSetSize(hProcess, (SIZE_T)-1, (SIZE_T)-1) 实现。

堆(Heaps) ​

堆(Heap)是位于虚拟 API 之上的内存管理器,高效地管理小内存分配。每个进程从默认进程堆(Default Process Heap)开始,句柄可通过 GetProcessHeap 获取:

cpp
HANDLE hHeap = ::GetProcessHeap();

HeapAlloc ​

HeapAlloc 从指定堆中分配内存块:

cpp
LPVOID HeapAlloc(
    _In_ HANDLE hHeap,
    _In_ DWORD  dwFlags,
    _In_ SIZE_T dwBytes
);

标志包括:

  • HEAP_ZERO_MEMORY — 将分配的内存块清零
  • HEAP_NO_SERIALIZE — 不获取堆锁,调用者需自行同步(适用于单线程或外部同步的场景)
  • HEAP_GENERATE_EXCEPTIONS — 分配失败时引发 STATUS_NO_MEMORY 异常,而非返回 NULL

示例分配:

cpp
MyData* pData = (MyData*)::HeapAlloc(::GetProcessHeap(), 0, sizeof(MyData));

HeapReAlloc ​

HeapReAlloc 调整已分配块的大小,支持 HEAP_REALLOC_IN_PLACE_ONLY 标志以避免复制数据到新位置:

cpp
LPVOID HeapReAlloc(
    _In_ HANDLE hHeap,
    _In_ DWORD  dwFlags,
    _In_ LPVOID lpMem,
    _In_ SIZE_T dwBytes
);

HeapFree ​

HeapFree 释放内存块。与 VirtualFree 不同,释放后从该地址读取不会引发访问冲突——从"地址仍指向已提交内存"的意义上讲,它仍然是有效的(但不应该这样做):

cpp
BOOL HeapFree(
    _In_ HANDLE hHeap,
    _In_ DWORD dwFlags,
    _In_ LPVOID lpMem
);

私有堆 ​

堆最初是一块保留内存区域,其中部分可能已提交。堆可以是固定最大大小或可增长的。默认进程堆是可增长的,其初始保留和提交大小可通过链接器设置(Visual Studio 项目属性中的堆预留大小和堆提交大小)。

默认情况下,初始提交为单个页面(4KB),保留大小为 1MB。创建私有堆(Private Heap)的主要原因包括:

  • 避免碎片化:为特定大小的对象使用独立堆,避免不同大小对象的分配/释放相互干扰
  • 管理特定大小对象:为固定大小结构体分配专用堆,提高分配效率
  • 批量释放:一次性销毁整个堆,无需逐个释放每个对象

通过 HeapCreate 创建私有堆:

cpp
HANDLE HeapCreate(
    _In_ DWORD  flOptions,
    _In_ SIZE_T dwInitialSize,
    _In_ SIZE_T dwMaximumSize
);

标志说明:

  • HEAP_GENERATE_EXCEPTIONS — 分配失败时引发异常
  • HEAP_NO_SERIALIZE — 不进行内部同步
  • HEAP_CREATE_ENABLE_EXECUTE — 以可执行权限分配内存(用于动态生成代码的场景)

若 dwMaximumSize 为零,则堆可增长(类似于默认进程堆);否则为固定大小堆。固定大小堆在 32 位系统上最大分配略小于 512KB,在 64 位上略小于 1MB。如果需要更大的分配,应使用 VirtualAlloc。

HeapDestroy 一次性释放整个堆,无需逐个释放堆中的分配块。这对于批量创建和销毁对象的场景非常高效。

私有堆与 C++ new/delete 重载 ​

私有堆可以与 C++ 的 new/delete 运算符重载结合使用,使客户端代码无需关心底层内存管理:

cpp
void* MyClass::operator new(size_t size) {
    if (::InterlockedIncrement(&s_Count) == 1)
        s_hHeap = ::HeapCreate(0, 64 << 10, 16 << 20);
    return ::HeapAlloc(s_hHeap, 0, size);
}

void MyClass::operator delete(void* p) {
    ::HeapFree(s_hHeap, 0, p);
    if (::InterlockedDecrement(&s_Count) == 0)
        ::HeapDestroy(s_hHeap);
}

客户端使用普通的 new / delete 语法,无需关心底层实现——堆在第一个对象创建时自动创建,在最后一个对象销毁时自动销毁。

堆类型 ​

低碎片化堆(LFH, Low Fragmentation Heap) ​

对于未设置 HEAP_NO_SERIALIZE 的堆,Windows 支持低碎片化堆(LFH, Low Fragmentation Heap),通过使用特定大小的桶(Bucket)最小化碎片化。例如,8 字节和 12 字节的分配都获得 16 字节的空间(属于同一个桶大小)。

在现代 Windows 中,LFH 自动激活且无法手动关闭或强制使用(Vista 之前可通过 API 手动控制)。可通过 HeapQueryInformation 查询堆类型:

cpp
ULONG heapType;
::HeapQueryInformation(hHeap, HeapCompatibilityInformation,
    &heapType, sizeof(heapType), nullptr);
// heapType == 0: 无 LFH
// heapType == 2: 使用 LFH

段堆(Segment Heap) ​

段堆(Segment Heap)在 Windows 8 中引入,对块管理更好,采取了额外的安全措施,防止攻击者通过校验指针来识别堆块。所有 UWP 进程默认使用段堆,一些系统进程(smss.exe、csrss.exe、svchost.exe 等)也使用段堆。

由于兼容性原因,段堆不是桌面应用程序的默认堆。可通过注册表为特定应用程序启用:在 HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options 中创建以可执行文件名为名的子项,添加 FrontEndHeapDebugOptions DWORD 值,设为 8。

堆调试功能 ​

HeapValidate ​

HeapValidate 扫描堆中所有已分配块的完整性。若 lpMem 为 NULL,则扫描整个堆;否则仅检查指定块。对于段堆,当 lpMem 为 NULL 时总是返回 TRUE。

cpp
BOOL HeapValidate(
    _In_ HANDLE hHeap,
    _In_ DWORD  dwFlags,
    _In_opt_ LPCVOID lpMem
);

HeapSetInformation ​

使用 HeapEnableTerminationOnCorruption 信息类,在检测到堆损坏时立即终止进程,防止进一步的数据损坏。此选项针对整个进程生效(堆句柄可传 NULL),且启用后无法禁用:

cpp
::HeapSetInformation(nullptr, HeapEnableTerminationOnCorruption, nullptr, 0);

GFlags ​

通过 GFlags 工具(包含在 Windows 调试工具包中)设置 NtGlobalFlags 可启用其他堆调试选项,包括:

  • 页堆(Page Heap):在每个分配后放置一个守卫页(Guard Page),访问越界时立即触发访问冲突
  • 堆尾检查(Heap Tail Checking):在分配末尾添加签名,释放时验证完整性
  • 释放检查(Free Checking):释放时用特定模式填充内存

使用这些调试选项会显著减慢所有堆操作,建议仅在排查内存相关问题时使用。

C/C++ 运行时(CRT) ​

Visual C++ 运行时中,malloc 等函数内部使用默认进程堆。简化后的源码显示 _malloc_base 内部调用 HeapAlloc(acrt_heap, 0, actual_size),而 acrt_heap 就是 GetProcessHeap() 的返回值。换句话说,malloc(size) 本质上等价于 HeapAlloc(GetProcessHeap(), 0, size)。

这意味着:

  • 使用 malloc 分配的内存可以使用 HeapFree 释放(不推荐跨 API 操作)
  • 不同编译器版本的 CRT 使用相同的底层堆
  • CRT 在自己的分配上添加了额外的簿记(Bookkeeping)和调试支持

本地/全局 API ​

LocalAlloc、GlobalAlloc、LocalFree、GlobalFree 等 API 主要是为兼容 16 位 Windows 而保留的。这些函数使用句柄(Handle)而非指针,分配后需要通过 LocalLock / GlobalLock 将句柄转换为指针,使用完毕后通过 LocalUnlock / GlobalUnlock 解锁。

在现代 Windows 编程中,这些 API 仅有以下少数场景需要:

  1. 剪贴板操作:需要使用 GlobalAlloc 返回的 HGLOBAL 句柄
  2. 某些安全 API:分配数据后要求调用者使用 LocalFree 释放
  3. 与旧代码或特定 API 的兼容性

其他情况应优先使用堆 API、C/C++ 运行时 API 或虚拟内存 API。

其他堆函数 ​

HeapSummary ​

HeapSummary 提供堆的汇总信息。调用前需先将结构体的 cb 成员初始化为结构体大小:

cpp
HEAP_SUMMARY summary;
summary.cb = sizeof(summary);
::HeapSummary(hHeap, 0, &summary);
// summary.cbAllocated   — 当前已分配字节数
// summary.cbCommitted   — 当前已提交字节数
// summary.cbReserved    — 预留内存大小
// summary.cbMaxReserved — 当前与 cbReserved 相同

HeapSize ​

HeapSize 查询已分配块的实际大小(可能大于请求的大小):

cpp
SIZE_T HeapSize(
    _In_ HANDLE hHeap,
    _In_ DWORD dwFlags,
    _In_ LPCVOID lpMem
);

HeapLock / HeapUnlock ​

HeapLock 和 HeapUnlock 获取和释放堆的临界区(Critical Section)。在多线程环境中进行堆遍历等操作前需要先锁定堆,操作完成后解锁:

cpp
::HeapLock(hHeap);
// 对堆进行操作...
::HeapUnlock(hHeap);

HeapWalk ​

HeapWalk 遍历堆中的所有块。遍历前必须先调用 HeapLock 锁定堆。枚举通过 PROCESS_HEAP_ENTRY 结构体实现,将 lpData 设为 NULL 开始首次调用,之后每次调用返回下一个已分配块的数据,返回 FALSE 时遍历结束:

cpp
PROCESS_HEAP_ENTRY entry = { 0 };
::HeapLock(hHeap);
while (::HeapWalk(hHeap, &entry)) {
    // 处理 entry
}
::HeapUnlock(hHeap);

对于其他进程的堆遍历,Toolhelp 提供了 TH32CS_SNAPHEAPLIST 标志配合 CreateToolhelp32Snapshot 使用。

HeapCompact ​

HeapCompact 合并相邻空闲块。当"禁用释放时的堆合并"(Disable Heap Coalesce On Free)全局标志启用时,此函数特别有用:

cpp
SIZE_T HeapCompact(
    _In_ HANDLE hHeap,
    _In_ DWORD dwFlags
);

GetProcessHeaps ​

GetProcessHeaps 获取当前进程中所有堆的句柄数组。可以先传 0 和 nullptr 获取堆数量,再分配缓冲区获取所有句柄:

cpp
DWORD count = (DWORD)::GetProcessHeaps(0, nullptr);
std::vector<HANDLE> heaps(count);
::GetProcessHeaps(count, heaps.data());

其他虚拟函数 ​

内存保护 ​

VirtualProtect 系列函数更改已提交页面的保护属性。VirtualProtectEx 可在不同进程操作(需要 PROCESS_VM_OPERATION 访问掩码),VirtualProtectFromApp 用于 UWP 进程。

cpp
BOOL VirtualProtect(
    _In_  LPVOID lpAddress,
    _In_  SIZE_T dwSize,
    _In_  DWORD  flNewProtect,
    _Out_ PDWORD lpflOldProtect
);

从 lpAddress 到 lpAddress + dwSize 范围内所有页面的保护属性会被更改,旧的保护属性通过 lpflOldProtect 返回。常用保护常量包括:

保护常量说明
PAGE_NOACCESS禁止所有访问
PAGE_READONLY只读
PAGE_READWRITE可读写
PAGE_EXECUTE可执行
PAGE_EXECUTE_READ可执行并读取
PAGE_EXECUTE_READWRITE可执行、读取和写入
PAGE_GUARD守卫页,首次访问时引发异常

锁定内存 ​

VirtualLock 告知内存管理器指定缓冲区不应被换出到磁盘,VirtualUnlock 解除锁定:

cpp
BOOL VirtualLock(
    _In_ LPVOID lpAddress,
    _In_ SIZE_T dwSize
);

进程可锁定的最大内存量略小于其最小工作集大小。如果需要锁定更大的块,必须先调用 SetProcessWorkingSetSize(Ex) 增加最小工作集大小。注意锁定过多内存可能影响系统整体性能。

内存提示函数 ​

OfferVirtualMemory(Windows 8.1+)告知系统已提交内存不再需要,系统可丢弃对应的物理页面而无需先将其写入页面文件。优先级参数(OFFER_PRIORITY 枚举)指定页面的重要性,值越低越早被丢弃:

优先级说明
VmOfferPriorityVeryLow最低优先级,最早被丢弃
VmOfferPriorityLow低优先级
VmOfferPriorityBelowNormal低于正常
VmOfferPriorityNormal正常优先级

地址必须页对齐,大小必须为页大小的倍数。该函数直接返回错误代码,ERROR_SUCCESS 表示成功。

ReclaimVirtualMemory 收回之前通过 OfferVirtualMemory 释放的内存,内容可能包含也可能不包含先前的数据。

DiscardVirtualMemory 相当于以 VmOfferPriorityVeryLow 优先级调用 OfferVirtualMemory,是一种便捷简写。

PrefetchVirtualMemory(Windows 8+)提供非连续内存块数组(WIN32_MEMORY_RANGE_ENTRY 结构),提示内存管理器使用大缓冲区并发 I/O 更快地获取数据。这纯粹是性能优化手段,不是功能必需的调用:

cpp
WIN32_MEMORY_RANGE_ENTRY entries[] = {
    { p1, size1 },
    { p2, size2 },
};
::PrefetchVirtualMemory(::GetCurrentProcess(),
    _countof(entries), entries, 0);

读写其他进程的内存 ​

ReadProcessMemory 和 WriteProcessMemory 允许拥有足够权限句柄的进程访问其他进程的地址空间。这是调试器、进程监控工具和代码注入技术的核心 API。

cpp
BOOL ReadProcessMemory(
    _In_  HANDLE  hProcess,
    _In_  LPCVOID lpBaseAddress,
    _Out_ LPVOID  lpBuffer,
    _In_  SIZE_T  nSize,
    _Out_ SIZE_T  *lpNumberOfBytesRead
);

ReadProcessMemory 需要 PROCESS_VM_READ 访问掩码,WriteProcessMemory 需要 PROCESS_VM_WRITE。即使进程句柄拥有相应的访问掩码,操作仍可能因目标页面的保护属性不兼容而失败。例如,无法向 PAGE_READONLY 保护的页面写入数据。在这种情况下,调用者可以尝试先使用 VirtualProtectEx 更改目标页面的保护属性。

主要使用场景:

  • 调试器:读取被调试进程的局部变量、线程栈、模块数据等
  • 代码注入:第 15 章将展示如何用 WriteProcessMemory 将 DLL 路径写入目标进程,然后创建远程线程加载 DLL

大页面(Large Pages) ​

Windows 支持两种页面大小:小页(Small Page,x86/x64 上为 4KB)和大页(Large Page,x86/x64 上为 2MB,ARM 上为 4MB)。使用 MEM_LARGE_PAGES 标志可以分配大页面。

使用大页面的优点:

  • 内部性能更好:虚拟地址到物理地址的转换不使用页表(Page Table),仅使用页目录(Page Directory),减少一级间接寻址
  • TLB 缓存效率更高:TLB(Translation Lookaside Buffer,页表缓存)条目数量有限,一个大页 TLB 条目可以映射 2MB 内存,而小页 TLB 条目仅映射 4KB
  • 始终不可换页:大页面永远不会被换出到磁盘,适用于对延迟敏感的应用程序

使用大页面的缺点:

  • 不能在进程间共享
  • 分配必须是大页大小的精确倍数
  • 物理内存碎片化时,可能无法找到连续的 2MB 物理内存块,导致分配失败
  • 需要 SeLockMemoryPrivilege 特权

获取必要特权 ​

SeLockMemoryPrivilege 通常未分配给任何用户或组(包括管理员)。获取方式:

  1. 通过本地安全策略或组策略,由管理员将需要此特权的用户或组添加到"锁定内存页"策略中,注销并重新登录后生效
  2. 以本地系统帐户(Local System Account)运行的服务可以在代码中请求所需特权

启用特权的示例函数:

cpp
bool EnableLockMemoryPrivilege() {
    HANDLE hToken;
    if (!::OpenProcessToken(::GetCurrentProcess(),
            TOKEN_ADJUST_PRIVILEGES, &hToken))
        return false;
    bool result = false;
    TOKEN_PRIVILEGES tp;
    tp.PrivilegeCount = 1;
    tp.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED;
    if (::LookupPrivilegeValue(nullptr, SE_LOCK_MEMORY_NAME,
            &tp.Privileges[0].Luid)) {
        if (::AdjustTokenPrivileges(hToken, FALSE, &tp,
                sizeof(tp), nullptr, nullptr))
            result = ::GetLastError() == ERROR_SUCCESS;
    }
    ::CloseHandle(hToken);
    return result;
}

使用大页分配 ​

GetLargePageMinimum 查询当前系统上大页面的大小:

cpp
auto largePage = ::GetLargePageMinimum();
auto p = ::VirtualAlloc(nullptr, 5 * largePage,
    MEM_RESERVE | MEM_COMMIT | MEM_LARGE_PAGES, PAGE_READWRITE);

超大页(Huge Pages, 1GB) ​

当使用 MEM_LARGE_PAGES 分配且分配大小至少为 1GB 时,系统优先尝试查找 1GB 的超大页(Huge Page),剩余部分使用 2MB 的大页。这进一步提升了 TLB 效率。

地址窗口化扩展(AWE) ​

地址窗口化扩展(AWE, Address Windowing Extensions)允许进程直接分配物理页面(Physical Pages),然后将这些物理页面映射到自己的地址空间中。AWE 的核心动机是让 32 位进程突破 4GB 虚拟地址空间的限制——分配的物理内存量可以超过 32 位地址空间容量,应用程序通过映射不同的"窗口"来访问不同的物理内存区域。

"唯一广为人知的在 32 位系统上利用 AWE 获取大内存优势的应用程序是 SQL Server。"

AWE 使用要求 ​

  • 需要 SeLockMemoryPrivilege 特权
  • AWE 页面不可换页(Non-pageable)
  • 必须使用 PAGE_READWRITE 保护(不能使用其他保护属性)
  • 在 64 位 Windows 上运行的 32 位进程(WOW64)无法使用 AWE

AWE 使用流程 ​

  1. 分配物理页面:
cpp
ULONG_PTR pages[Count];
::AllocateUserPhysicalPages(hProcess, &count, pages);
  1. 保留映射区域:
cpp
auto p = ::VirtualAlloc(nullptr, PageCount * PageSize,
    MEM_RESERVE | MEM_PHYSICAL, PAGE_READWRITE);
  1. 映射物理页面到地址空间:
cpp
::MapUserPhysicalPages(p, PageCount, pages);
  1. 使用内存(通过 p 指针访问物理页面)

  2. 清理:

cpp
::FreeUserPhysicalPages(hProcess, &count, pages);
::VirtualFree(p, 0, MEM_RELEASE);

注意:MapUserPhysicalPages 返回的物理页面可能不连续——每个页面的物理地址独立,但它们在虚拟地址空间中是连续的。

如今,64 位系统无需任何特殊 API 即可访问任意数量的物理内存,AWE 的复杂性和特权要求使其在新代码中几乎不再有用。

非统一内存访问(NUMA) ​

NUMA(Non-Uniform Memory Access,非统一内存访问)是一种多处理器架构,系统由多个节点(Node)构成,每个节点包含一组处理器和本地内存。从本地节点访问内存(Local Memory Access)的速度远快于访问其他节点的内存(Remote Memory Access)。Windows 调度器会尝试将线程调度到其栈所在物理内存对应的 CPU 上,以优化访问性能。

NUMA 查询函数 ​

GetNumaHighestNodeNumber 获取系统中的最高 NUMA 节点编号(返回 0 表示非 NUMA 系统或仅有一个节点):

cpp
ULONG highestNode;
::GetNumaHighestNodeNumber(&highestNode);

GetNumaNodeProcessorMaskEx 获取指定节点的处理器亲和性掩码,通过 GROUP_AFFINITY 结构返回处理器组和位掩码:

cpp
GROUP_AFFINITY group;
::GetNumaNodeProcessorMaskEx(node, &group);
// group.Group — 处理器组编号
// group.Mask  — 处理器位掩码

GetNumaAvailableMemoryNodeEx 获取节点上的可用物理内存量。

NUMA 信息输出示例 ​

cpp
void NumaInfo() {
    ULONG highestNode;
    ::GetNumaHighestNodeNumber(&highestNode);
    printf("NUMA nodes: %u\n", highestNode + 1);
    GROUP_AFFINITY group;
    for (USHORT node = 0; node <= (USHORT)highestNode; node++) {
        ::GetNumaNodeProcessorMaskEx(node, &group);
        printf("Node %d:\tProcessor Group: %2d, Affinity: 0x%08zX\n",
            (int)node, group.Group, group.Mask);
        ULONGLONG bytes;
        ::GetNumaAvailableMemoryNodeEx(node, &bytes);
        printf("\tAvailable memory: %llu KB\n", bytes >> 10);
    }
}

示例输出(2 节点,每节点 4 处理器):

NUMA nodes: 2
Node 0: Processor Group: 0, Affinity: 0x0000000F
        Available memory: 3567936 KB
Node 1: Processor Group: 0, Affinity: 0x000000F0
        Available memory: 3283832 KB

VirtualAllocExNuma ​

VirtualAllocExNuma 允许指定首选 NUMA 节点进行分配。nndPreferred 参数指定节点编号,仅在初始保留或保留并提交时有效,后续对该区域的 VirtualAlloc 提交调用忽略此参数。

在非 NUMA 系统上,可通过 Hyper-V 虚拟机模拟 NUMA 节点进行测试(在虚拟机 CPU 节点的 NUMA 设置中配置,需禁用动态内存)。

VirtualAlloc2 函数 ​

VirtualAlloc2 在 Windows 10 1803 中引入,整合了多种 VirtualAlloc 变体的功能。通过一次调用即可实现多种高级分配需求:

  • 在指定进程中分配内存(不同进程)
  • 选择首选 NUMA 节点
  • 特定内存对齐
  • 使用 AWE 技术
  • 内存分区(Memory Partition,半公开概念,超出本书范围)

最后两个参数是 MEM_EXTENDED_PARAMETER 结构体数组及其数量。结构体根据 Type 成员(MEM_EXTENDED_PARAMETER_TYPE 枚举)选择联合体中哪个成员有效:

cpp
typedef enum MEM_EXTENDED_PARAMETER_TYPE {
    MemExtendedParameterInvalidType = 0,
    MemExtendedParameterAddressRequirements,  // 地址对齐要求
    MemExtendedParameterNumaNode,             // 首选 NUMA 节点
    MemExtendedParameterPartitionHandle,      // 分区句柄
    MemExtendedParameterUserPhysicalHandle,   // AWE 物理页面句柄
    MemExtendedParameterAttributeFlags,       // 属性标志
    MemExtendedParameterMax
} MEM_EXTENDED_PARAMETER_TYPE;

设置 NUMA 节点的示例:

cpp
MEM_EXTENDED_PARAMETER param = { 0 };
param.Type = MemExtendedParameterNumaNode;
param.ULong = 1;   // NUMA 节点编号
auto p = ::VirtualAlloc2(::GetCurrentProcess(), nullptr, 1 << 30,
    MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE, &param, 1);

VirtualAlloc2 的设计理念是"一个统一接口替代多个专用函数",避免为每种扩展需求引入新的 API 变体。

总结 ​

本章全面探讨了 Windows 系统提供的各种内存管理 API,从最底层到最上层构成了完整的内存管理层次:

  1. 虚拟 API(VirtualAlloc 系列):以页为粒度操作,功能最强大,是其他所有内存 API 的底层基础。核心概念包括保留(Reserve)地址空间和提交(Commit)物理存储的分离,支持按需提交策略(微型 Excel 示例)。

  2. 工作集管理:控制进程驻留在物理内存中的页面集合,通过最小/最大工作集限制管理物理内存使用,支持软限制和硬限制。

  3. 堆 API:基于虚拟 API 构建,高效管理小内存分配。支持默认进程堆、私有堆、低碎片化堆(LFH)和段堆(Segment Heap),提供丰富的调试功能。

  4. C/C++ 运行时:malloc/free 等函数内部使用默认进程堆,为开发者提供最熟悉的接口。

  5. 跨进程内存访问:ReadProcessMemory 和 WriteProcessMemory 允许调试器等工具读写其他进程的地址空间。

  6. 高级特性:

    • 大页面(Large Pages):通过 2MB 或 1GB 页面提升 TLB 效率和内部性能
    • AWE:允许 32 位进程突破地址空间限制(目前几乎不再使用)
    • NUMA:在多节点系统上优化内存访问的局部性
    • VirtualAlloc2:Windows 10 1803 引入的统一接口,整合多种扩展功能
  7. 内存保护与锁定:通过 VirtualProtect 控制页面访问权限,通过 VirtualLock 防止关键内存被换出。

  8. 内存提示函数(Windows 8+):允许应用向系统提供内存使用意图的提示,优化内存管理决策。

下一章将深入探讨内存映射文件(Memory-Mapped Files),以及它们在文件映射和进程间内存共享方面的强大功能。