首页
社区
课程
招聘
[原创]记一次有教益的 vs2022 内存分配失败崩溃分析
发表于: 2023-10-15 19:35 11354

[原创]记一次有教益的 vs2022 内存分配失败崩溃分析

2023-10-15 19:35
11354

之前一直以为 64 位进程很难出现内存分配异常,因为 64 位进程的虚拟内存空间非常大(总共 64 位,目前只用了 48 位,也就是 256TB,用户态可以使用一半,也就是 128TB)。没想到,前一阵子居然遇到了 vs2022vs 终于有了 64 位的版本)分配内存失败的情况。分析到最后是因为分配 MEM_COMMIT 类型的内存失败导致的异常。一起来看看吧。

说明: 本文很早就写了草稿,一直没时间整理发布,Finally~

前一阵子,我在使用 vs2022 编辑代码的时候,不知道做了什么操作导致 vs2022 卡住了。过了一段时间后,procdump 自动运行起来了(因为我把 procdump 设置为了 JIT 调试器。当有进程崩溃的时候,procdump 会自动执行转储操作),等了好一会儿才退出。查看 d:\dumps\ 目录下的转储文件,真是不看不知道,一看吓一跳,对应的转储文件居然有将近 20GB

看来,vs2022 应该是遇到了内存方面的异常。
vs2022-dump-file

使用 windbg 打开对应的转储文件,执行 k 查看调用栈。如下图:

callstack-of-new-memory-throw-exception

可以很明显的看到是在调用 new() 分配内存失败后抛出了异常。再多查看几个栈帧,可以发现是由 vector_Emplace_reallocate() 函数触发的内存分配。vs202264 位的进程,虚拟内存空间可以说是大的离谱。居然内存分配会失败!有点意思,那到底分配了多大内存呢?

查看栈帧 0a0b 的反汇编代码,如下图:

search-new-size
从上图可知,new() 的参数 rcx 来自栈帧 0a 中的 raxrax 又来自 rcx+0x27rcx 来自栈帧 0b 中的 raxraxvcpkg!std::_Get_size_of_n<16>() 的返回值,vcpkg!std::_Get_size_of_n<16>() 的参数 rcx 来自 rax,而这个 rax 保存到栈上 rsp+0x78 的位置。可以先拿到 rsp+0x78 处的值,然后一步步推导出传递给 new() 的参数。

在开始之前,先了解一下 vcpkg!std::_Get_size_of_n<size_t>() 的逻辑。vcpkg!std::_Get_size_of_n<size_t>() 是一个模板函数,模板参数是元素类型的大小,这里是 0n16。该函数的实现很简单,就是返回 _Count * _Ty_size (当然还有一些界限检查,下面是精简后的代码)。

可以通过查看栈帧 0b rsp+0x78 位置的值得到要分配的元素个数,然后乘以 0n16 就可以得到最终传递给 new() 的值。

get-allocate-size-pass-to-new
计算后得知,本次分配的空间大约为 923MB

注意: 虽然分配的内存空间不是超级大,但是本次尝试分配的元素个数是 0x039bc719,通过 .formats 0x039bc719 可以查看对应的十进制数是 60540697,也就是大约 6 千万个对象!

说实话,分析到这里的时候,我是有点儿没底气的。vs2022 可是 64 位的进程啊!没想到只分配大概 923MB 就失败了!带着这个疑问,继续查看当前进程的地址空间情况。

可以使用 !address -summary 看一下内存使用情况,如下图:

address-summary
可以发现最大的空闲空间大概有 119.96TB 这么大。

说明: 既可以通过上面的 Largest Region by Usage 查看最大的空闲空间,还可以通过 !address -f:Free -c:".if(%3 > 0x80000000) {.echo %1 %2 %3}" 显示出大于 0x80000000 的空闲段,如下图:

get-address-region-larger-than-0x80000000

顺便说一句,第一次执行的时候是真的慢!

既然有足够大的空闲空间,为什么分配内存还会失败呢?看到这里我更疑惑了,同时心里有了另外一个疑问—— 在 x64 进程中,用户态代码到底可以分配多大内存?

于是我写了一段使用 malloc 分配内存的测试代码。如下:

经过几次调整后发现,大概分配 20GB 的时候就失败了。

当使用 malloc() 分配大块内存时,会调用 ntdll!NtAllocateVirtualMemory() 进行分配。使用 windbg 运行程序,当执行到 auto p = malloc(size); 这一行的时候,执行 bp ntdll!NtAllocateVirtualMemory 设置好断点。然后执行 g 让程序继续运行,很快就中断下来了。执行 gu 跳出当前函数,使用 r 命令查看寄存器,主要关注 rax,因为它保存了函数的返回值。发现 rax 的值是 00000000c000012d。由 ntdll!NtAllocateVirtualMemory() 的函数原型可知,返回值是 NTSTATUS 类型的。

查看官方文档可知,0xc000012d 的意义是 STATUS_COMMITMENT_LIMIT

c000012d_status_commitment_limit
根据 Description 列的描述可知,增大页面文件的大小可能会有帮助。看到这里的时候,我突然想起来,好像 malloc() 在调用ntdll!NtAllocateVirtualMemory() 的时候,传递的 AllocationType 应该是包含 MEM_COMMIT 标志的(因为可以直接对返回的地址空间进行读写操作了)。而分配这种类型的内存,windows 会检查是否有足够的内存(物理内存+页文件)支撑,如果剩余的物理内存+页文件(会被系统中的所有进程共同使用)的大小不能满足本次分配,那么会报错。

如果把 MEM_COMMIT 换成 MEM_RESERVE,能分配多大的内存呢?

于是我又写了一段测试代码,直接调用 VirtualAlloc() 进行内存分配。测试代码如下:

运行结果如下:

test-max-reserve-memory
当分配类型是 MEM_RESERVE 的时候,一次性最多可以分配大概 126 TB 的虚拟内存。基本符合之前的认知。

注意: 每次运行结果不完全一致,不过相差不多。

为了更好的展示不同情况下分配 MEM_COMMIT 的结果,我又添加如下测试代码:

我分别在不同系统内存占用的情况下运行了三次,三次运行结果如下:

当系统内存相对充裕的时候,运行结果如下:

allocate-memory-commit-1

当系统内存被消耗了一部分的时候,运行结果如下:

allocate-memory-commit-2

当使用 TestLimit64 -m 2048 -c 10 分配 20GBMEM_COMMIT 内存后,运行结果如下:
allocate-memory-commit-3

画外音: 当我尝试模拟系统内存吃紧的时候,突然想起来 Testlimit 就是用来测试各种资源极限的。-m 模拟的是分配 MEM_COMMIT 类型内存的,-r 模拟的是分配 MEM_RESERVER 类型内存的。-c 是分配数量,如果不指定,则无限分配。

现在还剩下两个问题待证实:

使用 k 3 显示 3 个调用栈帧。栈帧 01 会调用 ntdll!NtAllocateVirtualMemory(),所以栈帧 01 会传递参数给ntdll!NtAllocateVirtualMemory()。使用 ub 00007ffe30492762 L1a 查看相关调用代码,可以发现 AllocationType 保存在 rsp+0x20 的位置,Protect 保存在 rsp+0x28 的位置,前四个参数分别由 rcx, rdx, r8, r9 进行传递。

view-AllocationType-param
由此,可以确定之前的理解是正确的。 malloc() 调用 ntdll!NtAllocateVirtualMemory() 时,AllocationType 的值是 MEM_COMMIT

因为转储文件只包含当前进程的信息,没有系统级的转储文件,不好确认系统中的其它进程的内存使用情况。但是转储文件的大小已经达到了 18.3GB,本次尝试分配的大小是 923MB,加上 18.3GB,大概是 19GB

通过 .time 命令,可以发现系统已经运行了接近 2 天,当前进程已经运行了大概 18.5 个小时。由系统开机时间可以推算,当时应该有不少进程在运行(我的系统上,chromefirefox 基本是常开状态)。

show-system-and-process-running-time
而且在 vs2022 无响应的时候,整个系统确实有些卡顿。以上种种迹象表明,系统当时的内存吃紧。这时候出现内存分配异常,确实合情合理。

前几天,客户的程序也遇到了一个类似的问题。她机器上内存紧张的时候,执行程序中的一个功能需要分配 196MB 的内存,由于物理内存不足,失败了。因为我之前已经调查过类似的问题了,在调查客户的问题的时候,非常快速而且有信心。我想这就是写文章记录的价值之一吧!

procdump 真是事后调试的好帮手。以管理员权限运行 procdump -i -ma d:\dumps\ 即可安装。-i 表示安装(如果要卸载,可以使用 -u 参数)。-ma 表示执行完整转储,d:\dumps\ 表示 .dmp 文件保存的位置。

相较于 32 位进程的 4GB232 次方)虚拟内存空间而言, 64 位进程的虚拟内存空间超级大,目前是 256TB(总共 64 位,目前只用了 48 位),内核态和用户态平均分,用户态可以使用一半,也就是 128TB

如果使用 malloc() 或者 new() (内部会调用 malloc())分配的内存大小超出堆阈值,那么内部会使用 NtAllocateVirtualMemory() 分配内存,而且 AllocationType 的值是 MEM_COMMIT。分配 MEM_COMMIT 类型的内存是受物理内存+分页文件大小限制的。

NTSTATUS Values

https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-erref/596a1078-e883-4972-9bbc-49e60bebca55

template <size_t _Ty_size>
constexpr size_t _Get_size_of_n(const size_t _Count) {
    return _Count * _Ty_size;
}
template <size_t _Ty_size>
constexpr size_t _Get_size_of_n(const size_t _Count) {
    return _Count * _Ty_size;
}
int main()
{
    size_t size = 1024 * 1024 * 1024;
    size *= 20;
    auto p = malloc(size);
    return 0;
}
int main()
{
    size_t size = 1024 * 1024 * 1024;
    size *= 20;
    auto p = malloc(size);
    return 0;
}
NTSTATUS NtAllocateVirtualMemory(
  [in]      HANDLE    ProcessHandle,
  [in, out] PVOID     *BaseAddress,
  [in]      ULONG_PTR ZeroBits,
  [in, out] PSIZE_T   RegionSize,
  [in]      ULONG     AllocationType,
  [in]      ULONG     Protect
);
NTSTATUS NtAllocateVirtualMemory(
  [in]      HANDLE    ProcessHandle,
  [in, out] PVOID     *BaseAddress,
  [in]      ULONG_PTR ZeroBits,
  [in, out] PSIZE_T   RegionSize,
  [in]      ULONG     AllocationType,
  [in]      ULONG     Protect
);
#include <iostream>
#include "windows.h"
 
const size_t one_gb = 1LL * 1024LL * 1024LL * 1024LL; // 1 GB
 
double ToGb(size_t bytes)
{
    return bytes / 1024.0 / 1024.0 / 1024.0;
}
 
double ToTb(size_t bytes)
{
    return bytes / 1024.0 / 1024.0 / 1024.0 / 1024.0;
}
 
size_t TestMaxAllocateMemory(size_t init_size, size_t decrease_size, DWORD allocation_type)
{
    LPVOID p = nullptr;
    while (nullptr == (p = VirtualAlloc(nullptr, init_size, allocation_type, PAGE_READWRITE)))
    {
        auto last_error = GetLastError();
        std::cout << "allocate " << ToGb(init_size) << " GB failed. last error:" << last_error
            << ". try allocate " << ToGb(init_size - decrease_size) << " GB." << std::endl;
 
        init_size -= decrease_size;
    }
 
    return init_size;
}
 
void TestMaxReserveMemory()
{
    size_t size_reserve = 128LL * 1024LL * one_gb;  // 128 TB
    size_reserve = TestMaxAllocateMemory(size_reserve, one_gb, MEM_RESERVE);
    std::cout << "allocate " << ToTb(size_reserve) << " TB reserver memory success." << std::endl;
}
 
int main()
{
    TestMaxReserveMemory();
    std::getchar();
    return 0;
}
#include <iostream>

[培训]内核驱动高级班,冲击BAT一流互联网大厂工作,每周日13:00-18:00直播授课

收藏
免费 6
支持
分享
最新回复 (6)
雪    币: 6124
活跃值: (4656)
能力值: ( LV6,RANK:80 )
在线值:
发帖
回帖
粉丝
2
呃呃呃,根据惯用法都是先分配再提交的,f5一下vs2022那部分的代码看看?感觉不应该有这种问题
2023-10-16 00:06
0
雪    币: 8519
活跃值: (9122)
能力值: ( LV12,RANK:360 )
在线值:
发帖
回帖
粉丝
3
黑洛 呃呃呃,根据惯用法都是先分配再提交的,f5一下vs2022那部分的代码看看?感觉不应该有这种问题
您可以试试直接分配 30GB 的内存看看是否能成功 就用这段代码就行
void TestMaxCommitMemory()
{
    size_t size_commit = 128LL * one_gb; // 128 GB
    size_commit = TestMaxAllocateMemory(size_commit, one_gb, MEM_COMMIT);
    std::cout << "allocate " << ToGb(size_commit) << " GB commit memory success." << std::endl;
}
 
int main()
{
    //TestMaxReserveMemory();
    TestMaxCommitMemory();
    std::getchar();
    return 0;
}
2023-10-16 07:21
0
雪    币: 3004
活跃值: (30861)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
4
感谢分享
2023-10-16 09:05
1
雪    币: 6124
活跃值: (4656)
能力值: ( LV6,RANK:80 )
在线值:
发帖
回帖
粉丝
5

用给出的代码是可以测试成功的(虽然可以成功,但是一般也不推荐直接分配)

最后于 2023-10-18 11:30 被黑洛编辑 ,原因:
2023-10-18 11:24
0
雪    币: 8519
活跃值: (9122)
能力值: ( LV12,RANK:360 )
在线值:
发帖
回帖
粉丝
6
黑洛 用给出的代码是可以测试成功的(虽然可以成功,但是一般也不推荐直接分配)
您机器有多大的物理内存?分页文件有多大?我机器是 16GB 的物理内存,128GB 肯定会失败。您可以多开几个进程,后面的就会失败了。
2023-10-18 18:29
0
雪    币: 8519
活跃值: (9122)
能力值: ( LV12,RANK:360 )
在线值:
发帖
回帖
粉丝
7
黑洛 用给出的代码是可以测试成功的(虽然可以成功,但是一般也不推荐直接分配)
COMMIT 类型的内存是分配的时候 系统会做检查(系统会做COMMIT,如果成功了,那么后面就可以放心用了),并不会立刻分配真的物理内存,只有在访问的时候才会分配真正的物理内存。
2023-10-18 18:30
0
游客
登录 | 注册 方可回帖
返回
//