程序跑着跑着直接闪退,连个错误框都没有。这种情况别去猜,先开事件查看器,Windows日志的应用那一栏里有一条Event ID 1000,记着真正的线索:
错误应用程序名称: MyApp.exe,版本: 1.0.0.0,时间戳: 0x66f2a1c0
错误模块名称: MyApp.exe,版本: 1.0.0.0,时间戳: 0x66f2a1c0
异常代码: 0xc0000005
错误偏移: 0x000012a7
错误进程ID: 0x1a4c
异常代码0xc0000005就是STATUS_ACCESS_VIOLATION,也就是访问到了不该访问的地址。旁边几个容易看混的:0xc0000409是栈上的缓冲区溢出被/GS检查逮住,0xc0000374是堆结构被写坏了,0xc000041D是回调里抛了异常没人接。代码不一样,查的方向完全不同。
先让系统把dump留下来
默认的崩溃记录只有上面那几行,光凭它们什么也定位不了。Windows自带一套本地转储,默认没开,用注册表打开:
reg add "HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps" /v DumpFolder /t REG_EXPAND_SZ /d "C:\Dumps" /f
reg add "HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps" /v DumpType /t REG_DWORD /d 2 /f
DumpFolder默认指向%LOCALAPPDATA%\CrashDumps,DumpCount默认10个,超出后旧的被顶掉,DumpType默认1是迷你转储,只有栈和模块信息,堆里的内容一概没有。想看越界写坏的那块内存,DumpType必须设成2,完整转储。改完不用重启,下次崩溃就直接落盘。服务进程要注意一点,它的dump落在服务账户的profile目录里,System账户写的是%WINDIR%\System32\Config\SystemProfile。
不想动注册表,就用procdump挂在外面等:
procdump -ma -e 1 MyApp.exe C:\Dumps
-e 1表示把第一次抛出的异常也抓住,不加这个只抓没处理的那次,往往为时已晚。
没有PDB,dump只是一堆地址
拿到dump打开一看全是十六进制,十有八九是缺符号。编译的时候要带调试信息,Release也不能省。MSVC 14.16.27052、14.29.30157、14.40.33816、14.42.34433、14.44.35211、14.50.35710,这几代工具链里写法都一样:
cl /Zi /Od /EHsc main.cpp /link /DEBUG:FULL
链接器的/DEBUG:FULL会把私有符号也写进去。发布出去的程序留着PDB自己存档就行,符号服务器那套不上也没关系,分析的时候把PDB和dump放一起照样能用。之前碰到过一个dump死活出不来行号,查到最后是发布流水线把PDB删了,重新从构建产物里翻出来才对上。
用Windbg打开dump
WinDbg打开dump之后按这个顺序敲:
.symfix
.reload
!analyze -v
.ecxr
kv
!address <出问题的地址>
.symfix把符号路径指向微软服务器,.reload顺带把自己的PDB一起加载。!analyze -v会自动挑出出问题的那条指令,输出里FAULTING_IP就是崩溃地址,后面跟着的ExceptionInformation要对着看:第一个数是0表示读、是1表示写、是8表示执行,也就是把数据当代码跑了,通常是函数指针被踩坏或者DEP拦下了shellcode。.ecxr切到异常上下文再打kv,栈才是崩溃那一刻的栈。
崩溃地址本身就是线索
看到的是个很特别的地址时,不用看栈也知道大概:
1、地址在0x00000000附近,比如0x00000004、0x0000001c,是空指针加上成员偏移,典型写法是对着没判空的指针取成员。
2、0xcccccccc,Debug构建里未初始化的栈内存,VC填的就是这个;局部变量没赋值就拿来当指针用,命中率很高。
3、0xcdcdcdcd,未初始化的堆内存,malloc或new出来没写就用。
4、0xfeeefeee,已经free掉或delete掉的内存,use-after-free的典型痕迹。
5、0xbaadf00d,HeapAlloc出来没初始化的块;0xabababab是堆块后面的保护页,被它命中说明写越界写到堆管理结构上去了。
Release构建里这些填充值不会出现,看到的地址往往是一串乱七八糟的数,那时候就只能靠符号和栈了。
Debug构建里先让CRT替你盯着
开发阶段不必等到崩溃再查。在main开头塞几行,堆操作每次都会被检查一遍:
_CrtSetDbgFlag(_CRTDBG_ALLOC_MEM_DF | _CRTDBG_CHECK_ALWAYS_DF);
_CrtSetReportMode(_CRT_WARN, _CRTDBG_MODE_DEBUG);
_CrtSetBreakAlloc(18);
头一句让每次分配释放都校验堆结构,越界写入会在下一次堆操作时被逮住,报错的地方离真凶很近。_CrtSetBreakAlloc后面那个18是分配序号,程序会在第18次分配时自动断下,跑两次就能拿到泄漏那块的编号。这些只有Debug构建有效,Release下它们全是空操作。编译器那边配上/RTC1打开运行期检查,栈上的数组越界会直接弹框;/GS插栈上的安全cookie,/sdl再把更多警告升成错误。这些开关别在Release里留着。
AddressSanitizer
ASan在MSVC里从VS 16.9起可用,16.11、17.0、17.11、17.14,这几代一直在跟进。加一个开关就行:
cl /fsanitize=address /Zi /Od main.cpp
它抓的是use-after-free、双重释放、堆与栈的越界读写这类问题,报出来的信息直接带源码行号,比看dump省事。有几个坑:它与/RTC、增量链接、编辑并继续都不兼容,同时开就编译失败;只支持x86与x64;没装ASan组件时会报LNK1356找不到clang_rt.asan_dynamic-i386.lib,去VS安装器里补上就行。想让它在CI里也留证据,跑之前设ASAN_SAVE_DUMPS=crash.dmp,抓到问题会自动存一份转储。实测下来开销大概在两倍上下,Debug构建跑日常测试没问题,别拿它跑压测。
内核态那套是另一回事,KASan要VS 2022 17.11以上,还得是Windows 11 24H2或者Windows Server 2025。
页堆
ASan查不出来的老代码,还有页堆可以上。gflags在调试工具包里,装了WinDbg就有:
gflags /p /enable MyApp.exe /full
gflags /p /disable MyApp.exe
开了整页堆之后,每次堆分配后面都垫一整页不可访问的内存,越界写一个字节就立刻断在写入的那条指令上,不用等到堆结构被破坏才暴露。代价是内存占用能翻好几倍,速度也慢,只在本机复现的时候开,用完记得disable。抓到之后再配!heap -p -a <地址>,能看到那块内存是谁分配的、什么时候释放的。
让程序自己把现场存下来
现场环境装不了调试工具时,程序里自带一个崩溃回调最省事:
LONG WINAPI TopFilter(EXCEPTION_POINTERS* p) {
HANDLE h = CreateFileW(L"crash.dmp", GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, 0, NULL);
MINIDUMP_EXCEPTION_INFORMATION ei = { GetCurrentThreadId(), TRUE, p };
MiniDumpWriteDump(GetCurrentProcess(), GetCurrentProcessId(), h,
MiniDumpWithFullMemory, &ei, NULL, NULL);
CloseHandle(h);
return EXCEPTION_EXECUTE_HANDLER;
}
SetUnhandledExceptionFilter(TopFilter);
见过装了这个却一份dump都没收上来的情况,原因是程序里有别的地方调了abort,或者/GS检测到栈溢出后直接TerminateProcess,压根不走这个过滤器。栈溢出那类要额外调_set_abort_behavior(0, _WRITE_ABORT_MSG)与禁用错误报告弹窗,过滤器才接得到。
几类常见的成因
对着栈还是没头绪的时候,按下面几条逐个排除,命中率不低:
1、对象已经析构,别的线程还握着指针,多线程共享对象没加锁时最容易中招。
2、跨模块传函数指针,两边的调用约定不一致,__stdcall与__cdecl混用时Release下不会弹告诫,直接0xC0000005;Debug构建会先冒出「ESP的值没有在函数间正确保存」。
3、数组下标越界或者memcpy长度算错,写到了相邻的成员上,崩的地方离写入的地方隔着八百里。
4、虚函数表被踩坏,对象内存被写过之后再调虚函数,跳转地址就是一堆乱码。
5、DLL版本不匹配,结构体定义改过但旧DLL没重新编译,两边对一个字段的偏移理解不同。
数据截至2026年10月,上面这些行为在MSVC 14.40到14.50这几代工具链上没有变化。