【WinDbg 入门:用 C++ 自动生成 Dump,并学会分析崩溃现场】
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 场景,会看到从 main 到 CauseNullPointerWrite 的完整调用链路,能清晰看到程序是怎么一步步崩溃的。
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 的第一道门槛就已经跨过去了。
后续可以慢慢探索更复杂的场景(比如内存泄漏、死锁),但入门阶段,把今天的实操练熟,就足够应对大部分基础的程序崩溃问题了。
如果练习过程中遇到问题,欢迎在评论区留言,一起交流学习~
更多推荐
所有评论(0)