CppGuide社区 CppGuide社区
首页
  • 🔥最新谷歌C++风格指南(含C++17/20)
  • 🔥C++17详解
  • 🔥C++20完全指南
  • 🔥C++23快速入门
🔥C++面试
  • 第1章 C++ 惯用法与Modern C++篇
  • 第2章 C++开发工具与调试进阶
  • 第3章 C++多线程编程从入门到进阶
  • 第4章 C++网络编程重难点解析
  • 第5章 网络通信故障排查常用命令
  • 第6章 网络通信协议设计
  • 第7章 高性能服务结构设计
  • 第8章 Redis网络通信模块源码分析
  • 第9章 服务其他模块设计
  • 🚀 全部章节.pdf 下载 (opens new window)
  • 🔥C++游戏编程入门(零基础学C++)
  • 🔥使用C++17从零开发一个调试器 (opens new window)
  • 🔥使用C++20从零构建一个完整的低延迟交易系统 (opens new window)
  • 🔥使用C++从零写一个C语言编译器 (opens new window)
  • 🔥从零用C语言写一个Redis
  • leveldb源码分析
  • libevent源码分析
  • Memcached源码分析
  • TeamTalk源码分析
  • 优质源码分享 (opens new window)
  • 🔥远程控制软件gh0st源码分析
  • 🔥Windows 10系统编程
  • 🔥Linux 5.x内核开发与调试 完全指南 (opens new window)
  • TCP源码实现超详细注释版.pdf (opens new window)
  • 高效Go并发编程
  • Go性能调优
  • Go项目架构设计
  • 🔥使用Go从零开发一个数据库
  • 🔥使用Go从零开发一个编译器 (opens new window)
  • 🔥使用Go从零开发一个解释器 (opens new window)
Rust编程指南
  • SQL零基础指南
  • MySQL开发与调试指南
GitHub (opens new window)
首页
  • 🔥最新谷歌C++风格指南(含C++17/20)
  • 🔥C++17详解
  • 🔥C++20完全指南
  • 🔥C++23快速入门
🔥C++面试
  • 第1章 C++ 惯用法与Modern C++篇
  • 第2章 C++开发工具与调试进阶
  • 第3章 C++多线程编程从入门到进阶
  • 第4章 C++网络编程重难点解析
  • 第5章 网络通信故障排查常用命令
  • 第6章 网络通信协议设计
  • 第7章 高性能服务结构设计
  • 第8章 Redis网络通信模块源码分析
  • 第9章 服务其他模块设计
  • 🚀 全部章节.pdf 下载 (opens new window)
  • 🔥C++游戏编程入门(零基础学C++)
  • 🔥使用C++17从零开发一个调试器 (opens new window)
  • 🔥使用C++20从零构建一个完整的低延迟交易系统 (opens new window)
  • 🔥使用C++从零写一个C语言编译器 (opens new window)
  • 🔥从零用C语言写一个Redis
  • leveldb源码分析
  • libevent源码分析
  • Memcached源码分析
  • TeamTalk源码分析
  • 优质源码分享 (opens new window)
  • 🔥远程控制软件gh0st源码分析
  • 🔥Windows 10系统编程
  • 🔥Linux 5.x内核开发与调试 完全指南 (opens new window)
  • TCP源码实现超详细注释版.pdf (opens new window)
  • 高效Go并发编程
  • Go性能调优
  • Go项目架构设计
  • 🔥使用Go从零开发一个数据库
  • 🔥使用Go从零开发一个编译器 (opens new window)
  • 🔥使用Go从零开发一个解释器 (opens new window)
Rust编程指南
  • SQL零基础指南
  • MySQL开发与调试指南
GitHub (opens new window)
  • Windows 10系统编程 引言
  • 第1章:基础
  • 第2章:对象和句柄
  • 第3章:进程
  • 第4章:作业(Jobs)
  • 第5章:线程基础
  • 第6章:线程调度
  • 第7章:进程内线程同步
  • 第8章:进程间线程同步
  • 第9章:线程池
  • 第10章:高级线程
  • 第11章:文件和设备输入输出
  • 第12章:内存管理基础
    • 基本概念
    • 进程地址空间
      • 页面状态
      • 地址空间布局
      • 32位系统
      • 64位系统
      • 地址空间使用
    • 内存计数器
      • 进程内存计数器
    • 进程内存映射
    • 页面保护(Page Protection)
    • 枚举地址空间区域(Enumerating Address Space Regions)
      • 简单的VMMap应用程序(The Simple VMMap Application)
      • 更多地址空间信息
    • 共享内存
    • 页面文件
    • WOW64
      • WOW64重定向
    • 虚拟地址转换
    • 总结
  • 第13章:内存操作
  • 第14章:内存映射文件
  • 第15章:动态链接库
  • 第16章:安全性
  • 第17章:注册表
目录

第12章:内存管理基础

# 第12章:内存管理基础

内存是任何计算机系统的基本组成部分。在过去,使用内存相对简单,应用程序只需直接分配物理内存、使用它、释放它,就完成了整个过程。现代操作系统管理虚拟内存,这个术语有着一些容易引起误解的含义。在本章中,我们将介绍与内存(包括虚拟内存和物理内存)相关的所有主要概念。

本章内容包括:

  • 基本概念
  • 进程地址空间
  • 内存计数器
  • 进程内存映射
  • 页面保护
  • 枚举地址空间区域
  • 共享内存
  • 页面文件
  • WOW64
  • 虚拟地址转换

# 基本概念

如今的现代英特尔/AMD处理器在内存方面最初的表现并不突出。最初的8086/8088处理器仅支持1MB的内存(当时只有物理内存)。每次对内存的访问都是段地址和偏移量的组合,之所以需要这样做,是因为这些处理器内部处理16位值,但内存访问需要20位(1MB)。段寄存器的值(16位)乘以16(0x10),然后加上偏移量,才能得到1MB范围内的地址。这种工作模式现在被称为实模式(Real Mode),并且仍是如今英特尔/AMD处理器启动时的模式。

随着80386处理器的推出,虚拟内存诞生了,其基本使用方式一直延续至今,包括通过仅使用偏移量就能进行线性内存访问(将段寄存器的值设为零),这使得内存访问更加便捷。虚拟内存意味着每次内存访问都需要转换为物理地址。这种模式被称为保护模式(Protected Mode)。在保护模式下,无法直接访问物理内存,只能通过从虚拟地址到物理地址的映射来访问。由于CPU期望该映射存在,所以这种映射必须由操作系统的内存管理器预先准备好。

在64位系统中,保护模式被称为长模式(Long Mode),但它本质上是相同的机制,只是扩展到了64位地址。

虚拟地址和物理地址之间的映射,以及操作系统层面上内存块的管理,都是以被称为“页”(page)的块为单位进行的。这是必要的,因为不可能管理每一个字节,否则管理结构会比被管理的字节大得多。目前支持两种页面大小,在Windows 10和Server 2016的x64系统上还支持第三种页面大小。表12-1列出了Windows支持的所有架构的页面大小。

表12-1:页面大小

架构 小(普通)页面 大页面 巨型页面
x86 4KB 2MB 不适用
x64 4KB 2MB 1GB
ARM 4KB 4MB 不适用
ARM64 4KB 2MB 不适用

小(普通)页面是默认的页面大小,本章(以及后续章节)中提到的“页面”指的是小页面或普通页面,在所有架构中其大小均为4KB。如果提到不同的页面大小,会明确加上“大”或“巨型”的前缀。

# 进程地址空间

每个进程都有其独立的线性虚拟私有地址空间。地址空间从地址零开始,到某个最大值结束,这个最大值取决于操作系统的位数(32位或64位)以及进程的位数,我们很快就会讲到。这里重要的是“私有”这个概念。例如,当提到在地址0x100000处有某些数据时,还需要回答另一个问题:在哪个进程中?每个进程都有地址0x100000,但这个地址可能映射到不同的物理地址、文件,或者根本没有映射。图12-1展示了这种概念上的映射,其中两个进程将它们的一些页面映射到物理内存(随机存取存储器,RAM),一些页面映射到磁盘,还有一些页面未被映射。

img 图12-1:虚拟地址映射

一个进程可以直接访问其自身地址空间中的内存。这意味着一个进程不能通过简单地操作指针,意外或恶意地读取或写入另一个进程的地址空间。虽然可以访问另一个进程的内存,但这需要调用一个函数(本章后面会讨论的ReadProcessMemory或WriteProcessMemory),并且需要拥有对目标进程足够权限的句柄。

进程的地址空间被称为虚拟地址空间。这意味着该地址空间只是一个潜在内存映射的空间。每个进程在刚开始时对其虚拟地址空间的使用都很有限,可执行文件以及NtDll.Dll会被映射到其中。然后加载器(NtDll的一部分)会在进程地址空间内分配一些基本结构,比如默认进程堆(下一章会讨论)、进程环境块(Process Environment Block,PEB),以及进程中第一个线程的线程环境块(Thread Environment Block,TEB)。进程的大部分地址空间都是空闲的。

# 页面状态

虚拟内存中的每个页面可能处于以下三种状态之一:空闲(free)、已提交(committed)和已保留(reserved)。空闲页面未被映射,因此尝试访问该页面会导致访问冲突异常。进程的大部分地址空间最初都是空闲的。

已提交页面与空闲页面相反,这是一个已映射的页面,可能映射到RAM或文件,访问这样的页面通常会成功(前提是没有冲突的页面保护机制,本章后面会讨论)。如果页面在RAM中,CPU会直接访问数据并继续执行。如果页面不在RAM中(至少根据CPU查询的表显示如此),CPU会引发一个称为页错误(page fault)的异常,该异常会被内存管理器捕获。如果页面确实位于磁盘上,内存管理器会将其调回RAM,修正转换表使其指向RAM中的新地址,并指示CPU再次尝试访问。最终结果是,从调用线程的角度来看,访问成功了。如果确实涉及输入/输出操作,访问速度会变慢,但调用线程无需知道这些,也无需进行任何特殊操作,一切都是透明的。

从技术上讲,访问空闲页面也会导致页错误。在这种情况下,内存管理器会判定给定地址背后没有任何内容,并引发访问冲突异常。

已提交内存通常就是所谓的“已分配”内存。调用C/C++内存分配函数,如malloc、calloc、operator new等(当然,前提是这些函数调用成功),总是会提交内存。

我们将在下一章深入讨论内存分配的应用程序编程接口(API)。

最后一种页面状态介于空闲和已提交之间,称为已保留。已保留页面与空闲页面类似,访问它会导致访问冲突,因为该页面没有实际内容。不过,已保留页面日后可能会被提交。保留一个页面范围可以确保普通内存分配不会使用该范围,因为它是为其他目的预留的。我们在线程堆栈的管理方式中已经看到了这种理念。由于线程堆栈可以增长,并且在虚拟内存中必须是连续的,所以会保留一个页面范围,以防止进程中的其他分配使用该保留的地址范围。

表12-2总结了页面的各种状态。

表12-2:页面状态

页面状态 含义 访问时的情况
空闲 未分配的页面 访问冲突异常
已提交 已分配的页面 成功(假设没有页面保护限制)
已保留 未分配但为将来使用而保留的页面 访问冲突异常

# 地址空间布局

在本节中,我们将研究32位和64位系统上进程的地址布局。

表12-3总结了地址空间大小。

表12-3:进程虚拟地址大小

操作系统类型 进程类型 设置了LARGEADDRESSAWARE 未设置LARGEADDRESSAWARE
32位启动(无增加用户虚拟地址(UVA)) 32位 2GB 2GB
32位启动(有增加用户虚拟地址(UVA)) 32位 2GB 2GB到3GB
64位(Windows 8.1及更高版本) 32位 2GB 4GB
64位(Windows 8.1及更高版本) 64位 2GB 128TB
64位(Windows 8及更早版本) 32位 2GB 4GB
64位(Windows 8及更早版本) 64位 2GB 8TB

LARGEADDRESSAWARE是一个链接器标志(linker flag),可以在构建可执行文件时指定,并作为PE(Portable Executable,可移植可执行文件)头的一部分存储。也可以在不访问源代码的情况下,使用PE编辑工具(如Windows SDK中提供的editbin.exe命令行工具)稍后进行设置。这个标志的用途是什么呢?

如果可执行文件已签名,那么更改这个标志(或者实际上任何其他标志)都会使签名无效。

最初(在Windows NT 4之前),32位进程只能获得2GB的地址空间。2GB的地址需要31位来表示,所以最高有效位(Most Significant Bit,MSB)始终为零。从NT 4开始,32位Windows系统每个进程可以启动时拥有3GB的地址空间。然而,一些开发人员可能会利用他们使用的任何地址的最高有效位都设置为零这一事实,并将这个空闲位用于某些应用目的。然后,如果这样的进程被分配超过2GB的地址空间,而在超过2GB的地址中最高有效位可能为1,那么该进程可能会以某种方式失败,因为它在访问内存之前会屏蔽掉最高有效位。设置LARGEADDRESSAWARE位表明该可执行文件的开发人员没有对地址的最高有效位进行特殊处理,因此该进程可以毫无问题地接受大于2GB(0x80000000)的地址。

此位仅影响可执行文件,不影响动态链接库(Dynamic-Link Library,DLL)。动态链接库必须始终正确工作,并且绝不能对其获得的地址值做任何假设。

通常在Visual Studio中,在项目属性/链接器/系统(图12-2)中设置此位。32位配置的默认设置为“否”,64位配置的默认设置为“是”。对于32位可执行文件,假设你对地址没有任何特殊假设,将该标志设置为“是”几乎没有坏处。

存在一个坏处:如果你的进程存在内存泄漏,它将有更多的地址空间可供泄漏,这意味着系统会因为你的进程而消耗更多的内存。

图12-2:Visual Studio中的LARGEADDRESSAWARE标志

可以使用Dumpbin.exe命令行工具查看有关PE文件的信息,包括LARGEADDRESSAWARE位的状态。以下是对Explorer.exe的示例:

C:\>dumpbin /headers c:\windows\explorer.exe
Microsoft (R) COFF/PE Dumper Version 14.26.28720.3
Copyright (C) Microsoft Corporation.  All rights reserved.

Dump of file c:\windows\explorer.exe
PE signature found
File Type: EXECUTABLE IMAGE
FILE HEADER VALUES
8664 machine (x64)
8 number of sections
4D818882 time date stamp
0 file pointer to symbol table
0 number of symbols
F0 size of optional header
22 characteristics
Executable
Application can handle large (>2GB) addresses
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

如第5章所述,也有一些图形化工具可以显示这些信息,比如我自己开发的PE Explorer V2(图12-3)。

图12-3:PE Explorer V2显示PE属性——32位系统

# 32位系统

在32位系统上,存在两种变体,如表12-3所示,并在图12-4中以图形方式展示。

图12-4:32位地址空间布局

32位意味着4GB的地址空间,这可能让你疑惑为什么进程只获得2GB。图12-2揭开了这个谜团:高2GB是系统空间(也称为内核空间)。这是操作系统内核本身以及所有内核设备驱动程序所在的地方,以及它们在代码和数据方面消耗的内存。

请注意,系统空间是唯一的——只有一个系统,只有一个内核。这意味着系统空间中的地址是绝对的,而不是相对的;从每个进程上下文来看,它们的含义都是相同的。

如果系统在启动时使用“增加用户虚拟地址”选项,系统只能使用1GB的地址范围,而用户进程可以获得2GB到3GB的地址空间(任何大于2GB的地址都要求其PE头中设置LARGEADDRESSAWARE标志)。

要在32位系统上使用“增加UVA”选项启动,可以在提升权限的命令窗口中运行以下命令,然后重启系统:

c:\>bcdedit /set increaseuserva 3072
1

该数字是用户地址空间的大小(以MB为单位),可以从2048(2GB默认值)到3072(3GB)。

要删除此选项,使用bcdedit /deletevalue increaseuserva命令。

# 64位系统

32位系统中增加用户地址到3GB的选项虽然不错,但远远不够。在真正的64位操作系统出现之前,这是一个很大的差距。如今,大多数(如果不是全部的话)桌面版Windows系统都是64位的,不仅仅是服务器。64位系统有几个优点,其中第一个就是地址空间大幅增加。

64位的理论限制是2的64次方,即16EB(千兆(Giga)、万亿(Tera)、千万亿(Peta)、百亿亿(Exa))。这是一个极其庞大的地址范围,在当今系统中似乎遥不可及。要使用这样的地址空间,你必须拥有接近这个数量的随机存取存储器(Random Access Memory,RAM)加上分页文件,而这与当今的系统仍相差甚远。实际上,大多数现代处理器仅支持48位的虚拟和物理地址。这意味着可能获得的最大地址范围是2的48次方,即256TB。这就是为什么在64位系统中,每个进程可以有128TB的地址空间范围,而另外128TB用于系统空间。

英特尔Sunny Cove微架构支持57位的虚拟地址空间和52位的物理地址空间。这意味着使用这种处理器的地址空间每个进程将有64PB,系统空间也有64PB!

向64位系统的过渡大多是无缝的,因为可以在64位x64系统上运行32位x86进程,而无需对原始二进制文件进行任何更改。本章后面的“WOW64”部分将进一步讨论这一点。设置了LARGEADDRESSAWARE位的32位可执行文件在64位系统上可以获得4GB的地址空间。这是合理的,因为从3GB过渡到4GB确实需要额外的位,所以能够处理3GB地址的进程肯定能够处理4GB地址。

一个利用此功能的典型可执行文件示例是Visual Studio(devenv.exe)。Visual Studio是一个32位进程,由于开发人员使用64位系统,Visual Studio可以获得4GB的地址空间。有人声称这使得Visual Studio可能会泄漏更多内存。

从32位过渡到64位也存在一些问题。问题在于将设备驱动程序从32位转换为64位。内核中没有“WOW64”层。复杂的内核驱动程序,如显示驱动程序,在早期64位系统时代存在稳定性问题。幸运的是,这些问题现在都已成为过去。

图12-5展示了64位系统上32位和64位进程的地址空间布局。

图12-5:64位系统上的地址布局

Windows 8及更早版本的64位系统仅支持8TB的用户地址空间和8TB的系统空间。这是由于内核中的一个实现细节问题,在Windows 8.1中得到了修复。

然而,64位系统并非完美无缺。64位进程可能会泄漏内存,甚至导致系统崩溃,因为其地址空间实际上是无限的——在64位进程的地址空间耗尽之前,随机存取存储器(RAM)加上分页文件就会被填满。此外,与32位系统相比,地址转换需要额外的一级,如果转换后备缓冲器(Translation Lookaside Buffer,TLB)缓存没有得到有效使用,转换速度可能会更慢(本章后面会介绍)。

# 地址空间使用

我们已经大致了解了各种类型进程可用的虚拟内存空间大小。然而,并非整个用户地址空间范围都是可用的。为了了解哪些地址可用,哪些不可用,我们可以调用GetSystemInfo函数及其姊妹函数GetNativeSystemInfo:

VOID GetSystemInfo(_Out_ LPSYSTEM_INFO lpSystemInfo);
VOID GetNativeSystemInfo(_Out_ LPSYSTEM_INFO lpSystemInfo);
1
2

这两个函数都返回一个SYSTEM_INFO结构,其定义如下:

typedef  struct  _SYSTEM_INFO {
    union  {
        DWORD dwOemId;        //  Obsolete,  do not use
        struct  {
            WORD wProcessorArchitecture;
            WORD wReserved;
        };
    };
    DWORD dwPageSize;
    LPVOID lpMinimumApplicationAddress;
    LPVOID lpMaximumApplicationAddress;
    DWORD_PTR dwActiveProcessorMask;
    DWORD dwNumberOfProcessors;
    DWORD dwProcessorType;     //  obsolete
    DWORD dwAllocationGranularity;
    WORD wProcessorLevel;
    WORD wProcessorRevision;
} SYSTEM_INFO, *LPSYSTEM_INFO;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

该结构包含一些系统级信息,包括用户模式进程的最小和最大可用地址。GetSystemInfo函数会考虑调用进程的位数。在64位系统上的32位进程只能“看到”32位的值。GetNativeSystemInfo函数可以查看“真实”的值——在一定程度上。对于32位系统上的32位进程和64位系统上的64位进程,这两个函数的功能是相同的。

sysinfo应用程序会显示SYSTEM_INFO结构中的一些信息。它首先简单地调用GetSystemInfo函数:

SYSTEM_INFO si;
::GetSystemInfo(&si);
DisplaySystemInfo(&si, "System information");
1
2
3

从GetSystemInfo函数返回的任何信息都会被传递到辅助函数 DisplaySystemInfo 中,以显示结果。

如果当前进程是64位系统上的32位进程(即WOW64进程),则会再次调用 GetNativeSystemInfo 函数,以显示更准确的信息:

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

问题在于如何检查一个进程是否是WOW64进程。IsWow64Process 函数和较新的 IsWow64Process2 函数可以提供帮助:

BOOL IsWow64Process(
    _In_ HANDLE hProcess,
    _Out_ PBOOL Wow64Process);

// Windows 10+ 仅支持
BOOL IsWow64Process2(
    _In_ HANDLE hProcess,
    _Out_ USHORT* pProcessMachine,
    _Out_opt_ USHORT* pNativeMachine);
1
2
3
4
5
6
7
8
9

如果是WOW64进程,IsWow64Process 函数会在 Wow64Process 中返回 TRUE 。需要注意的是,如果在32位系统上运行,该函数会将 Wow64Process 设置为 FALSE 。

较新的 IsWow64Process2 函数可以提供关于运行该进程的处理器以及机器上本地处理器的更多信息。pProcessMachine 会返回 <winnt.h> 中定义的 IMAGE_FILE_MACHINE_* 常量之一。如果值为 IMAGE_FILE_MACHINE_UNKNOWN ,则意味着该进程不是WOW64进程。如果 pNativeMachine 不为 NULL ,它会从同一列表中返回本地机器标识符。

sysinfo 中的代码会检查当前进程是否是32位的(sizeof(void*) == 4)以及是否是WOW64进程。如果两者都为真,那么本地系统与当前进程不同,因此值得调用 GetNativeSystemInfo 函数。

任务管理器(Task Manager)在“详细信息”选项卡中有一个名为“平台”的列,它会显示每个进程的“位数”。

DisplaySystemInfo函数的实现大部分都很直接,它会显示SYSTEM_INFO实例中的大部分信息:

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);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

以下是在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
1
2
3
4
5
6
7
8
9

注意,最低可用地址是0x10000,这意味着虚拟地址空间的前64 KB不可用。传统上,这些空间用于捕获空指针(NULL pointers)。同样,在系统空间开始之前,地址空间的最高64 KB也不可用。简而言之,这意味着可用地址空间范围比预期的少128 KB。对于64位进程来说,这完全可以忽略不计。

在64位系统上,将同一应用程序作为32位可执行文件运行时,会产生以下输出:

System information
------------------
Processor Architecture: x86
Number of Processors: 16
Active Processor Mask: 0xFFFF
Page Size: 4 KB
Min User Space Address: 0x00010000
Max User Space Address: 0x7FFEFFFF
Allocation Granularity: 64 KB
Native System information
-------------------------
Processor Architecture: x64
Number of Processors: 16
Active Processor Mask: 0xFFFF
Page Size: 4 KB
Min User Space Address: 0x00010000
Max User Space Address: 0xFFFEFFFF
Allocation Granularity: 64 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

我们可以看到,底部和顶部同样有64 KB不可用。本地系统信息在处理器架构(x64)方面是准确的,而“本地”版本声称处理器是x86。在地址方面,本地输出中的上限假设32位地址空间的全部4 GB都可用,如果可执行文件设置了LARGEADDRESSAWARE链接器标志,情况确实如此。

在所有这些情况下,报告的页面大小都是4 KB(报告的是小页面大小)。同样值得注意的是所谓的分配粒度(allocation granularity),正如我们稍后将看到的,它是使用VirtualAlloc系列函数进行分配时的粒度。目前在所有Windows架构和版本中,分配粒度都是64 KB。

以下是在具有4个逻辑处理器的32位Windows 8.1上的另一个输出:

System information
------------------
Processor Architecture: x86
Number of Processors: 4
Active Processor Mask: 0xF
Page Size: 4 KB
Min User Space Address: 0x00010000
Max User Space Address: 0x7FFEFFFF
Allocation Granularity: 64 KB
1
2
3
4
5
6
7
8
9

与WOW64版本相比,内存相关的值是相同的,因为此系统没有以“增加UVA”模式启动。

# 内存计数器

开发人员通常希望了解他们的进程在内存使用方面的情况。进程是否消耗了大量内存?是否可能存在内存泄漏?系统本身的内存使用情况又如何呢?Windows提供了许多与内存相关的计数器,理解这些计数器非常重要,因为有些计数器的名称有些晦涩难懂,而且在某些情况下,不同的工具对相同的计数器叫法不同。

开发人员通常用来了解系统运行状况的第一个工具是任务管理器(Task Manager)。在其“性能”选项卡中选择“内存”子选项卡时,会显示与系统内存相关的信息。图12-6展示了一个带有注释的截图。

图12-6:任务管理器的性能/内存信息

表12-4总结了图12-6中显示的信息。

表12-4:任务管理器中的内存信息

名称 描述
内存使用情况图表 显示过去60秒的物理内存(RAM)消耗情况
已用(In Use) 当前正在使用的物理内存
(已压缩)(Compressed) 压缩内存的数量(见侧边栏“内存压缩”)
已提交/提交限制(Committed / Commit Limit) 总提交内存/页面文件扩展前的提交内存限制
内存组成 - 已修改(Memory Composition - Modified) 尚未写入磁盘的内存
内存组成 - 空闲(Memory Composition - Free) 空闲页面(其中大部分是零页面)
缓存(Cached) 在需要时可重新利用的内存(备用 + 已修改)
可用(Available) 可用的物理内存(备用 + 空闲)
分页池/非分页池(Paged pool / Non-paged pool) 内核池内存使用情况

内存压缩

内存压缩是在Windows 10中引入的一项功能,用于通过压缩当前不需要的内存来节省内存。这对进入后台的UWP应用程序特别有用,因为它们不占用CPU资源,所以这些进程使用的任何私有物理内存都可以释放出来。相反,这些内存会被压缩,仍然为其他进程留下空闲页面。当进程唤醒时,内存会迅速解压并准备好使用,避免了对页面文件的I/O操作。

在Windows 10的前两个版本中,压缩内存存储在系统进程的用户模式地址空间中。这在工具中过于明显,因此从Windows 10版本1607开始,一个名为“内存压缩”(一个最小化的进程)的特殊进程负责管理压缩内存。此外,任务管理器根本不会显示这个进程。而其他工具,如进程资源管理器(Process Explorer),则会正常显示这个进程。

图12-6中的“内存组成”栏大致展示了物理页面在内部是如何管理的。“已用”部分是当前被视为进程和系统工作集一部分的页面。备用页面(Standby pages)是其备份存储在磁盘上,但与所属进程的关联仍然保留的内存。如果进程现在访问其中一个页面,它们会立即回到其工作集(变为“已用”)。如果这些页面被立即丢弃到“空闲”页面堆中,那么就需要进行I/O操作才能将页面重新加载到RAM中。

“已修改”部分表示其内容尚未写入后备存储(通常是页面文件)的页面,因此这些页面不能被丢弃。如果已修改页面的数量变得太大,或者备用页面和空闲页面的数量变得太小,已修改页面将被写入它们的后备文件,并转移到备用状态。

所有这些转换和管理都是为了减少I/O操作。在进程资源管理器的“系统信息”视图的“内存”选项卡中,可以更精确地查看这些物理页面列表的管理情况,如图12-7所示。(使用“视图”/“系统信息…”菜单打开它。)

图12-7:进程资源管理器中关于内存的系统信息

图12-7中的“分页列表”部分详细说明了执行体的内存管理器(Memory Management)用于管理物理页面的各种列表。零页面(Zeroed pages)是仅包含零的页面,与包含垃圾数据的空闲页面相比,零页面占大多数。一个名为“零页面线程”的特殊执行体线程以优先级0运行(这是唯一具有此优先级的线程),它负责将空闲页面清零。零页面之所以重要,是为了满足安全要求,即分配的内存绝不能包含曾经属于另一个进程的数据,即使该进程已不存在。图12-6中内存组成的“空闲”部分包括空闲页面和零页面的总和。

图12-7中另一个有趣的部分是,实际上并没有单一的备用页面列表,而是基于优先级分为八个列表。这被称为内存优先级(Memory Priority),可以在进程资源管理器中逐个线程查看,不过这也是一个进程属性,默认情况下每个线程都会继承该属性。

当进程或系统需要物理内存,需要将备用列表中的页面转换为空闲页面时,就会用到内存优先级。问题是,应该先“释放”哪些页面(并断开它们与原始进程的连接)?一种简单的方法是使用先进先出(FIFO)队列,即从进程工作集中移除的第一个页面会最先变为空闲页面。然而,这种方法过于简单。假设一个进程在后台大量工作,比如反恶意软件程序或备份应用程序。这些进程显然会使用内存,但它们不像用户直接使用的应用程序那么重要。所以,如果需要物理内存,即使它们的备用页面是最近才使用的,也应该首先被释放。这就是内存优先级的作用。

默认的内存优先级是5。在第6章中,我们讨论了进程和线程的后台模式,在这种模式下,CPU优先级会降低到4,内存优先级会降低到1,这使得该进程使用的备用页面比内存优先级更高的进程的备用页面更有可能被重新使用。

有时,你可能希望在不进入后台模式的情况下更改内存优先级。Windows 8及更高版本通过SetProcessInformation函数(用于设置进程范围内的默认值)或SetThreadInformation函数(用于逐个线程设置)提供了此功能:

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);
1
2
3
4
5
6
7
8
9
10
11

这些函数相当通用,接受PROCESS_INFORMATION_CLASS和THREAD_INFORMATION_CLASS枚举的几个可能值。对于内存优先级,枚举值是ProcessMemoryPriority和ThreadMemoryPriority,优先级的值在1到5之间。这意味着只允许降低内存优先级。

图12-7显示了优先级6和7。这些优先级用于诸如超级预取(superfetch)之类的服务,这些服务试图在使用这些内存的进程启动之前加载代码和数据。这样的页面应尽可能长时间地保留在RAM中,因为它们可能为多个进程服务。

对于进程,句柄必须具有PROCESS_SET_INFORMATION访问掩码。对于线程,句柄必须具有THREAD_SET_INFORMATION访问掩码。

以下是一个示例,当前线程将其自身的内存优先级降低到2:

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

自然地,也存在相应的“获取”函数:

BOOL GetProcessInformation(
    _In_ HANDLE hProcess,
    _In_ PROCESS_INFORMATION_CLASS ProcessInformationClass,
    _Out_writes_bytes_(ProcessInformationSize) LPVOID ProcessInformation,
    _In_ DWORD ProcessInformationSize);

BOOL GetThreadInformation(
    _In_ HANDLE hThread,
    _In_ THREAD_INFORMATION_CLASS ThreadInformationClass,
    _Out_writes_bytes_(ThreadInformationSize) LPVOID ThreadInformation,
    _In_ DWORD ThreadInformationSize);
1
2
3
4
5
6
7
8
9
10
11

上述“设置”函数是对原生的NtSetInformationProcess和NtSetInformationThread函数的简单包装。同样,“获取”函数是对NtQueryInformationProcess和NtQueryInformationThread函数的简单包装。

# 进程内存计数器

任务管理器中与进程相关的内存计数器有点让人摸不着头脑。任务管理器的第一个问题在于,“详细信息”选项卡中显示的默认内存计数器:“内存(专用工作集)”或“内存(活动专用工作集)”(后者自Windows 10 1903版本起出现)。下面我们来剖析一下这些术语:

  • 工作集(Working Set):进程使用的物理内存。
  • 专用(Private):进程私有的内存(不共享)。
  • 活动(Active):不包括在后台运行的UWP(通用Windows平台,Universal Windows Platform )进程。

这些计数器的问题出在“工作集”这部分。它们表示当前存在于随机存取存储器(RAM)中的专用内存。然而,这是个不稳定的计数器,其数值可能会根据进程的活动情况上下波动。如果你想判断一个进程发生内存泄漏时,处理器分配(提交)了多少内存,那就不应该看这些计数器。

这些计数器仅显示专用内存,这通常是件好事,因为共享内存(比如动态链接库(DLL,Dynamic-Link Library)代码使用的内存)是固定的,对此人们基本无能为力。专用内存才是由进程控制的内存。

那么,到底该看哪个正确的计数器呢?答案是“提交大小(Commit Size)”。更让人困惑的是,进程资源管理器(Process Explorer)和性能监视器(Performance Monitor)把这个计数器称为“专用字节(Private Bytes)”。图12-8展示了任务管理器,其中“提交大小”和“活动专用工作集”并排显示,并按提交大小排序。

img 图12-8:任务管理器

提交大小同样针对专用内存,所以它和专用工作集处于同等地位。二者的区别在于不在工作集中的那部分内存。如果这两个计数器的数值相近,那就意味着要么该进程相当活跃,使用了大部分内存,要么就是Windows系统的可用内存并不紧张,所以内存管理器不会急着从工作集中移除页面。

在某些情况下,这两个计数器的差值可能会很大。在图12-8中,进程“Code”(进程ID为34316)的大部分已提交内存并不在其工作集中。这就是为什么查看专用工作集计数器可能会产生误导。从这个计数器看,该进程似乎大约消耗了97MB内存,但实际上它消耗了约368MB内存。的确,目前它在随机存取存储器中仅使用了97MB,但已提交的内存会占用页表(用于映射已提交的内存),并且这部分内存会计入系统的提交限制(如图12-6所示)。

总之:要确定进程的内存消耗情况,应使用任务管理器中的“提交大小”列。它不包含共享内存,但在大多数情况下,这并不重要。

在进程资源管理器中,与“提交大小”对应的是“专用字节”。任务管理器和进程资源管理器都有更多与内存相关的列(进程资源管理器的相关列比任务管理器更多)。有一列尤其没有与之相近的等效列,那就是图12-9中显示的“虚拟大小(Virtual Size)”列。

img 图12-9:进程资源管理器中的“虚拟大小”列

“虚拟大小”列统计的是所有非空闲状态的页面,也就是已提交和已保留的页面。这本质上就是进程占用的地址空间大小。对于潜在地址空间为128TB的64位进程来说,这个数值的影响不大。但对于32位进程而言,这可能是个问题。即便已提交的内存并不高,大量的保留内存区域也会限制新分配的可用地址空间,这就可能导致分配失败,即便系统整体的可用内存很充足。

前面提到的那些计数器不包含保留内存,这是有充分理由的。保留内存的开销很小,因为从中央处理器(CPU,Central Processing Unit)的角度看,它和空闲内存没什么区别——描述保留内存无需页表。实际上,从Windows 8.1开始,保留内存的开销更小了。

图12-9中“虚拟大小”列的一些数值可能看起来有点惊人。有好几个进程的虚拟大小似乎都在2TB左右。“专用字节”列显示的数值则小得多,这意味着“虚拟大小”所描述的内存大部分是保留内存。一些进程之所以有这么大的保留内存块,真正原因在于Windows 10的一项安全功能——控制流防护(CFG,Control Flow Guard)。你可以在进程资源管理器中添加“CFG”列,这样就能看到支持CFG的进程和大约2TB的巨大保留区域之间存在紧密关联。

我们将在第16章(第二部分)“安全”中更深入地探讨CFG。

调用GlobalMemoryStatusEx函数可以获取部分全局内存信息:

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);
1
2
3
4
5
6
7
8
9
10
11
12
13

这个函数名有点容易让人误解——MEMORYSTATUSEX结构体的成员中,只有部分表示系统范围的信息。其他成员与调用进程相关。

在调用该函数之前,必须将MEMORYSTATUSEX结构体的dwLength成员设置为结构体的大小。下面详细介绍一下这些成员:

  • dwMemoryLoad:一个介于0到100之间的数值,表示系统的物理内存负载(以百分比表示)。
  • ullTotalPhys:系统的总物理内存(以字节为单位)。
  • ullAvailPhys:可用的物理内存字节数(备用、空闲和零列表的总和)。
  • ullTotalPageFile:系统或调用进程的提交大小(以字节为单位,与页面文件没有直接关联),取两者中的较小值。
  • ullAvailPageFile:调用进程能够提交的最大字节数。
  • ullTotalVirtual:调用进程的虚拟地址大小。
  • ullAvailVirtual:调用进程中的可用地址空间(空闲页面)。

还有另一个函数可以补充系统范围的信息(需包含<psapi.h>头文件):

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);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

GetPerformanceInfo函数仅返回系统范围的信息,与调用进程无关(不过在64位Windows系统中,如果从32位进程调用,这些值可能有误,因为在32位进程中SIZE_T是32位的)。cb应设置为PERFORMANCE_INFORMATION结构体的大小。

请记住,PERFORMANCE_INFORMATION结构体中与内存相关的值是以页面为单位,而非字节。该结构体贴心地提供了页面大小(我们知道,在所有受支持的架构中,页面大小均为4KB)。

构建一个应用程序,尽可能多地显示任务管理器“性能”/“内存”选项卡中的值,并每秒更新这些值。将其与任务管理器进行对比。

# 进程内存映射

进程的地址空间必须包含进程在内存方面所使用的所有内容:可执行文件的代码和全局数据、动态链接库(DLL)的代码和全局数据、线程栈、堆(将在下一章讨论),以及进程提交和 / 或保留的任何其他内存。图12-10展示了一个进程虚拟地址空间的典型示例。

img 图12-10:进程虚拟地址空间示例

你或许能猜出图12-10中部分内容的含义。我们将在下一章讨论堆和VirtualAlloc函数。图12-10只是一个极简示例。一个典型的进程会加载几十个动态链接库,并且可能会使用多个线程。像.NET这样的框架有自己的动态链接库和堆,但所有这些看似不同的东西本质上都是由相同的 “元素” 构成的。

若要查看进程的实际内存映射,可以使用Sysinternals的VMMap工具。启动VMMap时,它会立即显示一个进程选择对话框,在其中你可以选择感兴趣的进程(图12-11)。“显示所有进程” 按钮允许以管理员权限启动VMMap,从而能够访问更多进程。不过,VMMap仍仅限于用户模式访问,无法打开受保护的进程。

img 图12-11:在VMMap中选择进程

选择一个进程后,VMMap的主视图由三个不同的水平部分组成(图12-12展示了explorer.exe进程的实例)。 img 图12-12:在VMMap中打开的进程

顶部区域显示了三个计数器:

  • 已提交内存(Committed memory)——进程中总的已提交内存(包括专用页面和共享页面)。
  • 专用字节(Private Bytes)——已提交的专用内存。
  • 工作集(Working set)——总的工作集(专用页面和共享页面使用的物理内存)。

每个计数器都配有某种直方图,展示该计数器中包含的内存区域类型。区域类型在第二部分介绍。表12-5总结了VMMap显示的区域类型。

表12-5:VMMap中的区域类型

类型 描述
Image 映射的图像(可执行文件(EXE)和动态链接库(DLL))
Mapped File 映射文件(不包括图像)
Shareable 由页面文件支持的内存映射文件
Heap 堆使用的内存
Managed Heap 由.NET运行时(CLR或CoreCLR)管理的内存
Stack 线程栈使用的内存
Private Data 通过VirtualAlloc分配的通用内存
Unusable 无法使用的内存块(小于64KB的分配粒度)
Free 空闲页面

底部部分根据当前选定的区域类型显示各个区域。你可以按任意列进行排序,还能通过展开“Address(地址)”节点深入查看某个区域,显示其中的内存块。该节点本身是通过单次调用VirtualAlloc保留的一块内存。然后,在保留区域内的内存块可能已提交,也可能仍处于保留状态。每个内存块都有相同的页面状态和保护属性(下一节将讨论)。

“Details(详细信息)”列会提供有关保留区域或内存块的更多信息(如果有的话)。VMMap使用多种技术来显示有关区域或内存块的有用信息。最简单的方法是调用GetMappedFileName函数,以检索在某个地址映射的文件(如果有):

DWORD GetMappedFileName(
    _In_ HANDLE hProcess,
    _In_ LPVOID lpv,
    _Out_ LPTSTR lpFilename,
    _In_ DWORD nSize
);
1
2
3
4
5
6

进程句柄必须具有PROCESS_QUERY_INFORMATION访问掩码位。给定lpv参数中的地址,该函数会在lpFileName中返回文件名(如果有)。函数的返回值是复制到lpFileName中的字符数;如果函数失败,则返回零。此函数唯一的问题在于,它以NT设备格式(\Device\harddiskVolume3\…)返回文件名,可能需要将其转换为Win32格式,才能供CreateFile等API使用。你可以使用第7章中的技术进行转换。

你可能还能想到VMMap提取详细信息的其他方法。例如,可以通过枚举进程中的线程(第10章介绍过)来报告线程栈,然后使用原生的NtQueryInformationThread函数检索未公开的线程环境块(TEB)结构,线程的栈大小就存储在该结构中。

如果你需要获取进程内存使用情况的汇总视图,PSAPI函数GetProcessMemoryInfo可以提供帮助:

BOOL GetProcessMemoryInfo(
    HANDLE Process,
    PPROCESS_MEMORY_COUNTERS ppsmemCounters,
    DWORD cb
);
1
2
3
4
5

该函数接受一个进程句柄,该句柄必须具有PROCESS_VM_READ访问掩码,并且要有PROCESS_QUERY_INFORMATION或PROCESS_QUERY_LIMITED_INFORMATION访问权限。当前进程句柄(GetCurrentProcess)是个很自然的选择,因为它具有完整的访问掩码。

根据cb参数中传入的大小,信息会以两种结构之一返回:

typedef struct _PROCESS_MEMORY_COUNTERS {
    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;
    SIZE_T PeakPagefileUsage;
} PROCESS_MEMORY_COUNTERS;
typedef PROCESS_MEMORY_COUNTERS *PPROCESS_MEMORY_COUNTERS;

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;
    SIZE_T PeakPagefileUsage;
    SIZE_T PrivateUsage;
} PROCESS_MEMORY_COUNTERS_EX;
typedef PROCESS_MEMORY_COUNTERS_EX *PPROCESS_MEMORY_COUNTERS_EX;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

扩展结构比原始结构多了两个成员。以下是这些成员的详细说明(所有内存大小均以字节为单位):

  • cb:结构的大小
  • PageFaultCount:进程中发生的页面错误异常的数量
  • PeakWorkingSetSize:物理内存消耗峰值
  • WorkingSetSize:当前物理内存消耗
  • QuotaPeakPagedPoolUsage:进程导致的分页池使用峰值
  • QuotaPagedPoolUsage:进程当前的分页池使用量
  • QuotaPeakNonPagedPoolUsage:进程导致的非分页池使用峰值
  • QuotaNonPagedPoolUsage:进程当前的非分页池使用量
  • PagefileUsage:进程当前的提交大小(私有提交内存)(Windows 8及更高版本)
  • PeakPagefileUsage:进程的提交大小峰值
  • PrivateUsage:与PagefileUsage相同(Windows 7及更早版本也适用)

内核的分页池和非分页池值需要进一步讨论。内核有两种基本的内存池类型:分页池,用于存放可分页到磁盘的内存;非分页池,根据其定义,始终驻留在随机存取存储器(RAM)中,永远不会被分页到磁盘。这些内存池由内核和设备驱动程序使用,它们的当前大小可以在任务管理器等工具中查看。不过,用户模式进程通过使用某些API也会间接对这些内存池产生影响。例如,创建任何内核对象时,句柄本身会使用分页池(在64位系统上为16字节,见第2章)。

也可以在任务管理器中通过添加相应的列(“Paged pool(分页池)”和“NP pool(非分页池)”)来查看内存池的值。

举个简单的例子,你可以运行Sysinternals的命令行工具TestLimit,尽可能多地创建句柄:

C:\>testlimit -h
Testlimit v5.24 - test Windows limits
Copyright (C) 2012-2015 Mark Russinovich Sysinternals - www.sysinternals.com
Process ID: 35288
Creating handles...
Created 16711496 handles. Lasterror: 1450
1
2
3
4
5
6

在任务管理器中查看TestLimit.exe进程,会看到图12-13所示的内容。

img

图12-13:TestLimit的分页池使用情况

注意,该进程的分页池大小约为256MB。这是合理的,因为大约创建了1600万个句柄,每个句柄占用16字节。

# 页面保护(Page Protection)

进程虚拟地址空间中的每个已提交页面都有保护标志。这些标志可以使用下一章将讨论的VirtualAlloc或VirtualProtect函数进行设置。表12-6展示了页面保护属性,在提交页面时可以指定其中一种属性。任何违反页面保护的访问操作都会导致访问冲突异常。

表12-6:基本保护标志

保护标志 描述
PAGE_NOACCESS 页面不可访问
PAGE_READONLY 仅允许读取访问
PAGE_READWRITE 允许读取和写入访问
PAGE_WRITECOPY 写时复制访问(在“共享内存”部分讨论)
PAGE_EXECUTE 允许执行访问
PAGE_EXECUTE_READ 允许执行和读取访问
PAGE_EXECUTE_READWRITE 允许所有可能的访问
PAGE_EXECUTE_WRITECOPY 允许执行访问和写时复制

除上述值外,还有一些可选的保护常量,如表12-7所示。

表12-7:可选保护标志

保护标志 描述
PAGE_GUARD 一个保护页。任何访问都会导致页面保护异常
PAGE_NOCACHE 不可缓存页面。仅在内存由内核驱动程序访问且驱动程序有此要求时使用
PAGE_WRITECOMBINE 一种优化选项,某些内核驱动程序可以使用。一般情况下不应使用
PAGE_TARGETS_INVALID (Windows 10及更高版本)页面是控制流防护(CFG)的无效目标(有关CFG的更多信息,请参见第16章)
PAGE_TARGETS_NO_UPDATE (Windows 10及更高版本)在使用VirtualProtect更改保护时,不更新CFG信息(见第16章)

页面保护在调用VirtualAlloc进行新的内存分配时初始设置,也可以通过调用VirtualProtect对现有页面进行更改。我们将在下一章详细介绍这两个函数。

我们已经了解了如何使用保护页来扩展线程栈(第5章),但保护页还可以用作一种通用机制,用于检测内存何时被访问。如果因为访问保护页而引发异常,PAGE_GUARD标志会自动移除,这样在访问同一页面时就不会再引发异常。如果需要,你可以使用VirtualProtect重新设置保护页。

# 枚举地址空间区域(Enumerating Address Space Regions)

VMMap是如何获取各个区域的信息的呢?返回此数据的基本函数是针对当前进程的VirtualQuery,或者是针对任何能获取足够权限句柄的进程的VirtualQueryEx:

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
);
1
2
3
4
5
6
7
8
9
10
11
12

VirtualQueryEx的进程句柄必须具有PROCESS_QUERY_INFORMATION访问掩码。这就解释了为什么受保护的进程无法访问,因为从用户模式无法获得此访问掩码。除了进程句柄外,这两个函数的工作方式相同。lpAddress是请求信息的地址。该地址总是向下舍入到最近的页面边界。这些函数返回一个MEMORY_BASIC_INFORMATION结构,用于描述lpAddress参数中包含的区域:

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;
1
2
3
4
5
6
7
8
9

dwLength参数应设置为MEMORY_BASIC_INFORMATION结构的大小。函数返回写入缓冲区的字节数(成功时为sizeof(MEMORY_BASIC_INFORMATION));如果函数失败,则返回零。

dwLength和返回值的类型为SIZE_T,这有些不寻常且没必要,因为该值最大也就是MEMORY_BASIC_INFORMATION的大小。它本应被定义为简单的DWORD类型。

MEMORY_BASIC_INFORMATION的成员如下:

  • BaseAddress:此内存块起始地址。如果无需向下舍入,它等于lpAddress参数。
  • AllocationBase:通过VirtualAlloc分配的原始区域基地址。BaseAddress包含在该区域内。VMMap就是根据AllocationBase将内存块划分为不同区域的。
  • AllocationProtect:在VirtualAlloc调用中指定的原始页面保护(见上一节)。
  • RegionSize:此内存块的大小。该大小跨多个页面,且状态(State成员,已提交、保留、空闲)、保护(Protect成员)和类型(Type成员,私有、图像、映射)在这些页面中都相同。
  • State:取值为MEM_COMMIT、MEM_FREE或MEM_RESERVED。
  • Protect:当前保护标志。
  • Type:已提交区域的分配类型,取值为MEM_IMAGE(映射的DLL或EXE)、MEM_MAPPED(非可移植可执行文件(PE)的映射文件)或MEM_PRIVATE(私有数据)。

有些成员仅在特定情况下才有意义。例如,Type和Protect仅在State为MEM_COMMITTED时才有意义。如果State为MEM_FREE,则AllocationProtect和AllocationBase没有意义。

# 简单的VMMap应用程序(The Simple VMMap Application)

SimpleVMMap控制台应用程序很好地利用了QueryVirtualEx函数,它会列出输入进程的内存块,并提供MEMORY_BASIC_INFORMATION中的详细信息;对于映射图像区域,还会列出映射文件的路径。

该应用程序的核心部分相当简单。大部分工作在于正确显示信息。

main函数接受一个进程ID,如果未提供,则将当前进程作为目标:

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]);
    }
1
2
3
4
5
6
7
8
9

现在,我们可以打开目标进程的句柄,并调用应用程序的主要功能来显示内存映射:

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);
1
2
3
4
5
6
7

ShowMemoryMap函数从地址零开始,循环遍历各个区域,并调用一个辅助函数来显示每个区域的数据:

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;
    }
}
1
2
3
4
5
6
7
8
9
10
11

当VirtualQueryEx返回0时,这意味着我们已到达合法地址空间的末尾,操作结束。地址会按区域大小递增。将其类型定义为BYTE*便于进行指针加法运算,并且和任何指针一样,它会自动转换为void*,这正是VirtualQueryEx所期望的类型。

DisplayHeaders函数仅用于显示各种头部信息,为实际数据的展示做准备。针对每个区域都会调用DisplayBlock函数:

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());
}
1
2
3
4
5
6
7
8
9
10

如果地址是保留区域(分配基址等于基址)的起始地址,则会添加一个星号。DisplayBlock函数使用了更多辅助函数,将各种值转换为适合显示的字符串。最后,GetDetails函数使用GetMappedFileName来检索映射页面的文件名:

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 "";
}
1
2
3
4
5
6
7
8
9
10

注意,不要使用32位进程来枚举64位进程的地址空间,否则会得到错误的结果。这是因为在为32位编译时,MEMORY_BASIC_INFORMATION结构体中保存的是32位指针和大小。不过,存在特定的32位和64位结构体,分别名为MEMORY_BASIC_INFORMATION32和MEMORY_BASIC_INFORMATION64。在这种情况下可以使用后者。

以下是一个(截取部分内容的)运行示例:

(0xA608)
Base Address Size State Protection Alloc. Protecti\
on Type Details
-------------------------------------------------------------------------------\
---------------------
*0x0000000000000000 2097024 KB Free
*0x000000007FFE0000 4 KB Committed Read Read \
Private
0x000000007FFE1000 32 KB Free
*0x000000007FFE9000 4 KB Committed Read Read \
Private
0x000000007FFEA000 920090968 KB Free
*0x000000DBDDE40000 4 KB Reserved Read/Write
0x000000DBDDE41000 12 KB Committed Read/Write/Guard Read/Write \
Chapter 12: Memory Management Fundamentals 600
Private
0x000000DBDDE44000 1008 KB Committed Read/Write Read/Write \
Private
0x000000DBDDF40000 768 KB Free
...
*0x000001FB57C40000 8 KB Committed Read/Write Read/Write \
Private
0x000001FB57C42000 56 KB Free
*0x000001FB57C50000 804 KB Committed Read Read \
Mapped \Device\HarddiskVolume3\Windows\System32\locale.nls
0x000001FB57D19000 28 KB Free
*0x000001FB57D20000 68 KB Committed Read Read \
Mapped \Device\HarddiskVolume3\Windows\System32\C_1252.NLS
0x000001FB57D31000 124 KB Free
...
0x00007FF5A47B0000 8779072 KB Free
*0x00007FF7BC500000 4 KB Committed Read Execute/WriteCo\
py Image \Device\HarddiskVolume3\Windows\System32\cmd.exe
0x00007FF7BC501000 196 KB Committed Execute/Read Execute/WriteCo\
py Image \Device\HarddiskVolume3\Windows\System32\cmd.exe
0x00007FF7BC532000 44 KB Committed Read Execute/WriteCo\
py Image \Device\HarddiskVolume3\Windows\System32\cmd.exe
0x00007FF7BC53D000 8 KB Committed Read/Write Execute/WriteCo\
py Image \Device\HarddiskVolume3\Windows\System32\cmd.exe
0x00007FF7BC53F000 8 KB Committed WriteCopy Execute/WriteCo\
py Image \Device\HarddiskVolume3\Windows\System32\cmd.exe
...
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42

# 更多地址空间信息

如果你再看一下VMMap(图12 - 12),就会发现对于每个内存块,VMMap都会提供诸如该内存块的工作集(随机存取存储器,RAM)大小(私有、共享、可共享)等信息。这些额外信息可以通过PSAPI的另一个函数QueryWorkingSetEx来获取:

BOOL QueryWorkingSetEx(
    _In_  HANDLE hProcess,
    _Out_ PVOID pv,
    _In_  DWORD cb);
1
2
3
4

进程句柄必须具备PROCESS_QUERY_INFORMATION访问掩码。pv必须指向一个或多个PSAPI_WORKING_SET_EX_INFORMATION类型的结构体:

typedef union _PSAPI_WORKING_SET_EX_BLOCK {
    ULONG_PTR Flags;
    union {
        struct {
            ULONG_PTR Valid : 1;
            ULONG_PTR ShareCount : 3;
            ULONG_PTR Win32Protection : 11;
            ULONG_PTR Shared : 1;
            ULONG_PTR Node : 6;
            ULONG_PTR Locked : 1;
            ULONG_PTR LargePage : 1;
            ULONG_PTR Reserved : 7;
            ULONG_PTR Bad : 1;
#if defined(_WIN64)
            ULONG_PTR ReservedUlong : 32;
#endif
        };
        struct {
            ULONG_PTR Valid : 1;         // 在此格式中,Valid = 0。
            ULONG_PTR Reserved0 : 14;
            ULONG_PTR Shared : 1;
            ULONG_PTR Reserved1 : 15;
            ULONG_PTR Bad : 1;
#if defined(_WIN64)
            ULONG_PTR ReservedUlong : 32;
#endif
        } Invalid;
    };
} PSAPI_WORKING_SET_EX_BLOCK, *PPSAPI_WORKING_SET_EX_BLOCK;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
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;
1
2
3
4

这个结构体可能看起来很复杂,但它不过就是一个感兴趣的地址(VirtualAddress)和一组标志(VirtualAttributes)。下面是这些标志的详细说明:

  • Valid:如果页面在进程的工作集中,则设置该标志。如果该标志未设置,就应该查看联合体的Invalid部分,此时大多数其他标志没有意义。
  • Shared:用于指示页面是否可共享。如果该标志未设置,那么页面就是私有的。
  • ShareCount:如果Shared标志已设置,这个成员就表示共享计数。如果它大于1,说明该页面正在被共享。该成员的最大计数值为7,所以不应将其视为精确的共享计数。
  • Win32Protection:基本保护标志,也可以通过VirtualQuery(Ex)获取。
  • Node:表示该页面所属的非统一内存访问(NUMA,Non - Uniform Memory Access)节点。
  • Locked:如果设置了该标志,意味着页面被锁定在物理内存中(有关锁定页面的更多内容,请参阅下一章)。
  • LargePage:如果设置了该标志,说明这是一个大页面(有关大页面的更多内容,请参阅下一章)。
  • Bad:如果设置了该标志,从硬件角度来看,这个页面有问题。从技术层面讲,它也可能代表一个内存隔离区(适用于Windows 10及更高版本)(有关隔离区的更多内容,请参阅下一章)。

SimpleVMMap2应用程序是SimpleVMMap的增强版本,它为每个已提交的内存块添加了其工作集属性(前提是该内存块驻留)。

DisplayBlock函数添加了一个用于查询已提交页面范围的调用:

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

DisplayWorkingSetDetails函数承担了所有繁重的工作:

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;
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

该函数首先计算内存块中的页面数量。然后遍历每个页面,使用QueryWorkingSetEx查询其工作集状态。这段代码的唯一难点在于,不是要显示每个页面的状态,而是要将所有具有相同属性的连续页面进行分组。只要属性相同,就把块大小增加一个页面并继续循环。如果属性发生变化,就显示现有统计信息,并将变量重置为下一组值。

最后一部分是辅助函数AttributesToString,它返回属性的字符串表示形式:

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);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

下面是一个运行示例:

C:\>SimpleVMMap2.exe 42504
Memory map for process 42504 (0xA608)
Base Address Size State Protection Alloc. Protecti\
on Type Details
-------------------------------------------------------------------------------\
---------------------
*0x0000000000000000 2097024 KB Free
*0x000000007FFE0000 4 KB Committed Read Read \
Private
Address: 000000007FFE0000 ( 4 KB) Attributes: 4000802F Shareable, Sha\
red
0x000000007FFE1000 32 KB Free
*0x000000007FFE9000 4 KB Committed Read Read \
Private
Address: 000000007FFE9000 ( 4 KB) Attributes: 4000802F Shareable, Sha\
red
0x000000007FFEA000 920090968 KB Free
*0x000000DBDDE40000 4 KB Reserved Read/Write
0x000000DBDDE41000 12 KB Committed Read/Write/Guard Read/Write \
Private
Address: 000000DBDDE43000 ( 4 KB) Attributes: 00000000 (Not in workin\
g set)
...
*0x000001FB57C00000 116 KB Committed Read Read \
Mapped
Address: 000001FB57C00000 ( 56 KB) Attributes: 4000802F Shareable, Sha\
red
Address: 000001FB57C0E000 ( 16 KB) Attributes: 40008000 (Not in workin\
g set)
Address: 000001FB57C12000 ( 16 KB) Attributes: 4000802F Shareable, Sha\
red
Address: 000001FB57C16000 ( 4 KB) Attributes: 40008000 (Not in workin\
g set)
Address: 000001FB57C17000 ( 24 KB) Attributes: 4000802F Shareable, Sha\
red
...
0x00007FF7BC559000 56 KB Committed Read Execute/WriteCo\
py Image \Device\HarddiskVolume3\Windows\System32\cmd.exe
Chapter 12: Memory Management Fundamentals 606
Address: 00007FF7BC559000 ( 12 KB) Attributes: 4000802F Shareable, Sha\
red
Address: 00007FF7BC55C000 ( 4 KB) Attributes: 00400000 (Not in workin\
g set)
Address: 00007FF7BC55D000 ( 4 KB) Attributes: 4000802F Shareable, Sha\
red
Address: 00007FF7BC55E000 ( 36 KB) Attributes: 40008000 (Not in workin\
g set)
...
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48

# 共享内存

一般来说,进程拥有相互独立、互不混淆的地址空间。然而,有时在进程之间共享内存是有益的。典型的例子就是动态链接库(DLL,Dynamic-Link Library)。所有用户模式的进程都需要NtDll.dll,大多数进程还需要Kernel32.Dll、KernelBase.dll、AdvApi32.Dll等许多其他动态链接库。如果每个进程在物理内存中都有自己的动态链接库副本,那么物理内存很快就会耗尽。事实上,使用动态链接库的主要动机之一,就是能够(至少在代码层面)实现共享。按照惯例,代码是只读的,因此可以安全地共享。来自可执行文件(EXE,Executable File)的可执行代码也是如此。如果多个进程基于同一个映像文件执行,那么(至少代码部分)没有理由不进行共享。图12-14展示了这一概念,其中Kernel32.dll在两个进程之间共享。

img

图12-14:共享代码页

在图12-14中,所有共享动态链接库的进程中,该动态链接库的虚拟地址都是相同的。这是必要的,因为并非所有代码都是可重定位的。第15章将进一步讨论这个问题。

那么全局数据呢?如果我们在全局作用域中这样声明一个变量:

int x;
void main() {
    x++;
    //...
}
1
2
3
4
5

然后我们运行这个可执行文件的两个实例,第二个实例中x的值会是多少呢?答案是1。x是某个进程的全局变量,而不是系统的全局变量。动态链接库的情况也是如此。如果一个动态链接库声明了一个全局变量,那么这个变量只在加载该动态链接库的每个进程中是全局的。

在大多数情况下,这正是我们所需要的。这是通过一种称为写时复制(Copy on Write,PAGE_WRITECOPY)的页面保护机制实现的。其原理是,所有使用相同变量(在可执行文件或这些进程使用的动态链接库中声明)的进程,都将这个变量所在的页面映射到同一个物理页面(图12-15)。如果一个进程改变了这个变量的值(图12-16中的进程A),就会抛出一个异常,导致内存管理器创建该页面的一个副本,并将其作为私有页面交给调用进程,同时移除写时复制保护(图12-16中的页面3)。

img

图12-15:写时复制——更改前

img

图12-16:写时复制——更改后

如果只是简单地将任何全局数据复制到使用它的每个进程中,虽然实现起来更简单,但会浪费物理内存。如果数据没有被更改,就不需要进行复制。

在某些情况下,我们希望在进程之间共享数据。一种相对简单的机制是使用全局变量,但要指定页面应该由普通的可读可写(PAGE_READWRITE)保护,而不是写时复制(PAGE_WRITECOPY)保护。

这可以通过在可执行文件或动态链接库中构建一个新的数据段,并指定其所需属性来实现。以下展示了如何做到这一点:

#pragma data_seg("shared")
int x = 0;
#pragma data_seg()
#pragma comment(linker, "/section:shared,RWS")
1
2
3
4

data_seg编译指示(pragma)在可移植可执行文件(PE,Portable Executable)中创建一个新的节。它的名称可以是任意的(最多8个字符),上述代码中为了清晰起见将其命名为“shared”。然后,所有需要共享的变量都放在这个节中,并且必须显式初始化,否则它们不会存储在这个节中。

从技术上讲,如果有多个变量,只需要显式初始化第一个变量。不过,最好还是将所有变量都进行初始化。

第二个#pragma指令是让链接器创建一个具有RWS(读、写、共享)属性的节。其中那个小小的“S”是关键。当映像被映射时,它将不具有写时复制(PAGE_WRITECOPY)保护,因此可以在使用相同PE文件的所有进程之间共享。

这样的变量是共享的,这意味着可能会出现并发访问的情况。例如,你可能需要使用互斥锁(mutex)来保护对这些变量的访问。

SimpleShare应用程序演示了这种技术的使用。图12-17展示了该应用程序首次启动时的对话框。

img

图12-17:SimpleShare启动时

现在你可以启动更多实例,并点击“Increment”(增加)按钮将显示的值加1。你会注意到所有实例都会同步变化。每个应用程序都有一个1秒的定时器,它只是读取当前值并显示出来。图12-18展示了一些具有相同值的实例。

img

图12-18:SimpleShare实例同步

该应用程序按照上述方法声明了一个全局共享变量:

#pragma data_seg("shared")
int SharedValue = 0;
#pragma data_seg()
#pragma comment(linker, "/section:shared,RWS")
1
2
3
4

每次点击“Increment”按钮都会对该变量进行简单的加1操作:

LRESULT CMainDlg::OnIncrement(WORD, WORD wID, HWND, BOOL&) {
    SharedValue++;
    return 0;
}
1
2
3
4

定时器处理程序只是读取共享值并输出:

LRESULT CMainDlg::OnTimer(UINT, WPARAM id, LPARAM, BOOL&) {
    if (id == 1)
        SetDlgItemInt(IDC_VALUE, SharedValue);
    
    return 0;
}
1
2
3
4
5
6

一种更通用的在进程之间共享内存的技术是使用内存映射文件(Memory Mapped Files),我们将在第14章详细讨论。

# 页面文件

处理器只能访问物理内存(随机存取存储器,RAM,Random Access Memory)中的代码和数据。如果启动某个可执行文件,Windows会将该可执行文件的代码和数据(以及NtDll.dll)映射到进程的地址空间中。然后,该进程的第一个线程开始执行。这会导致它执行的代码(首先是NtDll.dll中的代码,然后是可执行文件中的代码)被映射到物理内存并从磁盘加载,以便CPU能够执行。

假设该进程的所有线程都处于等待状态,比如该进程有一个用户界面,而用户最小化了应用程序窗口,并且有一段时间没有操作该应用程序。Windows可以将该可执行文件占用的随机存取存储器重新分配给其他有需要的进程。现在假设用户恢复了应用程序窗口,Windows现在必须将应用程序的代码重新加载到随机存取存储器中。那么代码会从哪里读取呢?从可执行文件本身读取。

这意味着可执行文件和动态链接库本身就是它们自己的备份。实际上,Windows会为可执行文件和动态链接库创建一个内存映射文件(这也解释了为什么这些文件不能被删除,因为至少有一个打开的文件句柄)。

那么数据呢?如果某些数据长时间未被访问(或者Windows的可用内存不足),内存管理器可以将数据写入磁盘,存储到一个页面文件中。页面文件用于备份私有、已提交的内存。使用页面文件不是必需的,Windows即使没有页面文件也能正常运行。但这会减少一次可提交的内存量。

此外,Windows最多支持16个页面文件。它们必须位于不同的磁盘分区,并且命名为pagefile.sys,位于根分区(这些文件默认是隐藏的)。如果某个分区已满,或者另一个分区是单独的物理磁盘(这可以提高输入/输出吞吐量),那么使用多个页面文件可能会有好处。

在基于ARM架构的Windows设备上仅支持2个页面文件。

Windows 8及更高版本有另一个特殊的页面文件,名为Swapfile.sys,在某些情况下会被通用Windows平台(UWP,Universal Windows Platform)进程使用。

任务管理器(图12-6)中显示的提交限制本质上是随机存取存储器的容量加上当前所有页面文件的大小。每个页面文件都可以有一个初始大小和一个最大大小。如果系统达到提交限制,页面文件会增大到其配置的最大值,这样提交限制也会随之增加(但可能会因为更多的输入/输出操作而导致性能下降)。如果已提交的内存下降到原始提交限制以下,页面文件的大小会恢复到初始大小。

可以通过以下步骤配置页面文件的大小:打开“系统属性”,选择“高级系统设置”,在“性能”部分选择“设置”,然后选择“高级”选项卡,最后在“虚拟内存”部分选择“更改……”,这将弹出图12-19所示的对话框。在点击最后一个按钮之前,注意按钮附近会显示当前分页文件的大小。

img

图12-19:页面文件配置对话框

通常情况下,顶部的复选框是被勾选的,这会让Windows自动管理页面文件的大小。从Windows 10开始,这是我推荐的选择。早期的Windows版本使用一种启发式方法,即初始页面文件大小为随机存取存储器大小的1倍,最大大小为随机存取存储器大小的3倍(Windows 8及更高版本将最大值限制为32GB)。这些启发式方法的问题在于,系统的随机存取存储器容量与用户实际执行的工作并没有太大关联。

例如,假设某个用户的工作需要40GB的已提交内存。如果机器有8GB的随机存取存储器,那么页面文件大小应该设置为32GB左右。另一方面,如果机器有32GB的随机存取存储器,只需要8GB的页面文件大小。如果该系统有64GB的随机存取存储器,根本就不需要页面文件!

当然,拥有更多的随机存取存储器是有好处的,因为这会降低使用页面文件的可能性,但页面文件的大小与系统中的随机存取存储器容量无关。

如果在Windows 10中选择“自动管理”,它会使用一种更好的方案来确定所需的页面文件大小。它会跟踪过去14天的已提交内存使用情况,并据此调整大小,这显然与用户实际的操作相关,而与系统的随机存取存储器容量无关。

无论如何,“自动管理”复选框可以取消勾选,这样就可以自定义初始大小和最大大小,或者完全删除分页文件。

页面文件的最大大小为16TB,但在ARM架构上限制为4GB。

页面文件的配置(和Windows中的大多数设置一样)存储在注册表中,路径为HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Memory Management\PagingFiles。

# WOW64

Windows 64上的Windows(WOW64)是一个软件层,它允许32位x86可执行文件在64位(x64)系统上无需任何修改即可运行。在本节中,我们将了解其工作原理以及本章前面未讨论过的影响。

在64位Windows(x64)系统上,你会发现有两组动态链接库(DLL)和可执行文件。原生(64位)镜像存储在System32目录中(例如c:\Windows\System32),而32位镜像则存储在SysWow64目录中(例如c:\Windows\SysWow64)。这是必要的,因为Windows执行的一条基本规则是32位进程无法加载64位DLL,反之亦然。这是有道理的,因为指针大小和地址范围都不同,这样做无法正常工作。

这条规则的例外情况是,仅包含资源(字符串、位图等)而没有代码的DLL可以被任何进程加载。

这些限制的最终结果是,32位可执行文件必须链接并加载32位DLL。这就是为什么会有一个单独的目录(SysWow64)来存放Windows提供的所有32位DLL。

SysWow64目录还包含标准应用程序的32位版本,例如记事本(Notepad)、画图(mspaint)、命令提示符(cmd)等。

使用32位DLL存在一个问题,即内核仍然是64位的,这意味着任何系统调用都必须以64位的方式调用,通常由64位的NtDll提供。此外,32位系统上的标准32位NtDll会直接调用系统调用,但这在64位系统上无法工作。这意味着64位系统有一个特殊的32位NtDll,它不会调用系统调用。相反,它会调用一些辅助DLL,这些DLL提供必要的系统调用转换(更改指针大小和其他参数),然后再调用真正的64位NtDll。这种架构如图12-20所示。

img 图12-20:WOW64架构

32位和64位DLL被加载到同一个进程中,这可能会让人感到困惑。从内核的角度来看,不存在真正的32位进程。32位代码并不知道它所能看到的4GB最大地址空间之外还有更多内容。这就好比二维生物生活在桌子上,它们不知道还有第三个维度。

图12-21展示了进程资源管理器(Process Explorer)的截图,显示了同一个进程中存在两个版本的NtDll。一个来自System32目录,加载到高于4GB的高地址,另一个来自SysWow64目录,加载到低于2GB的地址(“Base”列表示镜像加载的地址)。

img

图12-21:两个NtDll.Dll镜像加载到一个32位进程中

你还可以在进程地址空间中找到三个与转换相关的DLL。

32位WOW64进程还有其他一些变化。每个线程有两个堆栈,以及每个线程有两个线程环境块(Thread Environment Block)结构。一个用于线程处于32位模式时,另一个用于线程在调用“转换层”DLL时进入64位环境时。虽然从架构角度来看这些变化很有趣,但它们不应该影响代码的执行方式。

有些应用程序编程接口(API)在WOW64进程中无法工作,例如地址窗口化扩展(Address Windowing Extension,AWE)以及ReadFileScatter和WriteFileGather函数。幸运的是,这些情况相当少见,所以在实际应用中不太可能出现问题。

# WOW64重定向

如果一个32位WOW64进程调用GetSystemDirectory函数会发生什么呢?或者直接从类似c:\Windows\System32\ws2.dll这样的路径加载DLL又会怎样呢?如前所述,32位进程无法加载64位DLL。但可执行文件并不知道它运行在64位系统上,这正是WOW64的意义所在。

Windows提供了文件系统重定向功能,因此任何访问System32目录的尝试都会自动且透明地重定向到Syswow64目录。这对于显式路径以及像GetSystemDirectory这样的函数调用都有效。在访问“程序文件”(Program Files)目录时也会发生类似的重定向,它会被重定向到“程序文件 (x86)”(Program Files (x86))目录。

线程可以通过调用Wow64DisableWow64FsRedirection函数暂时取消这种重定向:

BOOL Wow64DisableWow64FsRedirection(_Out_ PVOID* OldValue);
1

OldValue参数是一个不透明的值,应该传递给Wow64RevertWow64FsRedirection函数以重新启用重定向:

BOOL Wow64RevertWow64FsRedirection(_In_ PVOID OldValue);
1

对于知道WOW64并且需要按实际情况进行输入/输出(I/O)操作的应用程序来说,禁用重定向可能会很有用。为了方便起见,该应用程序可能被编写为32位,以便它可以在32位和64位系统上无需修改即可运行。

禁用重定向仅对当前线程有效。进程中的其他线程不受影响,除非它们也请求禁用文件系统重定向。

要在不进行重定向的情况下访问真正的System32目录,可以使用虚拟路径c:\Windows\Sysnative。

WOW64层自动采用的另一种重定向形式是针对某些注册表项的。这将在第17章中讨论。

# 虚拟地址转换

在本章的最后一节,我们将了解虚拟地址如何转换为物理地址的基础知识。本节是可选内容,可以完全跳过。关于转换表的详细讨论超出了本书的范围;有关更多内容,请参阅《Windows Internals 7th edition, Part 1》一书的第5章。转换本身是自动进行的,因此当CPU看到类似这样的指令时:

mov eax, [100000H]
1

它知道地址0x100000是虚拟地址而不是物理地址(因为CPU被配置为在保护模式/长模式下运行)。CPU现在必须查看内存管理器预先准备好的表,这些表描述了该页面在随机存取存储器(RAM)中的位置(如果有的话)。如果该页面不在RAM中(在转换表中由CPU检查的有效位标记为零),它会引发页面错误异常,由内存管理器进行适当处理。地址转换涉及的基本组件如图12-22所示。

img 图12-22:虚拟地址转换

CPU以虚拟地址作为输入,并应输出(并使用)物理地址。由于一切都是按页面进行处理的,地址的低12位(页面内的偏移量)永远不会被转换,直接传递到最终地址。

CPU进行转换需要上下文。每个进程都有一个始终驻留在RAM中的初始结构。对于32位系统,它被称为页目录指针表(Page directory pointer table),对于64位系统,它是页映射级别4(这是英特尔的术语)。从这个初始结构出发,会使用其他结构,包括页目录,最后是页表(转换“树”的叶子节点)。页表项指向物理页面地址(如果有效位被设置)。当一个页面被移动到页面文件时,内存管理器会将相应的页表项标记为无效,这样下次CPU遇到该页面时,就会引发页面错误异常。

最后,转换后备缓冲器(Translation Lookaside Buffer,TLB)是一个最近转换页面的缓存,因此访问这些页面无需遍历多层结构进行转换。这个缓存相对较小,但从实际角度来看非常重要。这也强调了我们在第10章中讨论的与缓存和连续内存相关的一些内容:在相近时间内处理相同的内存地址范围有利于利用TLB缓存。

# 总结

在本章中,我们开启了对虚拟内存和物理内存世界的探索之旅。我们了解了进程的地址空间、页面状态等内容。在下一章(以及下一本书)中,我们将学习如何在应用程序中有效地使用与内存相关的API。

第11章:文件和设备输入输出
第13章:内存操作

← 第11章:文件和设备输入输出 第13章:内存操作→

最近更新
01
C++语言面试问题集锦 目录与说明
03-27
02
第四章 Lambda函数
03-27
03
第二章 关键字static及其不同用法
03-27
更多文章>
Copyright © 2024-2025 沪ICP备2023015129号 张小方 版权所有
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式