Windows中的内存一: 内存的分类

memetao 于 2025-04-20 发布

概要

此文记录windows中的内存分类

VVMap 工具

有个事情是这样子的,程序临近发版突然发现V2版本的内存绝对值比V1版本增加了30MB。

各模块负责人都坚称自己的修改不会引入额外的内存,于是领导让我搞清楚是哪里占用了更多的内存。

用VMMap打开两个进程, 来观看两个进程的内存分布:

V1: V1 V2: VV

比较清晰的看到了V2版本的Image区域比V1版本多了46MB, 大概就是这个区域里面有个新增的dll影响了进程的内存大小(任务管理器中见到的)

对比这两个版本加载的dll, 发现V2比V1新增了一个nvoglv64.dll。

各个模块的同学很多时候可能是无意调用了第三方库导致了新dll的加载,他自己也不知道。必须要进一步确定这个dll是由哪段代码加载的才能找到相应的同学来排查。

Windbg加载V2进程并设置好PDB路径:

lm #打印此时加载的dll列表发现没有这个nvoglv64.dll

sxe ld:nvoglv64.dll #当这个dll加载时中断

go #继续跑, 出现断点的时候打印函数栈就一目了然

内存分类

从VVMap看到内存分为了三大类:Committed、Private Bytes、Working Set. 从一个程序来分析(内存页4K):

int main() {
    constexpr SIZE_T SIZE = 100 * 1024 * 1024; // 100 MB
    // Reserve address space, but do not commit yet
    void* ptr = VirtualAlloc(nullptr, SIZE, MEM_RESERVE, PAGE_READWRITE);
    std::cout << "Reserved only. Press Enter...\n";
    std::cin.get();

    if (!VirtualAlloc(ptr, SIZE, MEM_COMMIT, PAGE_READWRITE)) {
        std::cerr << "Commit failed: " << GetLastError() << "\n";
    }
    std::cout << "Committed. Press Enter...\n";
    std::cin.get();

    char* data = static_cast<char*>(ptr);
    for (size_t i = 0; i < SIZE; i += 4096)
        data[i] = 1;
    std::cout << "Accessed memory. Press Enter to exit...\n";
    std::cin.get();
    VirtualFree(ptr, 0, MEM_RELEASE);
    return 0;
}

从VVMap中可以明显观察到有个102,000KB的段从Reserve(Protection描述)变成了Read/Write(Commited), 并且当对这个内存段进行写之后,Working Sets部分也会突然增大。

MEM_RESERVE

预留地址空间以便将来使用,以下情况下会失败:

只分配了地址和页表(PTE),不分配物理内存、Pagefile空间,并且访问会触发异常Access Violation

Private

Private内存指的是只属于当前进程的内存区域,这些内存不与其他进程共享。一般来说,Private 内存是由进程自己的代码、堆、栈或者通过 VirtualAlloc 分配的内存所占用的。

1. 私有且不共享:

2. 不同于映射文件或共享内存:

3. 可以通过 MEM_COMMIT 或 MEM_RESERVE申请:

如何通过API来获取一个进程的内存分配信息:

SIZE_T GetPrivateCommittedMemory() {
    SIZE_T total = 0;
    MEMORY_BASIC_INFORMATION mbi = {};
    BYTE* addr = 0;

    while (VirtualQuery(addr, &mbi, sizeof(mbi)) == sizeof(mbi)) {
        if ((mbi.State == MEM_COMMIT) && (mbi.Type == MEM_PRIVATE)) {
            total += mbi.RegionSize;
        }
        addr += mbi.RegionSize;
    }

    return total;
}

Working Set

Working Set是进程当前驻留在物理内存(RAM)中的、最近被访问过的虚拟内存页集合。

换句话说:

任务管理器中见到的内存就属于working set

malloc 和 new

注意到 VirtualAlloc分配的内存在VMMap里有明显的ReserveCommit显示,但用 malloc 或 new 却看不到类似的现象。这是因为它们之间的本质差别在于: | 特性 | VirtualAlloc | malloc / new | | ——————— | ———————————— | ——————————————– | | 调用层级 | 直接调用内核 API | 调用 CRT(C Runtime)堆管理器 | | 目标 | 分配整个虚拟内存页(通常4KB对齐) | 管理小块内存、适配频繁分配释放 | | Reserve/Commit 可见性 | 直接体现为 VAD(虚拟地址描述符)结构 | CRT 堆由一整块 VirtualAlloc 支撑,不暴露细节 | | 是否可控内存属性 | 是(如 PAGE_NOACCESS) | 否,由 CRT 控制 | | 适合 | 大内存、显式控制 | 小块频繁分配 |

malloc(size) ≠ VirtualAlloc(size)

它的实际流程大致如下:

也就是说:小块的 malloc 实际上是从某个已经提交的堆中“划出来”的,你看不到新的 commit/reserve 显示,因为那是堆的事,不是单个malloc的事

内存优先级(Memory Priority)

Memory Priority 是操作系统对进程或其内存页在内存中的“重要性”评估指标。Windows 会根据这个优先级,决定在内存压力大时,谁的页面先被驱逐出Working Set。优先级越低,越容易被回收。

虚拟内存地址空间

在 Windows x64 下,一个进程的虚拟地址空间划分为: | 区域 | 范围 | 用途 | | ——– | ————————————————– | ———————- | | 用户空间 | 0x0000000000000000 ~ 0x00007FFFFFFFFFFF(128TB) | 程序可用空间 | | 内核空间 | 0xFFFF080000000000 ~ 0xFFFFFFFFFFFFFFFF(128TB) | 内核使用空间,不可访问 |

默认使用4K大小为一页,每一页有几个状态: | 状态 | 说明 | | ——— | ———————————— | | Free | 未分配 | | Reserved | 保留地址空间(尚未使用物理内存) | | Committed | 实际申请了物理页(或 Pagefile 空间) | | Mapped | 映射文件内存 | | Guard | 特殊保护页,用于栈扩展检测 |

当你访问一个未在物理内存中的虚拟页时,会触发: | Page Fault 类型 | 说明 | | —————- | —————————————- | | Soft Page Fault | 页面在 Pagefile 或被换出,加载即可 | | Hard Page Fault | 需要从磁盘读取数据 | | Access Violation | 地址无效或无权限,进程崩溃(0xC0000005) |

一个进程的地址空间大致如下:

低地址
↓
[code] [.data] [.bss] [heap --> ...] ............. [stack <--]
↑
高地址

Windows 进程地址空间中主模块和 DLL 是如何被映射进内存的?

在一个 Windows 进程中,EXE文件和所依赖的DLL会以一种映射文件的方式被加载进虚拟地址空间。这个过程是操作系统通过CreateProcess + LoadLibrary等机制完成的。

类型 映射位置 内容 示例段
主模块(EXE) 较低地址(如 0x00400000 程序本体 .text, .data, .rdata
DLL 模块 稍高地址(如 0x7xxx0000 静态/动态依赖库 同样有 .text 等段
Mapped File DLL 通过内存映射进来 只读页、页保护  
Stack 高地址向下 每个线程一个栈  
Heap 程序启动后分配 多个堆(默认 + 创建的)  

加载时机

不论哪种方式,本质上系统都会调用NtMapViewOfSection来将DLL映射进进程的虚拟地址空间

寻找地址:系统如何决定 DLL 加载到哪里?

每个 DLL 都有一个默认的 ImageBase 地址(PE 头中定义):

加载过程如下: