WinDbg 入门:用 C++ 自动生成 Dump,并学会分析崩溃现场

最近一直在琢磨 WinDbg 调试器,作为 Windows 平台 C/C++ 开发的“排错神器”,它总能在程序崩溃、卡死、内存泄漏时帮上大忙。对于刚接触它的新手来说,不用一上来就死记硬背一堆命令,先理清这条核心调试主线,学习起来会轻松很多:

程序崩溃
  ↓
生成 Dump 文件
  ↓
准备对应 PDB
  ↓
用 WinDbg 打开 Dump
  ↓
查看异常类型
  ↓
查看调用栈
  ↓
定位到具体函数和代码位置

今天这篇博客,就用一个实操性极强的 C++ 练习程序,带大家完整走一遍流程——用 SetUnhandledExceptionFilter + MiniDumpWriteDump自动生成 Dump 文件,再用 WinDbg 一步步分析崩溃现场。这个练习程序特意做了三个常见崩溃场景:空指针写入、整数除零、多层调用后崩溃,而且会把 Dump 自动生成到 exe 旁边的 dumps 目录,新手也能轻松找到。


一、先搞懂:WinDbg 到底是什么?

WinDbg 是微软官方推出的 Windows 平台调试工具,专门用来解决 C/C++ 程序的各种“疑难杂症”,比如:

程序崩溃
程序异常
程序卡死
线程死锁
CPU 占用高
内存泄漏
访问冲突
堆破坏

如果做 Windows 客户端开发,WinDbg 基本是必备工具。大家可以这样类比理解,降低记忆成本:

Windows 平台:WinDbg
Linux 平台:GDB

这里要特别说一句,实际项目中,WinDbg 最常用的不是“边写代码边单步调试”,而是“事后排错”,流程大概是这样:

客户程序崩溃
  ↓
生成 .dmp 文件
  ↓
开发拿到 Dump
  ↓
用 WinDbg 打开
  ↓
分析异常类型、调用栈、模块、线程

这也是我们今天重点学习的场景——通过 Dump 文件,还原崩溃现场。


二、WinDbg 的两种核心分析方式

WinDbg 分析问题主要分两种,新手建议先从第一种入手,更容易建立信心:

1. 静态分析 Dump 文件(重点!)
2. 动态调试目标进程

1. 静态分析 Dump 文件(实际项目最常用)

静态分析的核心是“事后复盘”,流程很简单:

程序运行
  ↓
程序发生异常或崩溃
  ↓
生成 Dump 文件
  ↓
开发人员拿到 Dump 文件
  ↓
用 WinDbg 打开 Dump 分析

可以把 Dump 文件理解成「程序崩溃那一刻的现场快照」——程序虽然已经退出,但 Dump 里保存了当时的异常信息、线程信息、调用栈、模块信息、寄存器状态,甚至部分内存数据,足够我们还原“事故真相”。

这种方式特别适合这些场景:

客户现场崩溃(无法远程调试)
线上环境异常
偶发问题(很难复现)
程序已经退出但保留了 Dump 的情况

2. 动态调试目标进程

动态调试就是“实时监控”,把 WinDbg 附加到正在运行的程序上,流程如下:

程序正在运行
  ↓
WinDbg Attach 到目标进程
  ↓
程序继续运行
  ↓
一旦异常,WinDbg 中断
  ↓
开发人员查看调用栈、线程、变量、内存

用一句通俗的话区分两种方式:

静态分析:事后看事故现场照片
动态调试:现场盯着程序出问题

新手建议先吃透静态分析,再学动态调试,循序渐进更高效。


三、Dump 文件:崩溃现场的“快照”

Dump 文件也叫转储文件,常见扩展名是\.dmp,它保存的是程序运行某一刻的完整状态,里面主要包含这些关键信息:

异常信息(为什么崩溃)
线程信息(哪些线程在运行)
调用栈(程序崩溃前走了哪些步骤)
模块列表(加载了哪些 exe/dll)
寄存器状态(CPU 当时的状态)
部分内存数据(关键变量的值)
进程运行状态

对 WinDbg 静态分析来说,Dump 文件就是最核心的“输入材料”——没有 Dump,就没法复盘崩溃现场。

MiniDump 和 Full Dump 的区别(新手必看)

Dump 文件主要分两类,新手不用纠结,先掌握 MiniDump 就够了:

1. MiniDump(小型转储)

体积很小,通常只有几 MB,主要保存最核心的崩溃信息:

异常类型、异常地址
线程调用栈
模块信息、寄存器信息
少量必要内存

优点很明显:文件小、方便上传、适合自动收集,大多数崩溃问题用它就够了;缺点是信息不完整,复杂的内存问题可能分析不了。

2. Full Dump(完整转储)

包含的信息最完整,但文件也很大——可能比程序本身大好几倍。很多新手会疑惑:“我的 exe 才几十 KB,Dump 怎么有几十 MB?”

其实原因很简单:

exe 只是程序文件本体
程序运行后会加载 DLL
程序运行后会分配堆内存
程序运行后会创建线程
Dump 保存的是运行时现场(不是程序本身)

所以 Dump 文件大是正常的,练习时我们用 MiniDump(但会配置更多信息,方便观察)。


四、PDB 文件:分析 Dump 的“钥匙”

很多新手打开 Dump 后,看到的都是一堆乱码一样的地址,找不到函数名,问题就出在 PDB 文件上。

PDB 全称是 Program Database File,中文叫程序数据库文件(也叫调试符号文件),它的作用就像“地址说明书”,保存了这些关键信息:

函数名(比如 CauseNullPointerWrite)
变量信息(比如 value 是 null)
源码文件路径、源码行号
地址和函数的对应关系

没有 PDB 时,WinDbg 只能显示一堆地址:

00007ff6`12345678
00007ff6`1234abcd

有了正确的 PDB,WinDbg 就能显示清晰的函数名,甚至定位到源码行:

DumpPractice!CauseNullPointerWrite
DumpPractice!CauseStackCrashLevel3
DumpPractice!RunScenario
DumpPractice!main

这里有个关键注意点:PDB 必须和 EXE/DLL 是同一次编译的产物,不能拿其他版本的 PDB 凑数,也不要随便改 PDB 文件名,否则会加载失败。

总结一下这几个文件的关系:

Dump:崩溃现场
EXE/DLL:程序本体
PDB:地址说明书
WinDbg:把它们组合起来分析问题

五、实操环节:用 C++ 自动生成 Dump

理论讲完,进入最核心的实操——写一个 C++ 程序,自动生成 Dump 文件。这个程序叫 DumpPractice,没有复杂的业务逻辑,纯粹为了帮大家练习:如何让程序崩溃、如何生成 Dump、如何用 WinDbg 分析。

程序核心用了两个 API:SetUnhandledExceptionFilter(捕获未处理异常)和 MiniDumpWriteDump(生成 Dump 文件),这也是实际项目中最常用的 Dump 生成方式。

完整代码(可直接复制编译)

#include <windows.h>
#include <dbghelp.h>

#include <cstdio>
#include <cstring>
#include <cwchar>
#include <string>

#pragma comment(lib, "Dbghelp.lib")

namespace {

std::wstring g_lastDumpPath;

// 获取 DumpPractice.exe 所在目录,避免 Dump 生成到找不到的地方
std::wstring GetExeDirectory() {
    wchar_t exePath[MAX_PATH];
    ZeroMemory(exePath, sizeof(exePath));

    DWORD length = GetModuleFileNameW(NULL, exePath, MAX_PATH);
    if (length == 0 || length >= MAX_PATH) {
        return L".";
    }

    wchar_t* lastSlash = wcsrchr(exePath, L'\\');
    if (lastSlash == NULL) {
        return L".";
    }

    *lastSlash = L'\0';
    return exePath;
}

// 生成 Dump 路径,包含时间和 PID,避免多次运行覆盖
std::wstring BuildDumpPath() {
    const std::wstring exeDirectory = GetExeDirectory();
    const std::wstring dumpDirectory = exeDirectory + L"\\dumps";

    CreateDirectoryW(dumpDirectory.c_str(), NULL);

    SYSTEMTIME now;
    ZeroMemory(&now, sizeof(now));
    GetLocalTime(&now);

    wchar_t fileName[MAX_PATH];
    ZeroMemory(fileName, sizeof(fileName));
    swprintf_s(fileName,
               L"%ls\\DumpPractice_%04u%02u%02u_%02u%02u%02u_pid%lu.dmp",
               dumpDirectory.c_str(),
               now.wYear,
               now.wMonth,
               now.wDay,
               now.wHour,
               now.wMinute,
               now.wSecond,
               GetCurrentProcessId());
    return fileName;
}

// 核心函数:捕获异常后生成 Dump
LONG WriteDumpForException(EXCEPTION_POINTERS* exceptionInfo) {
    g_lastDumpPath = BuildDumpPath();

    // 创建 .dmp 文件
    HANDLE dumpFile = CreateFileW(g_lastDumpPath.c_str(),
                                  GENERIC_WRITE,
                                  0,
                                  NULL,
                                  CREATE_ALWAYS,
                                  FILE_ATTRIBUTE_NORMAL,
                                  NULL);
    if (dumpFile == INVALID_HANDLE_VALUE) {
        std::printf("CreateFileW failed. GetLastError=%lu\n", GetLastError());
        return EXCEPTION_EXECUTE_HANDLER;
    }

    // 配置异常信息(告诉系统哪个线程崩溃、异常现场在哪里)
    MINIDUMP_EXCEPTION_INFORMATION exceptionParam;
    ZeroMemory(&exceptionParam, sizeof(exceptionParam));
    exceptionParam.ThreadId = GetCurrentThreadId();
    exceptionParam.ExceptionPointers = exceptionInfo;
    exceptionParam.ClientPointers = FALSE;

    // 配置 Dump 类型,包含更多信息,方便新手观察
    const MINIDUMP_TYPE dumpType = static_cast<MINIDUMP_TYPE>(
        MiniDumpWithFullMemory |
        MiniDumpWithHandleData |
        MiniDumpWithThreadInfo |
        MiniDumpWithUnloadedModules);

    const BOOL ok = MiniDumpWriteDump(GetCurrentProcess(),
                                      GetCurrentProcessId(),
                                      dumpFile,
                                      dumpType,
                                      &exceptionParam,
                                      NULL,
                                      NULL);

    CloseHandle(dumpFile);

    if (ok) {
        std::wprintf(L"\nDump written: %ls\n", g_lastDumpPath.c_str());
        std::printf("Open it with WinDbg, then run: !analyze -v; .ecxr; k; dv\n");
    } else {
        std::printf("MiniDumpWriteDump failed. GetLastError=%lu\n", GetLastError());
    }

    return EXCEPTION_EXECUTE_HANDLER;
}

// 注册顶层未处理异常过滤函数
LONG WINAPI WriteDumpOnUnhandledException(EXCEPTION_POINTERS* exceptionInfo) {
    return WriteDumpForException(exceptionInfo);
}

// 场景1:空指针写入(访问冲突)
__declspec(noinline) void CauseNullPointerWrite() {
    std::printf("About to write through a null pointer.\n");
    volatile int* value = NULL;
    *value = 0x1234;
}

// 场景2:整数除零(另一种异常类型)
__declspec(noinline) int CauseDivideByZero() {
    std::printf("About to divide by zero.\n");
    volatile int numerator = 100;
    volatile int denominator = 0;
    return numerator / denominator;
}

// 场景3:多层调用后崩溃(练习看调用栈)
__declspec(noinline) void CauseStackCrashLevel3(int marker) {
    std::printf("Stack marker = %d. The next frame crashes.\n", marker);
    CauseNullPointerWrite();
}

__declspec(noinline) void CauseStackCrashLevel2(int marker) {
    CauseStackCrashLevel3(marker + 1);
}

__declspec(noinline) void CauseStackCrashLevel1() {
    CauseStackCrashLevel2(41);
}

__declspec(noinline) void CauseStackCrash() {
    std::printf("About to crash through a small call chain.\n");
    CauseStackCrashLevel1();
}

// 打印使用说明
void PrintUsage(const char* exeName) {
    std::printf("Usage:\n");
    std::printf("  %s null     - write through a null pointer\n", exeName);
    std::printf("  %s divzero  - trigger integer divide by zero\n", exeName);
    std::printf("  %s stack    - crash after several stack frames\n", exeName);
    std::printf("\nDouble-click or run without arguments defaults to: null\n");
}

// 根据命令行参数选择崩溃场景
void RunScenario(const char* scenario) {
    if (std::strcmp(scenario, "null") == 0) {
        CauseNullPointerWrite();
    } else if (std::strcmp(scenario, "divzero") == 0) {
        std::printf("Result = %d\n", CauseDivideByZero());
    } else {
        CauseStackCrash();
    }
}

// 单独封装 __try/__except,避免编译报错
int RunScenarioAndWriteDump(const char* scenario) {
    __try {
        RunScenario(scenario);
    } __except (WriteDumpForException(GetExceptionInformation())) {
        return 1;
    }

    return 0;
}

}  // namespace

int main(int argc, char* argv[]) {
    // 注册异常过滤函数,捕获未处理异常
    SetUnhandledExceptionFilter(WriteDumpOnUnhandledException);

    std::printf("DumpPractice PID: %lu\n", GetCurrentProcessId());
    std::wprintf(L"Dump directory: %ls\\dumps\n\n", GetExeDirectory().c_str());

    // 默认场景:null(双击 exe 直接运行)
    const char* scenario = argc > 1 ? argv[1] : "null";

    if (argc <= 1) {
        std::printf("No scenario specified; defaulting to: null\n\n");
        PrintUsage(argv[0]);
    }

    // 校验命令行参数
    if (std::strcmp(scenario, "null") != 0 &&
        std::strcmp(scenario, "divzero") != 0 &&
        std::strcmp(scenario, "stack") != 0) {
        std::printf("Unknown scenario: %s\n\n", scenario);
        PrintUsage(argv[0]);
        return 2;
    }

    return RunScenarioAndWriteDump(scenario);
}

代码核心逻辑拆解(新手必看)

这个程序启动后,主要做了 6 件事,链路很清晰:

main
  ↓
SetUnhandledExceptionFilter(注册异常回调)
  ↓
RunScenarioAndWriteDump(执行场景并捕获异常)
  ↓
RunScenario(选择崩溃场景)
  ↓
触发崩溃(3种场景之一)
  ↓
WriteDumpForException(生成 Dump)
  ↓
MiniDumpWriteDump(核心 API,写入 Dump 文件)

这里有几个新手容易忽略的细节,特意标注出来:

1. 为什么要获取 exe 所在目录?

很多新手双击 exe 运行时,当前工作目录可能不是 exe 所在目录,如果直接生成相对路径的 Dump,很容易找不到文件。代码里通过 GetExeDirectory\(\) 函数,把 Dump 固定生成到 exe 所在目录\\dumps,新手能快速找到。

2. 为什么 Dump 文件名要加时间和 PID?

生成的 Dump 文件名类似 DumpPractice\_20260515\_153012\_pid12345\.dmp,这样做有两个好处:一是多次运行不会覆盖之前的 Dump,二是能通过时间和 PID 区分不同的崩溃记录,练习和实际项目中都很实用。

3. 为什么用 MiniDumpWithFullMemory?

虽然函数叫 MiniDumpWriteDump,但代码里配置了 MiniDumpWithFullMemory,这会让 Dump 文件大一点,但包含的信息更多(比如局部变量、堆内存、线程信息),对新手练习来说,信息越全,越容易观察和理解。

4. 为什么同时用 SetUnhandledExceptionFilter 和 __try/__except?

这是为了保证练习时能稳定生成 Dump:SetUnhandledExceptionFilter 是实际项目中常用的“顶层异常回调”方式,而 \_\_try/\_\_except 是 Windows SEH 结构化异常处理,能避免某些环境下异常捕获失败,确保新手练习时每次都能生成 Dump。


六、三个崩溃场景(新手重点练习)

程序支持 3 种崩溃场景,每种场景对应不同的异常类型,建议都练一遍,加深对 WinDbg 分析的理解。

1. null:空指针写入(默认场景)

运行方式:双击 exe,或命令行输入 DumpPractice\.exe null,对应函数 CauseNullPointerWrite\(\)

崩溃类型:Access Violation(访问冲突),WinDbg 中异常代码为 c0000005,原因是程序试图向空指针(NULL)写入数据。

2. divzero:整数除零

运行方式:命令行输入 DumpPractice\.exe divzero,对应函数 CauseDivideByZero\(\)

崩溃类型:Integer Divide By Zero(整数除零),WinDbg 中异常代码为 c0000094,原因是分母为 0。

3. stack:多层调用后崩溃

运行方式:命令行输入 DumpPractice\.exe stack,对应调用链:

CauseStackCrash → CauseStackCrashLevel1 → CauseStackCrashLevel2 → CauseStackCrashLevel3 → CauseNullPointerWrite

这个场景最适合练习看调用栈——不是直接在 main 里崩溃,而是经过多层调用,能帮你熟练掌握“通过调用栈还原崩溃路径”的核心能力。


七、编译 + 运行 + 生成 Dump(一步一步来)

新手不用怕,跟着步骤来,就能顺利生成 Dump:

1. 编译程序

用 Visual Studio 创建 C++ 控制台项目,项目名建议叫 DumpPractice,把上面的代码复制到 main\.cpp,然后配置生成 PDB(关键步骤):

项目属性 → 配置属性 → 链接器 → 调试 → 生成调试信息:是
(新版本 VS 可检查:C/C++ → 常规 → 调试信息格式 → 程序数据库 /Zi)

编译后,会生成两个关键文件:DumpPractice\.exe(程序本体)和 DumpPractice\.pdb(符号文件),缺一不可。

2. 运行生成 Dump

有 4 种运行方式,新手推荐前两种:

方式1:双击 DumpPractice.exe(默认 null 场景)
方式2:命令行输入 DumpPractice.exe null(空指针写入)
方式3:命令行输入 DumpPractice.exe divzero(整数除零)
方式4:命令行输入 DumpPractice.exe stack(多层调用崩溃)

运行后,会在 exe 旁边生成 dumps 文件夹,里面就是生成的 \.dmp 文件。


八、WinDbg 分析 Dump(核心实操)

终于到了 WinDbg 的核心环节!打开 WinDbg Preview 或 WinDbg,跟着步骤来,新手也能轻松分析。

步骤1:打开 Dump 文件

打开 WinDbg,选择 File → Open dump file,找到 dumps 文件夹里的 \.dmp 文件,打开即可。

步骤2:设置 PDB 路径(关键!)

假设你的程序编译后在D:\\Study\\DumpPractice\\x64\\Debug(64位 Debug 版本),里面有 exe 和 pdb 文件,在 WinDbg 命令行输入:

.sympath+ D:\Study\DumpPractice\x64\Debug
.reload /f

如果是 32 位 Debug 版本,路径改成 D:\\Study\\DumpPractice\\Debug 即可。这一步是为了让 WinDbg 找到 PDB 文件,否则看不到函数名。

步骤3:执行核心命令(新手必记)

打开 Dump 后,按顺序执行以下命令,就能还原崩溃现场,新手建议记熟这 6 条命令:

1. !analyze -v(自动分析异常)

第一条必执行的命令,WinDbg 会自动分析异常类型、崩溃地址、调用栈等信息。重点看 EXCEPTION\_CODE(异常代码)、FAULTING\_IP(崩溃地址)、STACK\_TEXT(调用栈)。

比如 null 场景,会看到 EXCEPTION\_CODE: c0000005(访问冲突);divzero 场景,会看到 EXCEPTION\_CODE: c0000094(整数除零)。

2. .ecxr(切换到异常上下文)

新手最容易犯的错误:一打开 Dump 就看调用栈,结果看不到正确的崩溃现场。执行\.ecxr 后,WinDbg 会切换到真正发生异常的线程和寄存器上下文,后续查看调用栈才准确。

3. kv(查看详细调用栈)

执行\.ecxr 后,再执行 kv,就能看到完整的调用栈。比如 stack 场景,会看到从 mainCauseNullPointerWrite 的完整调用链路,能清晰看到程序是怎么一步步崩溃的。

4. dv(查看局部变量)

用来查看当前栈帧中的局部变量。比如 null 场景,在 CauseNullPointerWrite 栈帧中,执行 dv 能看到 value = 0x00000000,明确知道是空指针导致的崩溃。

5. .exr -1(查看异常详情)

查看最近一次异常的详细记录,比如访问冲突的参数(是读内存失败还是写内存失败)、访问的地址等,能进一步定位崩溃原因。

6. lmvm DumpPractice(查看模块信息)

查看当前程序模块的详细信息,重点看 符号状态PDB 路径,确认 PDB 已经成功加载(会显示 Symbols loaded)。

新手练习流程(建议收藏)

按这个顺序练习,能快速掌握核心操作:

1. 编译程序,确认生成 exe 和 pdb
2. 运行 DumpPractice.exe null(生成 Dump)
3. 用 WinDbg 打开 .dmp 文件
4. 输入 .sympath+ 配置 PDB 路径
5. 输入 .reload /f 重新加载符号
6. 输入 !analyze -v 自动分析异常
7. 输入 .ecxr 切换异常上下文
8. 输入 kv 查看详细调用栈
9. 输入 dv 查看局部变量
10. 输入 .exr -1 查看异常详情
11. 输入 lmvm DumpPractice 确认 PDB 加载成功

练完 null 场景,再依次练 divzero 和 stack 场景,重点观察不同异常的代码和调用栈差异。


九、常用命令速查(新手必备)

整理了新手最常用的命令,放在这里,方便随时查看:

命令作用
!analyze -v自动分析异常,获取核心崩溃信息
.ecxr切换到异常发生时的上下文
.exr -1查看最近一次异常记录
k查看基本调用栈
kv查看详细调用栈
dv查看当前栈帧局部变量
lm查看所有加载的模块
lmvm DumpPractice查看 DumpPractice 模块详细信息(含 PDB 状态)
.sympath查看当前符号路径
.sympath+ 路径添加符号路径(加载 PDB)
.reload /f强制重新加载符号

十、新手最容易踩的坑(避坑指南)

1. 找不到 Dump 文件

解决方案:Dump 固定生成在 exe 所在目录\\dumps,去 exe 旁边找 dumps 文件夹即可,不要去其他目录找。

2. WinDbg 看不到函数名,只有地址

解决方案:PDB 加载失败了。检查 3 点:PDB 路径是否正确、PDB 和 exe 是否是同一次编译、有没有混淆 Debug/Release 或 x86/x64 版本。

3. 调用栈看起来不对,找不到崩溃函数

解决方案:先执行 \.ecxr,切换到异常上下文,再执行 kv 查看调用栈,不要直接打开 Dump 就看调用栈。

4. Dump 文件很大,担心占用空间

解决方案:练习时用 MiniDumpWithFullMemory 是为了方便观察,实际项目中可以去掉这个配置,生成体积更小的 MiniDump,满足大部分崩溃分析需求。


十一、总结:WinDbg 入门的核心的是“思路”,不是“命令”

看到这里,你已经掌握了 WinDbg 入门的核心流程。其实 WinDbg 不难,新手不用死记硬背所有命令,重点是记住这条调试链路:

程序注册异常处理函数
  ↓
程序发生崩溃
  ↓
异常处理函数拿到异常现场
  ↓
MiniDumpWriteDump 生成 Dump
  ↓
WinDbg 打开 Dump
  ↓
加载 PDB(关键钥匙)
  ↓
!analyze -v 看异常类型
  ↓
.ecxr 切换异常现场
  ↓
kv 查看调用栈
  ↓
dv 查看局部变量
  ↓
lmvm 确认符号加载成功

对新手来说,只要能做到这几件事,就算真正入门了:

1. 能让程序自动生成 .dmp 文件
2. 能用 WinDbg 打开 .dmp 文件
3. 能加载正确的 PDB
4. 能用 !analyze -v 看异常类型
5. 能用 .ecxr + kv 看调用栈
6. 能从调用栈定位到自己的崩溃函数

当你能通过 DumpPractice\.exe stack 看到完整的多层调用栈,并且能清晰说出“程序是怎么一步步崩溃的”时,WinDbg 的第一道门槛就已经跨过去了。

后续可以慢慢探索更复杂的场景(比如内存泄漏、死锁),但入门阶段,把今天的实操练熟,就足够应对大部分基础的程序崩溃问题了。

如果练习过程中遇到问题,欢迎在评论区留言,一起交流学习~

更多推荐