十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

用Visual Studio 2019调试dmp文件:从符号到崩溃定位

用Visual Studio 2019调试dmp文件:从符号到崩溃定位 做Windows端开发的朋友估计都碰过这种让人糟心的场景客户现场程序崩了运行日志最后一条还是正常输出后面就没了下文。电话打过去对方也说不清楚自己到底点了什么。这时能拿到的往往只有一个.dmp文件——进程崩溃那一刻的内存快照。很多刚接触调试的人拿到dmp文件是懵的不知道该干嘛或者双击打开看到一堆十六进制就放弃了。其实用 Visual Studio 2019 调试 dmp 文件没有想象的那么玄核心就是把符号对上、源码指对然后看调用堆栈。这篇文章我把整个流程拆开揉碎讲一遍每一步该点哪里、有什么坑、为什么要这么做都会交代清楚适合C/C#的桌面端开发者尤其是经常要对接用户侧崩溃反馈的朋友。1. dmp文件到底是什么为什么非调不可1.1 崩溃现场的快照不只是“错误记录”dmp文件Dump File转储文件是进程在某一时刻的内存镜像。你可以把它理解成事故现场的照片拍照那一刻进程的代码执行到哪一行、每个线程的调用栈长什么样、每个变量大概是什么值、哪些模块被加载进来了全都记录在里面。如果抓的是完整转储Full Dump甚至能把进程当时占用的全部内存都保存下来理论上可以还原出崩溃前的几乎所有状态。和日志相比dmp文件有两个不可替代的优势。第一日志靠的是程序员预先埋点一旦漏了关键路径崩溃时什么都记不下来dmp不依赖埋点它记录的是“全量现场”。第二日志只能告诉你“大概发生了什么”dmp能告诉你“精确到哪一行代码、哪个变量的值导致了这个结果”。所以遇到偶发崩溃、内存越界、栈被写坏这类疑难杂症dmp基本是唯一可靠的排查依据。1.2 哪些场景下必须用dmp调试我在实际工作中遇到最多的三类情况客户环境崩溃本地复现不了。这几乎是无解的首选方案。用户机器上跑着杀毒软件、各种输入法钩子、显卡驱动和你的开发机环境差别太大。本地跑一百次都正常客户那边一启动就崩。让用户把dmp抓出来是最快拿到线索的途径。程序在无人值守的服务器上半夜崩溃。没有人在旁边盯着也没有交互式界面。配置好Windows错误报告WER自动抓取转储第二天检查服务器用dmp定位崩溃点。内存泄漏或句柄泄漏这类需要逐步排查的问题。连续抓取几个时间点的dmp通过对比堆内存增长趋势能快速锁定可疑模块。1.3 为什么选用VS 2019而不是其他工具不少人一听说分析dmp就想到WinDbg。WinDbg确实强大但命令行的学习曲线比较陡很多初学者光是在符号加载这一步就卡住了。Visual Studio 2019本身的“仅限本机调试”能力已经足够日常排查它有图形化界面、能直接和源码联动、能自动加载PDB符号文件甚至在大多数情况下能自动跳到崩溃的源代码行这对快速定位问题非常友好。并且VS 2019相当于一个“全套工具箱”调试dmp时打开“调用堆栈”“局部变量”“反汇编”几个窗口所有信息都是图形化呈现不需要记命令。对于日常崩溃分析完全够用了。WinDbg可以留着处理极端复杂的场景但99%的问题在VS 2019里就能定位。注意调试dmp文件环境版本建议和崩溃程序编译时保持一致。比如程序是用VS 2019编译的就用VS 2019打开dmp这样工具链对PDB格式、调试器内部结构的兼容性最好。用VS 2022打开2019的dmp通常也行但偶尔会遇到符号加载或调试模式切换的小问题。2. 调试前务必搞定的两样东西符号和源文件2.1 PDB符号文件为什么如此关键PDBProgram Database是程序编译时生成的符号文件里面记录了函数名、变量名、行号这些“人话”信息。没有PDB调试器只能看到一堆裸的内存地址和十六进制数字你根本不知道崩溃点对应的到底是什么函数。很多人把这个理解成“有PDB最好没有也能大致看看”。这个观念得改。对于排查崩溃问题PDB几乎是必需品。举个具体例子不带PDB时调用堆栈可能显示为0x00007FF6B2A31C2F()带上PDB后同一位置显示为CServer::HandleLoginPacket() 0x47。哪个能干活一目了然。所以一定要养成好习惯发布Release版本时把对应的PDB文件归档保存好。PDB和exe/dll是一一对应的版本变了PDB就得换。我给每个发布版本都单独建一个文件夹命名带版本号和编译日期PDB和exe/dll一起存档。曾经有个客户反馈线上偶发崩溃我本机翻遍硬盘找不到对应版本的PDB只能靠反汇编硬啃效率惨不忍睹。后来就老实归档了再遇到类似问题符号一加载几分钟就能定位。2.2 配置Microsoft符号服务器解除系统库的空白排查崩溃时经常看到调用栈中间有几帧是系统模块比如ntdll.dll、kernel32.dll甚至某些函数名显示为“unknown”。这些系统DLL的符号默认本机没有。调试器必须先去Microsoft的公共符号服务器下载对应的符号文件主要是PDB否则中间帧断掉调用栈看起来就不完整不好判断崩溃是被谁触发的。配置方法很简单打开VS 2019菜单栏依次进入“工具” - “选项” - “调试” - “符号”。勾选“Microsoft符号服务器”。下方“缓存符号到符号文件目录”填一个本地缓存目录建议用D:\Symbols或C:\SymbolsCache。符号文件其实挺大的尤其系统版本更新频繁缓存目录会慢慢变大记得定期清理。点击“确定”保存。配置完成后第一次调试某个dmp时会明显卡一会儿那是VS在后台下载符号。后续再用到相同版本的符号会直接从本地缓存读取速度就上来了。2.3 本地符号路径的设置技巧Microsoft符号服务器解决的是系统模块但你自己写的代码模块符号必须从自己的构建服务器或发布目录找。这里有个小技巧在同一个“符号”设置页面里“符号文件(.pdb)位置”列表中添加上自己存档PDB的路径。比如我的发布档目录是这样组织的D:\ReleaseArchive\ ├── V1.2.3.0\ │ ├── MyApp.exe │ ├── MyApp.pdb │ ├── CoreLib.dll │ └── CoreLib.pdb └── V1.2.4.0\ ├── MyApp.exe └── MyApp.pdb配置时直接把D:\ReleaseArchive加进去VS在加载符号时如果发现模块版本和某个子目录里的PDB匹配就会自动加载。这一步配置到位后面整个调试过程会顺畅很多。注意符号加载有“版本匹配”的强校验。如果把V1.2.3.0的PDB强行用在V1.2.4.0的exe上调试器会明显报错或直接拒绝。千万不要靠改名混过去调试出来的堆栈是错的反而浪费时间。2.4 源文件路径对不上怎么办PDB里记录的是编译时源代码的绝对路径。比如你在D:\Projects\MyApp\Core\Server.cpp编译的代码那么调试时VS就默认去这个位置找源码。如果本机目录结构不同就会提示“源文件不存在”。解决办法有两个。如果代码工程在自己机器上只是路径不同可以在“工具” - “选项” - “调试” - “常规”里勾选“源文件需要与原始版本完全匹配”然后调整本机目录结构把源码放到和编译时一致的相对路径。另一个办法是在“调试” - “选项” - “调试” - “符号”旁边的“源设置”菜单里添加源文件搜索路径。实际操作中我一般直接把整个源码项目从版本库拉下来切到对应标签放到编译时一致的路径下省事又不出错。3. 一步步实操从打开dmp到定位崩溃代码3.1 打开dmp文件选对调试模式现在假设手里已经拿到一个dmp文件后面第5节会讲怎么抓正式进入调试流程。第一步双击打开.dmp文件。VS 2019会显示一个“快速启动”页面页面顶部是dump文件的摘要信息包括崩溃发生在哪个模块、哪个线程、异常类型、异常地址等。关键操作在页面中间有一个“操作”下拉菜单里面有几项使用“仅限本机”进行调试使用“混合”进行调试使用“托管的”进行调试设置符号路径保存为快照如果你的程序是Native C选“仅限本机”。如果是C#/.NET程序选“托管的”。不确定就选“混合”但混合模式调试时有时会卡一些。我平时绝大多数是在调C的崩溃所以直接选“仅限本机”。注意如果你的“仅限本机”按钮是灰色不可用的可能是项目配置问题或者VS缺少C桌面开发组件。到Visual Studio Installer里勾选“使用C的桌面开发”工作负荷装上就能用了。3.2 等符号加载完再点“中断”点击“使用仅限本机进行调试”后VS会进入一个类似断点中断的调试状态。第一次会有个进度条显示“正在加载符号”。这里有个重要提醒千万不要看着界面没反应就急着点“全部中断”或“继续”。实际执行的完整流程是等VS自动从符号目录加载所有模块的PDB。如果配置了Microsoft符号服务器还会联网下载系统符号这个过程可能持续几秒到一两分钟。下载完成后VS才会分析调用堆栈。这时打开“调用堆栈”窗口调试菜单 - 窗口 - 调用堆栈或快捷键 CtrlAltC已经能看到每个线程的栈了。如果堆栈窗口还是显示“无法找到当前堆栈帧”多半是符号没加载好需要到“模块”窗口手动右键加载符号。“模块”窗口调试 - 窗口 - 模块是个非常有用的工具它列出了进程加载的所有DLL/exe并显示每个模块的“符号状态”。如果状态列显示“已加载符号”就正常如果显示“无法找到或打开PDB文件”就在该行右键选择“加载符号”手动指向PDB所在目录。在等待符号加载完成的瞬间注意观察VS底部状态栏它会显示当前正在加载哪个模块的符号这对诊断卡顿非常有帮助。3.3 查看异常信息确认崩溃点符号就绪后打开“异常设置”或直接看“即时窗口”能看到异常信息。VS 2019在调试dmp时如果崩溃点有异常记录会自动弹出一个描述框比如Unhandled exception at 0x00007FF6B2A31C2F in MyApp.dmp: 0xC0000005: Access violation reading location 0x0000000000000008.这个0xC0000005就是访问冲突Access Violation也就是经典的空指针越界、野指针操作。常见的异常代码我整理了一张表异常代码含义常见原因0xC0000005访问冲突空指针解引用、野指针、越界读写0xC0000409栈缓冲区溢出栈上数组越界、memcpy长度失控0x00000000除零错误整数除以00xC00000FD栈溢出无限递归、过大的栈局部变量0xE0434352未处理的.NET异常托管程序的catch未捕获或CLR内部异常0x80000003断言失败或断点触发代码中的DebugBreak或assert拿到异常代码问题方向就有了。比如最常见的0xC0000005我会优先看顶层调用栈帧定位到对应的源码行再检查那个指针有没有判空、数组索引有没有越界。3.4 调用堆栈窗口逐帧追踪崩溃来源打开“调用堆栈”窗口看到的是崩溃线程的调用链。窗口里有几列模块、函数名、行号、地址。典型的C调用堆栈长这样MyApp.exe!CServer::HandleLoginPacket() Line 128 MyApp.exe!CServer::ProcessNetEvent() Line 345 MyApp.exe!CNetworkThread::Run() Line 78 MyApp.exe!thread_startunsigned int (__stdcall *)(void *) Line 97 kernel32.dll!BaseThreadInitThunk ntdll.dll!RtlUserThreadStart最上面一行是崩溃时正在执行的函数往下是调用方。我的习惯是从最上面一层开始看先双击跳转到源码行。如果第一帧没法对齐源码比如优化掉了行号就往下看第二帧、第三帧一般都能找到对应的业务代码。这里有个常见坑Release版本开了优化之后局部变量的值和调用栈可能会“看起来”对不上甚至某些变量被编译器优化得根本看不见。遇到这种情况不用慌先看函数调用链和传入的参数能定位到具体的处理流程就成功了一大半。3.5 局部变量和监视窗口看现场数据跳到源码行后打开“局部变量”窗口调试 - 窗口 - 局部变量或 CtrlAltV、L能看到当前函数的所有局部变量。如果是类成员函数还能展开this指针看到所有成员变量。这是最有价值的一步。比如你看到一个空指针崩溃在局部变量里展开指针发现某个成员变量的值是0x00000000或者某个字符串变量的内容是空串但长度字段却是天文数字崩溃原因基本就水落石出了。有监听需求的还可以右键变量 - “添加监视”把关键变量固定在“监视”窗口里方便一路跟踪。调试dmp和调试活进程最大的区别是dmp状态是静态的变量值不会变了但正因如此你可以放心大胆地慢慢看、慢慢翻不用怕程序继续跑飞。3.6 多线程场景先切线程再查堆栈程序崩溃往往不只是主线程的事。尤其是网络程序工作线程一大把真正的崩溃线程可能是某个另起的业务线程。这时打开“线程”窗口调试 - 窗口 - 线程或 CtrlAltH能看到所有线程及其状态。每个线程都有ID崩溃线程通常会被标记为“异常”或高亮。如果看不出哪个线程异常就看线程的“位置”列VS会在该线程当前执行的函数名上做个标注。双击线程行切过去再打开它的调用堆栈就能分析这个线程崩溃时的上下文。实际操作中有一个技巧对比“正常线程”和“崩溃线程”的调用栈。比如正常线程都在阻塞等待网络事件崩溃线程却在做数据解析那么问题范围就瞬间缩小到数据解析这段代码了。3.7 没有源码也能看反汇编窗口的兜底方案有时候你调的是一个第三方库的崩溃或者自己历史版本源码找不到了源码反正看不了。这时“反汇编”窗口调试 - 窗口 - 反汇编或 CtrlAltD是最后的兜底。反汇编窗口会把崩溃点处CPU指令逐条列出来旁边还标注着模块名和指令地址。虽然汇编不像C那么好读懂但经过前两步你已经知道大概的模块和函数范围了。用call、mov、lea这些常见指令结合寄存器窗口里的rax、rcx值也能大致判断崩溃前一刻做了什么操作。比如一个mov rax, [rcx08h]崩溃了基本能断定rcx08h地址非法也就是某个对象的成员指针是空指针或野指针。提示汇编调试需要一定的汇编基础。建议平时积累一点基础不至于到了只能靠反汇编时手足无措。好在95%的崩溃都能通过源码堆栈搞定反汇编只是备选。4. 常见问题与排查技巧实录4.1 符号加载失败现象模块窗口显示“无法找到或打开PDB文件”。原因几乎可以锁定下面几种PDB路径没配置对、符号文件名不匹配、PDB版本不匹配、缓存目录权限不够、Microsoft符号服务器连接超时。排查顺序我固定是这样确认“工具”-“选项”-“调试”-“符号”里已经勾选“Microsoft符号服务器”并填了缓存目录。确认本地符号路径里有对应版本的PDB。PDB的名称必须和模块名一致比如MyApp.exe对应MyApp.pdb。手动在模块窗口右键该模块“加载符号”并选择PDB文件看VS报什么具体错误。检查缓存目录有没有被清理或权限不足。有时杀毒软件会拦截符号写入导致加载失败。如果还是不行用文本编辑器打开PDB的第一行看文件头确认它的“版本时间戳”和exe里记录的调试信息是否一致这部分可以用来佐证但实际操作不多。经常出现的一个场景是项目在编译的时候PDB被输出到了和exe同一个目录但发布的时候只打包了exe把PDB遗漏了。然后客户侧崩溃本地又没有这个版本的PDB只能干瞪眼。所以发布流水线里一定要把PDB归档这一步写进去。4.2 源码路径对不上现象当下层堆栈双击某个函数帧VS提示“源文件不存在”。这种情况要么是源码路径变了要么是反编译时目标机器上完全没有该源码。我的经验是如果源码在本机有先确认编译时的目录结构。VS 2019的PDB里存的是绝对路径所以我建议建工程时就保持所有开发机的目录结构一致比如都用C:\Work\ProjectName\作为根目录就能最大程度避免这个问题。如果源码换过盘符或目录可以在“工具”-“选项”-“调试”-“符号”页面下方找到“源文件搜索路径”添加多个可能的源码根目录。如果那个版本源码真的丢了那么就把这个函数帧看作黑盒只从调用方和参数去推断问题。实在不行就用反汇编窗口硬看。4.3 堆栈显示“unknown”或乱码现象堆栈窗口有一堆0x000007FEF25A1C2F()这种无法显示函数名的帧。先判断是PDB没加载还是dmp本身抓得不完整。如果只有个别帧不显示通常是因为那个模块是静态库或系统托底模块PDB没加载。如果你看到所有用户代码模块的栈都是“unknown”那大概率是符号路径配置错了或者dmp文件本身是个迷你转储缺少必要的上下文。迷你转储Minidump常用的类型有“小内存转储128KB”和“内核内存转储”。128KB的转储常常信息不全有时连完整的线程栈都保不住分析起来会很痛苦。所以我在客户机器上会尽量引导他们抓取“完整内存转储”虽然文件大可能几百MB甚至几个G但信息最全能看清事情全貌。用WinDbg或Procdump可以控制转储级别下一节会提到。4.4 崩溃线程定位不准现象多个线程都在干活VS没自动高亮异常线程不知道看哪个。这时先看“异常”窗口调试-窗口-异常设置里面会列出捕获到的异常线程ID。或者看“即时窗口”输入~* k可以列出所有线程的调用栈注意这是脚本命令要用在即时窗口的调试模式。不过最省事的办法还是在“线程”窗口里看哪一行的“位置”列后面带感叹号或“已中止”等异常标记优先点那一行。4.5 dmp文件明明打开很快但一按调试就卡死现象dmp打开正常配置符号后点“使用仅限本机进行调试”VS卡得跟死机一样。多数情况是VS在尝试访问符号服务器下载大量符号而网络的连通性并不好超时等待非常耗时。解决办法是把“工具”-“选项”-“调试”-“符号”里“Microsoft符号服务器”暂时取消勾选先从本地加载已有PDB等排查完用户代码再补系统调用链的分析。或者先把Microsoft符号服务器的缓存预填充好在能联网的机器上用symchk.exe提前下载常用系统符号包拷到本地符号目录。4.6 崩溃模块是自己写的一个DLL但堆栈只显示到exe层现象调用栈顶层是exe里的模块怎么点都看不到自己DLL里的函数。原因一般是DLL的PDB没加载进来。DLL和exe是不同的模块各自的PDB要独立加载。回到“模块”窗口找到自己那DLL检查符号状态如果是“无法找到”就手动加载它的PDB。一旦加载调用堆栈会自动刷新DLL内部的具体崩溃函数就会显示出来了。提示这种“堆栈只显示到exe层”的现象大坑多发于DLL编译时被优化内联或DLL的符号归档目录和exe不在一起。发布时一定要把整套exedll对应全部PDB同时归档。5. 顺带分享几个抓dmp的姿势5.1 任务管理器手动抓取Windows 10/11的任务管理器自带转储功能。右键任务栏-任务管理器-详细信息找到目标进程右键选择“创建转储文件”。默认会生成到%LOCALAPPDATA%\Temp\目录下文件名类似MyApp (PID 1234).dmp。不过任务管理器抓的是“完整转储”一个内存占用2G的进程dump文件就能到2G甚至更大传文件时不太方便。但胜在操作简单让客户去操作也容易教。5.2 Procdump按规则自动抓取Sysinternals出品的Procdump是生产环境抓dump的利器。它支持多种触发条件按进程异常、按CPU占用率、按内存阈值、按窗口无响应等。常用命令我放在下面# 按指定PID在进程异常退出时生成完整转储 procdump -ma -e -x C:\Dumps ProcessName.exe # 按进程CPU持续5秒超过80%时抓取 procdump -ma -c 80 -s 5 ProcessName.exe C:\Dumps # 按PID抓完整转储 procdump -ma -o PID C:\Dumps参数-ma表示写完整转储-e表示进程收到未处理异常时触发-x表示在进程异常退出时抓取。这套规则用在自动监控脚本里非常省心崩了自动留证。5.3 WER注册表配置崩溃自动保存如果要做到“用户不用管崩溃时系统自动记录”可以配置Windows错误报告WER的LocalDumps键。在注册表HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps下新建项项名称你的exe名称如MyApp.exeDumpFolderREG_EXPAND_SZ转储目录如C:\DumpsDumpTypeREG_DWORD2表示完整转储1表示MiniDumpDumpCountREG_DWORD保留转储文件最大数量配置好后程序崩溃时会自动往指定目录写dmp不用客户参与非常推荐用在自己产品的安装包或部署脚本里。5.4 让抓取方按要求传文件客户抓了文件后经常直接往微信群里丢文件有时已经损坏或截断。我一般会提前告诉客户“文件可能很大最好压缩一下用网盘或大文件传输工具传。”再提醒一句“如果文件只有几十KB很可能抓的是MiniDump信息不全不能分析。请用完整转储的方式再抓一遍。”6. 几个踩过坑之后的实在建议调试dmp文件这件事工具层面的操作不难难的是平时积累好习惯真到用的时候才能顺手。第一每个版本的PDB必须归档这一点我说过好几遍了因为实在太重要。哪怕就是一个内部小工具编译出来放到客户那里出了状况没有PDB就只能看天书。把PDB和exe/dll连同版本号、编译时间一起打包放在统一归档目录这个动作就花几秒钟但到了排查问题的时候能给你省下几小时。第二符号服务器和源码路径这些配置在VS里只有几行设置但一定要提前配好别拿到来dmp才临时弄。尤其是内网开发环境访问外网受限的团队提前把系统符号文件缓存好能省去很多等待和卡顿。第三拿到dmp不要急着双击先看文件大小。小于1MB的“迷你转储”往往信息不全先要求对方改用完整转储重新抓。大于几百MB的转储文件加载时要耐心等一下符号不要中途取消。第四别把dmp调试当成最终的“破案”工具它更接近“现场勘察”。先通过堆栈和变量值锁定崩溃发生的准确位置但根因往往还要结合代码逻辑和业务场景进一步分析。比如堆栈显示某个函数里访问了空指针但更根本的问题可能是这个函数之前某个地方没有判空就传入了null或者某个对象已经提前释放了。这时候我还需要回去看源代码逻辑以及有没有时机相关的Bug。第五如果看完了堆栈还是没头绪试试从“线程”窗口看看有没有其他线程在做奇怪的事情比如一个线程正在释放资源另一个线程刚好在用。这类“线程间竞争”导致的偶发崩溃现象很随机但通过多份不同时间点的dmp对比往往能发现固定的崩溃线程和操作规律。我自己的习惯是每次发布会顺手写一个小的“崩溃自查笔记”这个版本改了什么核心模块、有没有涉及内存管理、有没有新的多线程交互。如果之后客户反馈崩溃我拿dmp打开看到堆栈落在这些改动范围内一下子就能缩小范围。这比漫无目的地等dmp数据要高效得多。最后哪怕只是为了让同事知道“这个问题我已经定位到哪个函数了”也值得把某个版本的dmp和PDB对应关系记清楚。遇到线上崩溃谈话间直接甩出一行堆栈和一个变量值比你说一百句“我大概知道是哪里出了问题”都有说服力。工具是死的习惯是活的把准备工作做扎实调试dmp才能变成一件真正靠谱的事。
返回列表