前言
前一阵子遇到了 vs2022
卡死的问题,在上一篇文章中重点分析了崩溃的原因 —— 当 vs2022
尝试分配 923MB
的内存时,物理内存+页文件大小不足以满足这次分配请求,于是抛出异常。
本篇文章将重点挖掘一下 vs2022
在崩溃之前已经分配的内容。
说明: 本文很早就写了草稿,一直没时间整理发布,Finally~
还是先从调用栈入手,找到关键参数,然后查看参数内容。
查找 vector 对象地址
栈帧 0b
对应的函数是 std::vector<T>::_Emplace_reallocate()
,栈帧 0c
会调用这个函数。根据调用约定可知,调用类成员函数时,rcx
会指向类对象,在这里 rcx
会指向 std::vector<std::shared_ptr<std::stringstream>>
类型的实例。可以通过查看栈帧 0c
的反汇编代码确定 rcx
的来源。
从图中可知,rcx
的值来自 rbp-0x70
。那 rbp
的值是多少呢?使用 uf
查看 vcpkg!code_store::a_store::a_thread_impl::append_code_item_name()
函数的反汇编代码。
由上图可知,先把 rsp-0x920
赋值给 rbp
,然后 rsp
会减小 0xa20
。所以可以通过 rsp+0xa20-0x920
计算出对应的 rbp
的值,再减去 0x70
即可得到 rcx
的值。由此可知 vector
对象的地址是 0x000000b1 6547e5d0
。
查看 vector 内容
查阅 vs
提供的 STL
源码可知,vector
对象起始偏移 0
的位置存储了第一个元素的地址,起始偏移 8
(64
位程序)的位置存储了最后一个元素后面的地址。可以查看 vector
中前 20
个元素。
由调用栈可知,vector
中的元素类型是 shared_ptr<stringstream>
。根据源码可知,shared_ptr<T>
类型的大小是 16
字节,偏移 0
的位置存储了对象的地址,偏移 8
的位置存储了引用计数对象的地址。
1 | template <class _Ty> |
vector 中有多少个元素
大家应该都知道,vector
中的元素是顺序存储的,知道了起始地址及结束地址,也知道每个元素的大小,可以很容易计算出 vector
中的元素数量。
在 windbg
中输入 ? (000001c2434b7170-000001c21ccdd060) / 0n16
可以得到元素个数 40360465
。
根据上次分析的结果可知,分配的元素数量是60540697
。 通过查看 vs
提供的源码可知,容器扩容时会按 1.5
倍进行扩容。
来验证以一下是否符合这个规律。在 windbg
中输入 ? 0n40360465 + 0n40360465 / 2
可以得到结果 60540697
。
可见,当时 vs
在调用类似 push_back()
之类的方法向容器中增加元素,但是容器正好满了,触发了扩容操作。由此也可以验证之前的分析是正确的。
验证引用计数对象数据
拿第一个元素进行验证,实际对象的地址是 000001be 580056f0
,引用计数对象的地址是 000001be 580056e0
。先验证引用计数对象是否正确。
_Ref_count_base
结构如下图所示:
说明:
devenv
加载的模块所对应的调试符号已经去除了Type
信息,没办法通过dt
显示类型信息。上图是我用windbg
调试新建的测试工程时的截图。
从下图可知,引用计数相关数据是完美匹配的。
一般 shared_ptr<T>
的引用计数和实际的数据是没有关系的,比如下面的代码:
1 | int* p = new int(); |
sp._Ptr
的值是 0x017b9450
,sp._Rep
的地址是 0x017b9640
,两者之间没有明显关系。
但是,如果你观察的比较仔细,可以发现一个非常有趣的现象 —— vector
中的每个元素(智能指针)的引用计数对象的地址 +0x10
正好等于实际对象的地址。
以第一个元素为例,引用计数对象的地址是 000001be 580056e0
,实际对象的地址是 000001be 580056f0
,两者正好相差了 0x10
。
这是怎么回事呢?如果你对 stl
比较熟悉,可能已经想到了 std::make_shared()
,vector
中存储的对象都是通过 std::make_shared()
创建出来的。
make_shared
我摘录了 vs
中提供的源码
1 | template <class _Ty, class... _Types> |
注意代码中 _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
字节。
大概的内存结构图如下:
务必注意 _Ref_count_obj2
中的 _Storage
存储了整个目标对象,而不是指针。
总结
procdump
真是事后调试的好帮手。以管理员权限运行procdump -i -ma d:\dumps\
即可安装。-i
表示安装(如果要卸载,可以使用-u
参数)。-ma
表示执行完整转储,d:\dumps\
表示.dmp
文件保存的位置。相较于
32
位进程的4GB
(2
的32
次方)虚拟内存空间而言,64
位进程的虚拟内存空间超级大,目前是256TB
(总共64
位,目前只用了48
位),内核态和用户态平均分,用户态可以使用一半,也就是128TB
。如果使用
malloc()
或者new()
(内部会调用malloc()
)分配的内存大小超出堆阈值,那么内部会使用NtAllocateVirtualMemory()
分配内存,而且AllocationType
的值是MEM_COMMIT
。分配MEM_COMMIT
类型的内存是受物理内存+分页文件大小限制的。
参考资料
vs
源码- NTSTATUS Values