摘要
本文记录了一次独特的调试经历:作为开发利器的 Visual Studio 2022,其在切换调用栈时频繁崩溃。面对这一问题,利用 procdump 自动捕获崩溃转储文件,并通过 WinDbg 初步排查将问题指向堆内存的异常操作,可能是堆损坏或重复释放。为了精准定位,我启用 gflags 工具开启页堆检测,最终成功捕获到首次释放操作的完整调用栈,明确问题根源在于VSDebug!treegrid::CTreeGridItemContainerGenerator::Refresh过程中的内存重复释放。虽然因缺少源码无法直接修复,但通过环境隔离(关闭特定程序)避免了问题复现。此次实战再次证明了 procdump、gflags 等工具在诊断复杂内存问题中的巨大价值,也提醒我们即使面对没有源码的“黑盒”组件,系统化的调试方法依然能指引我们找到问题的本质。
缘起
最近,用 vs2022 在调试的时候,切换调用栈,会有很大概率崩溃。一次两次就忍了,不停的崩溃就有点说不过去了。 话不多说,先放张动图看看 vs2022 是怎么崩溃的。

好在我已经设置了 procdump 为事后调试器,每当有进程崩溃的时候,都会在 d:\dumps\ 目录下保存一份转储文件。下图是最近保存的一些转储文件(已经清理过几次了)。

初步分析
老规矩,使用 windbg 打开对应的转储文件,先无脑 !analyze -v 一波,没看到有用的信息。执行 k 命令查看调用栈,如下图:

看到熟悉的 delete 基本就猜到是堆出问题了,或者是堆破坏或者是重复释放。如果能找到 delete 的地址,然后使用地址相关命令(比如, !address 或者 !heap -x addr 命令)应该是可以看到一些信息的。那么该如何找到这个地址呢?
查找关键地址
64 位程序一般是 __stdcall,一般第一个参数是通过 rcx 传递的,但是寄存器的值随着调用其它函数是会改变的,除非保存到栈上过。为了查看传递给 delete 的参数,找到调用它的栈帧。找到栈帧 10 (VSDebug!operator delete+0x9)对应的返回地址(00007fff98f13082),使用 ub 查看对应的反汇编。查看是否有保存 rcx 的操作,没有的话,继续向调用方向找(当然也可以向被调用方向查找是否有保存 rcx 的操作)。直到找到栈帧 12 的返回值地址,
12 0000002899dad690 00007fff99103111 VSDebug!CClassFactory<CRefCount>::Release+0x27
使用 ub 00007fff99103111 L28(为什么 L28,因为好截图)

从图中可知,rcx 来自 rbx,而 rbx 的值被保存到当前栈帧(栈帧 13) rsp+0x40 的位置上过。
执行 dq 0000002899dad6c0 + 0x40 L2 然后执行 !address 0000014185ce4360,然后执行 !heap -x 0x14185ce4360,可以发现这个地址确实是 free 的。如下图:

虽然这里的值不是 100% 靠谱,但是也能在一定程度上证实我们的猜测。其实,解决堆相关问题,可以使用神器 gflags,可以在尽可能早的时候把问题暴露出来。
设置 gflags
打开 gflags.exe,切换到 Image File 页,在 Image:(TAB to refresh) 后面的编辑框内输入 vs 的进程名 devenv.exe,然后按 TAB 键。可以无脑勾选跟堆相关的所有选项。每一项的具体意义可以询问 AI,给出的解释比较靠谱。

当然,也可以通过命令行执行,最终都是操作注册表(下表摘自微软官方文档):
| 设置类型 | 注册表位置 |
|---|---|
| 系统级设置(注册表) | HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\GlobalFlag |
| 特定程序的设置(“映像文件”)适用于计算机的所有用户。 | HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options\ImageFileName\GlobalFlag |
| 特定程序的无提示退出设置(“无提示进程退出”)适用于计算机的所有用户。 | HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\SilentProcessExit\ImageFileName |
| 计算机的所有用户的图像文件的页堆选项 | HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options\ImageFileName\PageHeapFlags |
| 用户模式堆栈跟踪数据库大小 (tracedb) | HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options\ImageFileName\StackTraceDatabaseSizeInMb |
| 为图像文件创建用户模式堆栈跟踪数据库(ust、0x1000) | Windows 将映像文件名添加到 USTEnabled 注册表项的值(HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options\USTEnabled)。 |
| 在可能的情况下大型页加载映像 | HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options\ImageFileName\UseLargePages。 |
| 特殊池(内核特殊池标记) | HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\Memory Management\PoolTag |
| 验证开始/验证结束 | HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\Memory Management\PoolTagOverruns。 “ 验证开始 ”选项将值设置为 0。 “ 验证结束 ”选项将值设置为 1。 |
| 映像文件的调试器 | HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options\ImageFileName\调试器 |
| 对象引用跟踪 | HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\Kernel\ObTraceProcessName ObTracePermanent 和 ObTracePoolTags |
配置好后,再次重现问题,打开转储文件,再次进行分析。
再次分析
执行 k 命令,可以发现在 GetParent 函数内部触发了异常。如下图:

执行 .ecxr 查看异常发生时的上下文,如下图:

第一眼就感觉 rax 的值好特殊啊。看看它的来源,很幸运,在崩溃代码附近(00007fff984e309a)执行 ub 和 u 操作可以找到完整信息,如下图:

红色高亮部分是非常典型的虚函数调用代码。rax 指向虚表,rbx 是对象地址。非常幸运的是在红框上方,把 rbx 保存到了栈 rsp+40h 的地方。其实,没必要,因为当前上下文是异常发生时的上下文(执行了 .ecxr 命令),直接可以获取 rbx 的值。通过查看栈上的值,也佐证了这个观点。

拿到对象地址后,执行 !address 000001a053fd31f0 和 !heap -x 000001a053fd31f0

从图中没看出特别明显的错误(尤其注意 Flags 的值)。我们已经使用 gflags 开启页堆,并且开启了 ust,可以通过 !heap -p -a 0x1a053fd31f0 命令查看此地址相关的调用栈,如下图:

从上图可知,在 VSDebug!treegrid::CTreeGridItemContainerGenerator::Refresh 的时候已经触发了 delete 操作,至此,已经可以确认这是个重复释放的问题了,而且第一次释放时的调用栈也很清楚。
虽然问题已经很明确了,但是我堆 f0f0f0f0f0f0f0f0 这个填充模式充满了好奇。
填充模式
我在查看 windbg 帮助文档关于 !heap 部分时,意外发现了它的意义。原来是开启轻型页堆时,内存被释放后,会用此模式填充。

而且,我还发现了其它几个有意思的填充值,赶紧实战验证下,通过上面的 !heap -x 000001a053fd31f0 命令已经得到了对应的 Heap Entry 的地址是 000001a053fd31a0。使用 dd 000001a053fd31a0 L80 命令看一下这段内存数据的值。

解决问题
虽然查到问题了,但是没有代码,也没法解决。但是在整个折腾的过程中发现只有开着某个特定程序的时候,vs 才会崩溃,关闭这个特定程序后 vs 就不再崩溃了。应该是那个程序使用了 UIA 相关接口,做了一些事情,导致 vs 崩溃。
就不再继续折腾了,有时候不是所有问题都要有一个明确的答案,这也算是解决问题的一种方式吧。
亲自动手
我已经把对应的转储文件上传到百度云盘了,感兴趣的小伙伴可以下载,亲自实战一番。
链接: https://pan.baidu.com/s/15Xa-pCeezeHNwNVwJfGzxA 提取码: puv8
参考资料
https://learn.microsoft.com/zh-cn/windows-hardware/drivers/debugger/gflags-details
总结
procdump是收集转储文件的神兵利器,一定要放到自己的武器库里gflags是解决内存破坏问题的神器,一定要放到自己的武器库里gflags最终结果是设置注册表,必要时可以手动设置x64位程序默认调用约定是__stdcall,第一个参数一般会通过rcx传递