调试实战 | 记一次有教益的 vs2022 内存分配失败崩溃分析(续)

前言

前一阵子遇到了 vs2022 卡死的问题,在上一篇文章中重点分析了崩溃的原因 —— 当 vs2022 尝试分配 923MB 的内存时,物理内存+页文件大小不足以满足这次分配请求,于是抛出异常。

本篇文章将重点挖掘一下 vs2022 在崩溃之前已经分配的内容。

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

还是先从调用栈入手,找到关键参数,然后查看参数内容。

查找 vector 对象地址

栈帧 0b 对应的函数是 std::vector<T>::_Emplace_reallocate(),栈帧 0c 会调用这个函数。根据调用约定可知,调用类成员函数时,rcx 会指向类对象,在这里 rcx 会指向 std::vector<std::shared_ptr<std::stringstream>> 类型的实例。可以通过查看栈帧 0c 的反汇编代码确定 rcx 的来源。

view-vector-instance-from-stackframe-0c

从图中可知,rcx 的值来自 rbp-0x70。那 rbp 的值是多少呢?使用 uf 查看 vcpkg!code_store::a_store::a_thread_impl::append_code_item_name() 函数的反汇编代码。

view-rbp-in-frame-0c

由上图可知,先把 rsp-0x920 赋值给 rbp,然后 rsp 会减小 0xa20。所以可以通过 rsp+0xa20-0x920 计算出对应的 rbp 的值,再减去 0x70 即可得到 rcx 的值。由此可知 vector 对象的地址是 0x000000b1 6547e5d0

view-rbp-rcx-in-frame-0c

查看 vector 内容

查阅 vs 提供的 STL 源码可知,vector 对象起始偏移 0 的位置存储了第一个元素的地址,起始偏移 864位程序)的位置存储了最后一个元素后面的地址。可以查看 vector 中前 20 个元素。

view-vector-front-20-data

由调用栈可知,vector 中的元素类型是 shared_ptr<stringstream>。根据源码可知,shared_ptr<T> 类型的大小是 16 字节,偏移 0 的位置存储了对象的地址,偏移 8 的位置存储了引用计数对象的地址。

1
2
3
4
5
6
7
8
9
10
11
12
template <class _Ty>
class shared_ptr : public _Ptr_base<_Ty> { // class for reference counted resource management
...
};

template <class _Ty>
class _Ptr_base { // base class for shared_ptr and weak_ptr
private:
element_type* _Ptr;
_Ref_count_base* _Rep;
...
};

vector 中有多少个元素

大家应该都知道,vector 中的元素是顺序存储的,知道了起始地址及结束地址,也知道每个元素的大小,可以很容易计算出 vector 中的元素数量。

windbg 中输入 ? (000001c2434b7170-000001c21ccdd060) / 0n16 可以得到元素个数 40360465

根据上次分析的结果可知,分配的元素数量是60540697。 通过查看 vs 提供的源码可知,容器扩容时会按 1.5 倍进行扩容。

来验证以一下是否符合这个规律。在 windbg 中输入 ? 0n40360465 + 0n40360465 / 2 可以得到结果 60540697

view-vector-size

可见,当时 vs 在调用类似 push_back() 之类的方法向容器中增加元素,但是容器正好满了,触发了扩容操作。由此也可以验证之前的分析是正确的。

验证引用计数对象数据

拿第一个元素进行验证,实际对象的地址是 000001be 580056f0,引用计数对象的地址是 000001be 580056e0。先验证引用计数对象是否正确。

_Ref_count_base 结构如下图所示:

view-_Ref_count_base_detail

说明: devenv 加载的模块所对应的调试符号已经去除了 Type 信息,没办法通过 dt 显示类型信息。上图是我用 windbg 调试新建的测试工程时的截图。

从下图可知,引用计数相关数据是完美匹配的。

verify-reference-count-object

一般 shared_ptr<T> 的引用计数和实际的数据是没有关系的,比如下面的代码:

1
2
int* p = new int();
std::shared_ptr<int> sp(p);

view_normal_shared_ptr

sp._Ptr 的值是 0x017b9450sp._Rep 的地址是 0x017b9640,两者之间没有明显关系。

但是,如果你观察的比较仔细,可以发现一个非常有趣的现象 —— vector 中的每个元素(智能指针)的引用计数对象的地址 +0x10 正好等于实际对象的地址。

以第一个元素为例,引用计数对象的地址是 000001be 580056e0,实际对象的地址是 000001be 580056f0,两者正好相差了 0x10

这是怎么回事呢?如果你对 stl 比较熟悉,可能已经想到了 std::make_shared()vector 中存储的对象都是通过 std::make_shared() 创建出来的。

make_shared

我摘录了 vs 中提供的源码

1
2
3
4
5
6
7
template <class _Ty, class... _Types>
shared_ptr<_Ty> make_shared(_Types&&... _Args) { // make a shared_ptr to non-array object
const auto _Rx = new _Ref_count_obj2<_Ty>(_STD forward<_Types>(_Args)...);
shared_ptr<_Ty> _Ret;
_Ret._Set_ptr_rep_and_enable_shared(_STD addressof(_Rx->_Storage._Value), _Rx);
return _Ret;
}

注意代码中 _Ret._Set_ptr_rep_and_enable_shared() 第一个参数的值是 _Rx->_Storage._Value 的地址。

_Rx 的类型是 _Ref_count_obj2<_Ty>*_Ref_count_obj2 继承自 _Ref_count_base。而 _Ref_count_base 的大小是 16 字节:虚表指针 8 字节,两个引用计数各占 4 字节,一共 16 字节。

大概的内存结构图如下:

make_shared_relation

务必注意 _Ref_count_obj2 中的 _Storage 存储了整个目标对象,而不是指针。

总结

  • 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 类型的内存是受物理内存+分页文件大小限制的。

参考资料

BianChengNan wechat
扫描左侧二维码关注公众号,扫描右侧二维码加我个人微信:)
0%