第13章:内存操作
内存 API
Windows 提供多组内存操作 API,从低级到高级排列,形成一个层次化的内存管理体系。最底层的是虚拟 API(Virtual API),与内存管理器最为接近,以页(Page)为粒度操作;最上层是 C/C++ 运行时函数,如 malloc 和 free,它们内部调用堆 API,再由堆 API 调用虚拟 API。图 13-1 展示了这些 API 组及其依赖关系。
核心 API 组自上而下包括:
- C/C++ 运行时函数(CRT):
malloc、free、new、delete等,对开发者最为友好 - 堆 API(Heap API):
HeapAlloc、HeapFree等,高效管理小内存分配 - 本地/全局 API(Local/Global API):
LocalAlloc、GlobalAlloc等,主要为兼容 16 位 Windows - 虚拟 API(Virtual API):
VirtualAlloc、VirtualFree等,功能最强大,直接与内存管理器交互
VirtualAlloc 系列函数
VirtualAlloc(Virtual Memory Allocation)是最底层的虚拟内存 API,具有以下核心特性:
- 功能最强大,几乎能完成所有虚拟内存操作
- 始终以页为单位进行操作,操作的地址必须是页边界(Page Boundary)
- 被更高级别的 API(堆 API、CRT 等)所使用
VirtualAlloc
VirtualAlloc 用于保留(Reserve)和/或提交(Commit)内存。其函数原型接收可选地址、大小、分配类型和保护标志:
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(提交物理存储)。可以组合使用这两个标志,在一次调用中同时保留并提交内存:
auto p = ::VirtualAlloc(nullptr, 1 << 20, MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);书中提到一个历史遗留问题:从技术上讲,可以仅使用 MEM_COMMIT 来同时提交和保留内存,这在严格意义上并不正确,但由于许多开发人员依赖此行为,微软决定不修复这个"漏洞"。
任何已提交的页面保证被填充为零(Zero-filled),这是出于安全要求——防止进程意外看到其他进程残留的内存数据。
其他分配类型标志:
| 标志 | 说明 |
|---|---|
MEM_RESET | 表明内存不再需要,页面内容无需写入页面文件(Page File) |
MEM_RESET_UNDO | MEM_RESET 的反向操作 |
MEM_LARGE_PAGES | 使用大页面(Large Pages)分配 |
MEM_PHYSICAL | 用于地址窗口化扩展(AWE) |
MEM_TOP_DOWN | 优先从高地址开始分配 |
MEM_WRITE_WATCH | 跟踪对已提交区域的写入操作 |
VirtualAllocEx
VirtualAllocEx 可在不同进程中操作内存,多了一个进程句柄参数,该句柄需要 PROCESS_VM_OPERATION 访问掩码:
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 必须为零。这意味着不能部分释放一个保留区域——必须整体释放。
BOOL VirtualFree(
_In_ LPVOID lpAddress,
_In_ SIZE_T dwSize,
_In_ DWORD dwFreeType
);使用 MEM_DECOMMIT 时,可以取消提交区域中的部分页面,lpAddress 和 dwSize 指定要取消提交的范围。
保留和提交内存
对于大内存分配,VirtualAlloc 是不错的选择,因为它以页粒度操作。小分配(几十到几百字节)则更推荐使用堆函数,因为堆的效率更高且避免浪费地址空间。
提交内存并不意味着立即分配物理 RAM——它增加系统的总提交量(Commit Charge),保证在实际访问时内存一定可用。具体流程如下:
- 提交内存时,系统在页面文件中预留空间(或确认有足够的物理内存)
- 进程实际访问某页面时,系统将该页面放入 RAM(物理内存)
- 如果物理内存不足,系统可能将其他不常用的页面换出(Swap Out)到磁盘
这种"按需分配"(Demand Paging)机制使得进程可以提交远超实际物理内存大小的虚拟内存。
微型 Excel 应用程序
书中展示了一个使用保留/提交策略的示例——微型 Excel 应用程序。该示例的核心思路是:
- 先保留 1GB 的地址空间(不提交物理存储)
- 根据用户输入的单元格坐标计算对应地址
- 按需提交对应页面(每次提交一个页面 4KB,覆盖 4 个单元格,每单元格 1KB)
- 通过结构化异常处理(SEH,Structured Exception Handling)捕获访问冲突,自动提交所需内存
首先,保留大块地址空间:
void* m_Address = ::VirtualAlloc(nullptr, TotalSize, MEM_RESERVE, PAGE_READWRITE);计算给定坐标单元格的地址:
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 捕获访问冲突异常:
__try {
::wcscpy_s((WCHAR*)p, CellSize / sizeof(WCHAR), text);
}
__except (FixMemory(p, GetExceptionCode())) {
// 此代码永远不会执行
}修复函数尝试提交内存并让 CPU 重试引发异常的指令:
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 可以获取当前和峰值工作集大小:
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 修改:
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 获取:
HANDLE hHeap = ::GetProcessHeap();HeapAlloc
HeapAlloc 从指定堆中分配内存块:
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
示例分配:
MyData* pData = (MyData*)::HeapAlloc(::GetProcessHeap(), 0, sizeof(MyData));HeapReAlloc
HeapReAlloc 调整已分配块的大小,支持 HEAP_REALLOC_IN_PLACE_ONLY 标志以避免复制数据到新位置:
LPVOID HeapReAlloc(
_In_ HANDLE hHeap,
_In_ DWORD dwFlags,
_In_ LPVOID lpMem,
_In_ SIZE_T dwBytes
);HeapFree
HeapFree 释放内存块。与 VirtualFree 不同,释放后从该地址读取不会引发访问冲突——从"地址仍指向已提交内存"的意义上讲,它仍然是有效的(但不应该这样做):
BOOL HeapFree(
_In_ HANDLE hHeap,
_In_ DWORD dwFlags,
_In_ LPVOID lpMem
);私有堆
堆最初是一块保留内存区域,其中部分可能已提交。堆可以是固定最大大小或可增长的。默认进程堆是可增长的,其初始保留和提交大小可通过链接器设置(Visual Studio 项目属性中的堆预留大小和堆提交大小)。
默认情况下,初始提交为单个页面(4KB),保留大小为 1MB。创建私有堆(Private Heap)的主要原因包括:
- 避免碎片化:为特定大小的对象使用独立堆,避免不同大小对象的分配/释放相互干扰
- 管理特定大小对象:为固定大小结构体分配专用堆,提高分配效率
- 批量释放:一次性销毁整个堆,无需逐个释放每个对象
通过 HeapCreate 创建私有堆:
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 运算符重载结合使用,使客户端代码无需关心底层内存管理:
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 查询堆类型:
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。
BOOL HeapValidate(
_In_ HANDLE hHeap,
_In_ DWORD dwFlags,
_In_opt_ LPCVOID lpMem
);HeapSetInformation
使用 HeapEnableTerminationOnCorruption 信息类,在检测到堆损坏时立即终止进程,防止进一步的数据损坏。此选项针对整个进程生效(堆句柄可传 NULL),且启用后无法禁用:
::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 仅有以下少数场景需要:
- 剪贴板操作:需要使用
GlobalAlloc返回的HGLOBAL句柄 - 某些安全 API:分配数据后要求调用者使用
LocalFree释放 - 与旧代码或特定 API 的兼容性
其他情况应优先使用堆 API、C/C++ 运行时 API 或虚拟内存 API。
其他堆函数
HeapSummary
HeapSummary 提供堆的汇总信息。调用前需先将结构体的 cb 成员初始化为结构体大小:
HEAP_SUMMARY summary;
summary.cb = sizeof(summary);
::HeapSummary(hHeap, 0, &summary);
// summary.cbAllocated — 当前已分配字节数
// summary.cbCommitted — 当前已提交字节数
// summary.cbReserved — 预留内存大小
// summary.cbMaxReserved — 当前与 cbReserved 相同HeapSize
HeapSize 查询已分配块的实际大小(可能大于请求的大小):
SIZE_T HeapSize(
_In_ HANDLE hHeap,
_In_ DWORD dwFlags,
_In_ LPCVOID lpMem
);HeapLock / HeapUnlock
HeapLock 和 HeapUnlock 获取和释放堆的临界区(Critical Section)。在多线程环境中进行堆遍历等操作前需要先锁定堆,操作完成后解锁:
::HeapLock(hHeap);
// 对堆进行操作...
::HeapUnlock(hHeap);HeapWalk
HeapWalk 遍历堆中的所有块。遍历前必须先调用 HeapLock 锁定堆。枚举通过 PROCESS_HEAP_ENTRY 结构体实现,将 lpData 设为 NULL 开始首次调用,之后每次调用返回下一个已分配块的数据,返回 FALSE 时遍历结束:
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)全局标志启用时,此函数特别有用:
SIZE_T HeapCompact(
_In_ HANDLE hHeap,
_In_ DWORD dwFlags
);GetProcessHeaps
GetProcessHeaps 获取当前进程中所有堆的句柄数组。可以先传 0 和 nullptr 获取堆数量,再分配缓冲区获取所有句柄:
DWORD count = (DWORD)::GetProcessHeaps(0, nullptr);
std::vector<HANDLE> heaps(count);
::GetProcessHeaps(count, heaps.data());其他虚拟函数
内存保护
VirtualProtect 系列函数更改已提交页面的保护属性。VirtualProtectEx 可在不同进程操作(需要 PROCESS_VM_OPERATION 访问掩码),VirtualProtectFromApp 用于 UWP 进程。
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 解除锁定:
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 更快地获取数据。这纯粹是性能优化手段,不是功能必需的调用:
WIN32_MEMORY_RANGE_ENTRY entries[] = {
{ p1, size1 },
{ p2, size2 },
};
::PrefetchVirtualMemory(::GetCurrentProcess(),
_countof(entries), entries, 0);读写其他进程的内存
ReadProcessMemory 和 WriteProcessMemory 允许拥有足够权限句柄的进程访问其他进程的地址空间。这是调试器、进程监控工具和代码注入技术的核心 API。
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 通常未分配给任何用户或组(包括管理员)。获取方式:
- 通过本地安全策略或组策略,由管理员将需要此特权的用户或组添加到"锁定内存页"策略中,注销并重新登录后生效
- 以本地系统帐户(Local System Account)运行的服务可以在代码中请求所需特权
启用特权的示例函数:
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 查询当前系统上大页面的大小:
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 使用流程
- 分配物理页面:
ULONG_PTR pages[Count];
::AllocateUserPhysicalPages(hProcess, &count, pages);- 保留映射区域:
auto p = ::VirtualAlloc(nullptr, PageCount * PageSize,
MEM_RESERVE | MEM_PHYSICAL, PAGE_READWRITE);- 映射物理页面到地址空间:
::MapUserPhysicalPages(p, PageCount, pages);使用内存(通过
p指针访问物理页面)清理:
::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 系统或仅有一个节点):
ULONG highestNode;
::GetNumaHighestNodeNumber(&highestNode);GetNumaNodeProcessorMaskEx 获取指定节点的处理器亲和性掩码,通过 GROUP_AFFINITY 结构返回处理器组和位掩码:
GROUP_AFFINITY group;
::GetNumaNodeProcessorMaskEx(node, &group);
// group.Group — 处理器组编号
// group.Mask — 处理器位掩码GetNumaAvailableMemoryNodeEx 获取节点上的可用物理内存量。
NUMA 信息输出示例
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 KBVirtualAllocExNuma
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 枚举)选择联合体中哪个成员有效:
typedef enum MEM_EXTENDED_PARAMETER_TYPE {
MemExtendedParameterInvalidType = 0,
MemExtendedParameterAddressRequirements, // 地址对齐要求
MemExtendedParameterNumaNode, // 首选 NUMA 节点
MemExtendedParameterPartitionHandle, // 分区句柄
MemExtendedParameterUserPhysicalHandle, // AWE 物理页面句柄
MemExtendedParameterAttributeFlags, // 属性标志
MemExtendedParameterMax
} MEM_EXTENDED_PARAMETER_TYPE;设置 NUMA 节点的示例:
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, ¶m, 1);VirtualAlloc2 的设计理念是"一个统一接口替代多个专用函数",避免为每种扩展需求引入新的 API 变体。
总结
本章全面探讨了 Windows 系统提供的各种内存管理 API,从最底层到最上层构成了完整的内存管理层次:
虚拟 API(VirtualAlloc 系列):以页为粒度操作,功能最强大,是其他所有内存 API 的底层基础。核心概念包括保留(Reserve)地址空间和提交(Commit)物理存储的分离,支持按需提交策略(微型 Excel 示例)。
工作集管理:控制进程驻留在物理内存中的页面集合,通过最小/最大工作集限制管理物理内存使用,支持软限制和硬限制。
堆 API:基于虚拟 API 构建,高效管理小内存分配。支持默认进程堆、私有堆、低碎片化堆(LFH)和段堆(Segment Heap),提供丰富的调试功能。
C/C++ 运行时:
malloc/free等函数内部使用默认进程堆,为开发者提供最熟悉的接口。跨进程内存访问:
ReadProcessMemory和WriteProcessMemory允许调试器等工具读写其他进程的地址空间。高级特性:
- 大页面(Large Pages):通过 2MB 或 1GB 页面提升 TLB 效率和内部性能
- AWE:允许 32 位进程突破地址空间限制(目前几乎不再使用)
- NUMA:在多节点系统上优化内存访问的局部性
- VirtualAlloc2:Windows 10 1803 引入的统一接口,整合多种扩展功能
内存保护与锁定:通过
VirtualProtect控制页面访问权限,通过VirtualLock防止关键内存被换出。内存提示函数(Windows 8+):允许应用向系统提供内存使用意图的提示,优化内存管理决策。
下一章将深入探讨内存映射文件(Memory-Mapped Files),以及它们在文件映射和进程间内存共享方面的强大功能。