用 WinDbg + SOS 解读崩溃转储 ── 采集之后的实务分析入门

· · WinDbg, SOS, 崩溃转储, .NET, CSharp, 调试, PDB, 故障排查, 技术咨询

之前的文章「Windows 崩溃转储采集入门」整理了用 WER LocalDumps、ProcDump、MiniDumpWriteDump 把转储文件“采集下来”的做法。不过,转储文件光是采集到手边并不会告诉你任何事情。要实际动手解读“是哪个线程”“为什么”崩溃,或者“是什么”一直握着内存不放,才能真正成为排查的素材。

本文接续采集篇,把焦点放在如何用 WinDbg 与 SOS 扩展实际读取已采集的转储文件。内容涵盖安装与符号设置、.NET 应用中不可或缺的 SOS 扩展加载、!clrstack!dumpheap -stat 等代表性命令“该看什么、怎么判断”、原生崩溃的 !analyze -v,以及不用 WinDbg 的 dotnet-dump analyze 该如何区分使用。

1. 先说结论

  • 转储分析的主角是 WinDbg(当前版本,原名 WinDbg Preview)。可用 winget install Microsoft.WinDbg 或通过 Microsoft Store 获取,在 Windows 10 周年更新(1607)之后 / Windows 11 的 x64、ARM64 上均可运行。1
  • 即使符号(PDB)没有正确加载,!clrstack!dumpheap -stat!gcroot 等命令仍会直接基于 CLR 的元数据和堆数据工作。丢失的只是托管代码的源文件名、行号,以及原生堆栈帧的符号名。不过,如果想追到源代码行,那就是另一回事了,因此常见做法是在 _NT_SYMBOL_PATH 中同时放入 Microsoft 的公共符号服务器与自有 PDB 的位置。2
  • 在 .NET(Framework / Core / 5+)应用的转储文件中,要先加载 SOS 扩展才能看到托管侧的信息。仅靠原生的 k(显示堆栈)命令是追不到 C# 代码的。3
  • 代表性的排查思路有 3 种。因异常而崩溃时走 !clrstack!pe内存持续增长时走 !dumpheap -stat!gcroot原生崩溃时从 !analyze -v 入手。
  • 不使用 WinDbg 的选项是 dotnet-dump analyze。大多数 SOS 命令都能直接沿用,但无法处理原生堆栈帧。如果手边要排查的是纯托管、不涉及原生 DLL 或 COM 的场景,这个工具的接入更轻量。4
  • 本文写的是“怎么读”而不是“怎么取”。转储文件的获取方式(WER / ProcDump / MiniDumpWriteDump)请参考采集篇,崩溃时与日志相互对照的设计,请参考「Windows 应用崩溃时保留日志与转储文件的设计」。

2. 安装 WinDbg 并设置符号

2.1 安装

当前版本的 WinDbg 可通过以下任一方式安装。1

winget install Microsoft.WinDbg

通过 Microsoft Store 安装也会得到相同的引擎,命令、扩展、工作流都是通用的。安装后会自动更新(通过 Store 或直接安装时在后台进行,通过 winget 安装时可用 winget upgrade Microsoft.WinDbg),因此不太需要为版本差异带来的行为不同而烦恼。1

2.2 打开转储文件

windbg -z C:\CrashDumps\MyApp\MyApp_20260702_101500.dmp

-z 是指定转储文件并启动的选项。通过 GUI 菜单“File > Open Dump File”也可以实现同样的效果。

2.3 设置符号路径

Windows 调试器查找符号文件(PDB)的位置,是通过 _NT_SYMBOL_PATH 环境变量,或在会话中用 .sympath 命令指定。2 实务中的基本做法是同时放入 Microsoft 的公共符号服务器与自有 PDB 的位置

.symfix C:\Symbols\Microsoft
.sympath+ C:\Symbols\MyApp
.reload
  • .symfix 是设置指向 Microsoft 公共符号服务器(https://msdl.microsoft.com/download/symbols)路径的快捷命令,并附带指定的本地缓存。操作系统标准 DLL 的符号会从这里自动下载。5
  • .sympath+ 会把自有 PDB 的存放位置附加到已有路径之后。自有代码的 PDB 必须自行准备,不会出现在 Microsoft 的符号服务器上。
  • .reload 重新加载,确认模块列表的符号状态。

如果要用环境变量永久设置,格式如下。这种方式适合 CI 或构建服务器上的自动分析。

set _NT_SYMBOL_PATH=srv*C:\Symbols\Microsoft*https://msdl.microsoft.com/download/symbols;C:\Symbols\MyApp

符号是否正确加载,可用 lm(loaded modules)命令列出模块列表,确认目标模块是否显示为 pdb symbols。如果仍显示 deferred,说明符号还没有被解析。

3. 加载 SOS 扩展

.NET 应用的转储文件,仅靠原生的 WinDbg 命令是看不到“托管堆的内容”“C# 的堆栈帧”“异常对象的内容”的。填补这个缺口的正是 SOS(Son of Strike)扩展。堆的排查、堆损坏的检测、运行时内部数据类型的显示,一直到运行中托管代码状态的掌握,都是通过 SOS 命令完成的。3

3.1 因运行时而异

目标应用是 .NET Framework,还是 .NET (Core) / .NET 5+,会影响需要加载的运行时本体与 SOS 的来源。

目标 运行时本体 加载命令
.NET Framework clr.dll .loadby sos clr
.NET Core / .NET 5+ coreclr.dll .loadby sos coreclr

.loadby 是从指定模块(clrcoreclr)所在的目录,查找同一位置的扩展 DLL(sos.dll)并加载的命令。优点是不需要写完整路径,就能准确获取对应转储采集环境的 SOS 版本。6

10.0.18317.1001 及以后版本的 WinDbg、cdb,只要检测到目标进程加载了 coreclr.dll(或 Linux/macOS 上的 libcoreclr.so),就会自动从 Microsoft Extension Gallery 加载对应 .NET 的扩展。6 上面的 .loadby 手动命令,适用于自动加载不生效,或使用较旧版本调试器的情况。

3.2 找不到 SOS 时

在自动加载不生效的环境中,可以用 dotnet-sos 工具在本机安装。

dotnet tool install --global dotnet-sos
dotnet-sos install

安装完成后,也可以在 WinDbg 中如下手动加载(较旧的调试器可能需要这一步)。7

.load %USERPROFILE%\.dotnet\sos\sos.dll

3.3 确认是否加载成功

!sos.help

或者,如果目标是 Core 系列可以尝试 !Threads,Framework 系列可以尝试 !sosstatus,只要不报错并返回一些信息,就说明加载成功。如果命令在这里以类似 Unable to find module 的错误失败,几乎都是符号路径或运行时不一致所致(例如转储采集环境与本机运行时的位数、版本不同)。在进入下一章的命令之前,卡在这一步的情况在实务中并不少见。

4. 读取异常与堆栈 ── !clrstack 与 !pe

这是因未处理异常而崩溃的转储文件的第一步。

!threads

先用 !Threads(在 lldb 环境中别名为 clrthreads)确认托管线程列表,以及各线程的 Exception 字段。8 如果有线程持有异常,就切换到那个线程。

~5s
!clrstack

!CLRStack 只显示托管代码的堆栈跟踪。9 如果想连参数和变量都看到,可以加上 -a(相当于 -l-p 的组合快捷方式)。

!clrstack -a
  • 如果能看到自有代码的方法排列出来,就能直接读出“在哪里”“经过什么调用路径”崩溃的。能显示到源文件名、行号,是因为符号被正确加载了(第 2 章)。CLRStack 是直接从 CLR 的元数据枚举托管帧,因此符号有无并不影响帧是否显示。符号不足时丢失的只是源文件名、行号,帧本身不会因此被省略。9
  • 如果一个自有代码的帧都看不到,先别怀疑符号不足,应该怀疑的是:选中的线程其实是没有异常的另一个线程(选错了线程)、只有原生侧崩溃而托管帧本来就不存在,或者转储文件的类型(如 Mini)没有包含足够的堆栈信息。

接下来看异常对象本身。

!pe

!PrintException(简称 !pe)在省略地址时,会显示当前线程最后一次抛出的异常。可以获取类型名、消息、内部异常(用 -nested 显示),一直到堆栈跟踪字符串。10System.NullReferenceException 这种光看类型名看不出什么信息的异常,就需要与 !clrstack -a 看到的局部变量值互相对照。

5. 追踪堆与泄漏 ── !dumpheap -stat 与 !gcroot

在“内存缓慢增长,几小时到几天后才崩溃”这类排查中是核心命令。要先分辨是等待 GC 还是真正的泄漏,前置的判断方法已经在「用 .NET 区分 GC 等待与内存泄漏」中详细写过。本文接续其后,属于读一份转储文件并深入到“是什么握着”的部分。

!dumpheap -stat

-stat 选项只显示托管堆的统计摘要。会按类型的数量与总大小,大致从多到少排列,因此可以先锁定“数量压倒性偏多的类型”。11 实务中常见的有以下 2 种情况:

  • 业务类本身在增加(例如 MyApp.Models.Customer 有几十万个)── 说明某处一直存在强引用握着它
  • 只有 System.String 或数组异常多── 大多是排名靠前的业务类内部数据所致,先怀疑业务类,往往比逐个查看实例更快

锁定目标后,获取单个实例的地址。

!dumpheap -type MyApp.Models.Customer

然后排查该对象为什么没有被 GC 回收,一直残留着。

!gcroot 000001a2b3c4d5e0

!GCRoot 会搜索整个托管堆与句柄表,找出所有能到达指定对象的根(堆栈上的变量、静态字段、GC 句柄等)。12 如果输出中看到用于缓存的静态字段或事件处理程序的订阅,那里就是取消订阅遗漏的嫌疑对象。下面这种“放进缓存后就不释放”的代码是典型例子:

public static class CustomerCache
{
    // 没有清理途径,只会不断增长的静态字典
    private static readonly Dictionary<int, Customer> _cache = new();

    public static void Add(Customer c) => _cache[c.Id] = c;
}

如果 !gcroot 的输出中出现像 CustomerCache 这样的静态容器,就是考虑加上有效期、上限数量,或改用 WeakReference 的依据。11

6. 原生崩溃的自动分析 ── !analyze -v

涉及 C++ DLL、COM、厂商 SDK 的原生崩溃(如访问违规),一律先从这个命令入手。

!analyze -v

!analyze 是进行崩溃、异常自动分析的扩展命令,加上 -v 会显示详细信息。13 输出中特别需要关注的有以下 3 项:

  • EXCEPTION_CODE / BUGCHECK_STR: 是哪种异常(访问违规、堆栈溢出等)
  • FAULTING_IP / FOLLOWUP_IP: 实际崩溃的指令地址,以及对应的模块、函数名
  • MODULE_NAME / IMAGE_NAME: 崩溃处是自有模块,还是第三方 DLL

如果崩溃发生在自有模块之外的厂商 DLL 中,要继续往下追就需要厂商的 PDB(通常拿不到)。实务中比较现实的做法,是回溯到“调用方(自有代码最后传入的参数)”,怀疑传入的值是否有问题。请用 !analyze -vSTACK_TEXT 字段确认自有代码调用了什么之后才崩溃。

!analyze 不仅能用于异常导致的转储文件。怀疑是挂起(hang)时,选定目标线程后执行以下命令,会分析线程之间的阻塞关系。

!analyze -hang

7. 不用 WinDbg 的选项 ── dotnet-dump analyze

如果只想排查 .NET Core / .NET 5+ 的托管代码(不涉及原生 DLL 或 COM),比 WinDbg 更轻量的 dotnet-dump 也是一个选项。

dotnet tool install --global dotnet-dump
dotnet-dump analyze C:\CrashDumps\MyApp\MyApp_20260702_101500.dmp

analyze 子命令会打开一个预装了 SOS 的交互式会话,clrstackdumpheapgcroot 等本文介绍过的大多数命令,都能直接使用而不需要 ! 前缀。4

区分使用的大致标准如下:

观点 WinDbg + SOS dotnet-dump analyze
原生堆栈帧 可见 不可见(仅托管侧)4
!analyze -v 的原生自动分析 可用 不可用
Linux 的转储文件 可在 Windows 上用 WinDbg 分析(x64 转储用 x64 版、Arm64 转储用 x64 版、x86 转储用 x86 版)14 支持(使用同一平台位数的工具)14
macOS 的转储文件 不支持(WinDbg 的 Linux 转储支持不包含 macOS) 支持(.NET 5 及以后)4
接入的轻量程度 安装程序或 winget dotnet global tool 一条命令
纳入跨平台 CI 较费工 较容易

“可能涉及 COM、P/Invoke、原生 DLL”就用 WinDbg,“纯托管代码的内存泄漏排查,且想在 CI 或多平台上运行”就用 dotnet-dump analyze,是实务中的分界线。两者共用 SOS 的命令体系,因此在一边学到的命令,在另一边几乎都能直接沿用。如果要处理在 macOS 上采集的转储文件,WinDbg 不在选项之内,这一点要注意,只能选 dotnet-dump(或 LLDB)。

8. 符号读不到就什么都做不了

到目前为止的部分步骤,即使符号(PDB)没有正确加载,也还算能正常工作。如第 4 章所写,!clrstack!dumpheap -stat!gcroot 是直接读取 CLR 的元数据与堆数据,因此即使没有 PDB,帧与类型信息本身依然会显示。PDB 缺失时丢失的是托管代码的源文件名、行号,以及原生帧、原生模块的符号名(只显示地址而不是函数名)。如果手边只有转储文件和可执行文件,在“先想弄清楚发生了什么”的场景下,不必卡在寻找 PDB 上,直接从 !threads!clrstack 入手也没问题。不过,如果想追到源代码行以确定问题所在,那就是另一回事了。第 2 章在 .sympath+ 中加上了自有 PDB 的路径,但实务中光是“不知道对应已发布 EXE/DLL 的 PDB 在哪里”,就足以让排查卡在无法确定源代码行的阶段,这种情况比采集篇中写到的还要频繁。

PDB 本身包含什么、不包含什么、Portable PDB,以及 Source Link(把源代码管理的元数据嵌入程序集,让调试器能直接获取对应构建时间点提交的源代码的机制),都已在「PDB 是什么」这篇文章中整理过。15 如果要把转储分析纳入持续性的运维流程,按构建版本保存 PDB、并启用 Source Link,这件事的重要程度和采集配置本身不相上下。这一步偷懒的话,!clrstack 的输出就完全不会出现源代码行,只能靠地址和类型名摸索。

9. 实例 ── 句柄泄漏排查中的转储分析

之前写过的「工业相机长期运行崩溃排查 - 句柄泄漏篇」,是长时间运行后突然崩溃的工业相机控制应用的排查案例,元凶不是内存泄漏而是句柄泄漏。这类排查中转储文件能发挥作用,是在以下组合成立的时候:

  1. 先用 !dumpheap -stat 确认托管堆一侧正常(各类型的数量、大小没有持续增长)
  2. 如果进程的句柄数量仍在持续增长,就能区分出泄漏的不是托管对象,而是 操作系统句柄(文件、事件、相机 SDK 内部分配的句柄等)
  3. 如果还残留着持有 SafeHandle 的托管包装对象,就用 !gcroot 追踪该包装对象的 GC 根,找出“本应释放却仍有引用残留”的位置

也就是说,!dumpheap -stat 是判断“是不是托管堆在增长”的分岔点,一旦确认不是,排查的重心就会转向像 Application Verifier 这类原生边界异常检测工具。这套异常路径测试基础设施的搭建方式,在「用 Application Verifier 搭建 Windows 异常路径测试基础设施」中有介绍。转储分析扮演的是“掌握当前发生的状态”,Application Verifier 扮演的是“提前重现异常”,两者并用是长时间运行系统故障排查的常规做法。

10. 总结

崩溃转储文件“采集”相对容易,“读懂”则需要更多时间才能熟练,但套路并不算多。

  1. 安装 WinDbg,并在符号路径中同时放入 Microsoft 的公共符号服务器与自有 PDB(第 2 章)
  2. .NET 应用要加载 SOS 扩展(.loadby sos clr / .loadby sos coreclr,或自动加载。第 3 章)
  3. 因异常而崩溃就走 !clrstack!pe,内存增长就走 !dumpheap -stat!gcroot,原生崩溃就从 !analyze -v 入手(第 4~6 章)
  4. 如果用途纯粹是托管侧的排查,也可以考虑轻量的 dotnet-dump analyze(第 7 章)

而支撑这一切的基础,就是 PDB 与符号的管理。在建立转储采集配置的同时,一并定好按构建版本保存 PDB、启用 Source Link,实际发生故障时的排查时间会有很大差异。如果自身难以完成分析,或抽不出时间,只要把转储文件和日志一起发过来,我们也可以协助分析。

相关文章

相关咨询领域

合同会社小村软件提供结合崩溃转储文件与日志的故障原因排查、仅在长期运行后才出现的故障定位,以及转储文件、PDB 保存与分析体制本身的设计咨询。

参考链接

  1. Microsoft Learn, Install the Windows debugger. 说明通过 winget / Microsoft Store 安装 WinDbg 的方式、支持的操作系统(Windows 10 1607 及以后、Windows 11)与架构(x64、ARM64),以及自动更新的行为。  2 3

  2. Microsoft Learn, Symbol path for Windows debuggers. 说明通过 _NT_SYMBOL_PATH 环境变量设置符号路径的方式,以及 .symfix 命令可以设置指向公共符号服务器的默认路径。  2

  3. Microsoft Learn, SOS debugging extension. 说明 SOS 扩展可用于托管堆信息的收集、堆损坏的检测、运行时内部数据类型的显示,以及在 WinDbg 上的语法为 ![command]。  2

  4. Microsoft Learn, Dump collection and analysis utility (dotnet-dump). 说明 dotnet-dump analyze 提供可直接使用 SOS 命令的交互式会话,但因不是原生调试器而无法显示原生堆栈帧,以及 macOS 支持从 .NET 5 开始。  2 3 4

  5. Microsoft Learn, Microsoft public symbol server. 说明 srv*DownstreamStore*https://msdl.microsoft.com/download/symbols 格式的符号路径语法,以及 .symfix 可以附带本地缓存进行设置。 

  6. Microsoft Learn, Debugging Managed Code Using the Windows Debugger. 说明 .NET Framework 的运行时为 clr.dll、.NET Core/.NET 5+ 的运行时为 coreclr.dll.loadby 可从模块附近加载扩展,以及 WinDbg 10.0.18317.1001 及以后版本的自动加载机制。  2

  7. Microsoft Learn, SOS installer (dotnet-sos). 说明通过 dotnet-sos install 在本机安装 SOS 扩展,以及在较旧版本调试器中手动加载的命令。 

  8. Microsoft Learn, SOS debugging extension - Commands. 说明 Threads(在 lldb 环境中别名为 clrthreads)命令会列出各线程的 ID、域、最后抛出的异常等信息。 

  9. Microsoft Learn, SOS debugging extension - Commands. 说明 CLRStack 命令只显示托管代码的堆栈跟踪,-a 选项可同时显示局部变量与参数,以及符号(SYMOPT_LOAD_LINES)只影响源文件名、行号能否显示,与帧本身是否显示无关。  2

  10. Microsoft Learn, SOS debugging extension - Commands. 说明 PrintException(pe)命令在省略地址时,会显示当前线程最后一次抛出的异常,并可用 -nested 显示嵌套异常。 

  11. Microsoft Learn, Debug a memory leak in .NET 以及 Dump collection and analysis utility (dotnet-dump) - Analyze memory leaks and allocations. 说明 dumpheap -stat 按类型显示数量与总大小的统计,以及以此为起点推进排查的方法。  2

  12. Microsoft Learn, SOS debugging extension - Commands. 说明 GCRoot 命令会搜索整个托管堆与句柄表,找出通往指定对象的引用(根)。 

  13. Microsoft Learn, Using the !analyze Extension 以及 !analyze (WinDbg). 说明 !analyze -v 的崩溃、异常自动分析,输出中 FAULTING_IPMODULE_NAME 等字段的含义,以及用于挂起排查的 !analyze -hang。 

  14. Microsoft Learn, Debug Linux dumps. 说明可以在 Windows 上用 WinDbg 或 dotnet-dump 分析 Linux 的转储文件,并且需要按采集环境(x64/Arm64/x86)的位数使用对应版本的工具。  2

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

常见问题

汇总了咨询这一主题时常见的问题。

.NET 应用的崩溃转储该从哪个命令开始读?
排查的思路大致分 3 种。如果是因未处理异常而崩溃,先用 !threads 找出持有异常的线程,再用 !clrstack 显示托管堆栈,并用 !pe 确认异常对象的类型、消息与内部异常。如果是内存持续增长的排查,先用 !dumpheap -stat 找出数量偏多的类型,再用 !gcroot 找出握住该对象的根(静态字段或事件处理程序的订阅等)。如果是原生崩溃,则从 !analyze -v 的自动分析入手。
在 WinDbg 中要如何加载 SOS 扩展?
.NET Framework 用 .loadby sos clr,.NET Core/.NET 5+ 用 .loadby sos coreclr。10.0.18317.1001 及以后版本的 WinDbg,只要检测到目标进程加载了 coreclr.dll,就会自动加载 SOS。如果自动加载不生效,可以用 dotnet-sos 工具在本机安装后手动 .load。是否加载成功,可以用 !sos.help 或 !Threads 执行后不报错并返回信息来确认。
没有 PDB(符号)也能做转储分析吗?
在一定程度上可以。!clrstack、!dumpheap -stat、!gcroot 是直接读取 CLR 的元数据与堆数据,因此即使没有 PDB,堆栈帧与类型信息依然会显示。丢失的是托管代码的源文件名、行号,以及原生堆栈帧的符号名。如果想追到源代码行以确定问题所在,就需要在符号路径中同时放入 Microsoft 的公共符号服务器与自有 PDB 的位置。按构建版本保存 PDB,并启用 Source Link,在运维上非常重要。
dotnet-dump analyze 与 WinDbg 该如何区分使用?
如果可能涉及 COM、P/Invoke 或原生 DLL,用 WinDbg;如果是纯托管代码的排查,dotnet-dump analyze 是基本方针。dotnet-dump 只需一条 dotnet global tool 命令即可安装,clrstack、dumpheap 等大多数 SOS 命令都能直接沿用,但无法处理原生堆栈帧,也无法使用 !analyze -v。在 macOS 上采集的转储无法用 WinDbg 分析,因此只能选 dotnet-dump 或 LLDB。两者共用 SOS 的命令体系,因此学会的命令几乎可以互通使用。

作者简介

本文作者的个人简介页面。

Go Komura

小村软件有限公司 代表

以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表