目录
1、概述
2、静态分析dump文件的一般步骤
2.1、查看异常类型
2.2、使用.ecxr命令切换到发生异常的线程上下文,查看发生异常的那条汇编指令
2.3、使用kn/kv/kp命令查看异常发生时的函数调用堆栈
2.4、使用lm命令查看模块的时间戳,找到对应的pdb文件,设置到Windbg中
3、问题分析实例说明
4、使用Windbg详细分析dump文件,展现完整分析过程
4.1、查看异常类型和发生崩溃的汇编指令,初步分析
4.2、使用kn/kv/kp命令查看函数调用堆栈
4.3、找到pdb文件,设置到windbg中,查看完整的函数调用堆栈
4.4、可以将C++源代码路径设置到Windbg中,Windbg会自动跳转到源代码行号上
4.5、有时需要查看函数调用堆栈中函数的局部变量或C++类对象中变量的值
4.6、有时可能需要使用IDA去查看二进制文件的汇编代码上下文去辅助分析
VC++常用功能开发汇总(专栏文章列表,欢迎订阅,持续更新...)https://blog.csdn.net/chenlycly/article/details/124272585C++软件异常排查从入门到精通系列教程(专栏文章列表,欢迎订阅,持续更新...)https://blog.csdn.net/chenlycly/article/details/125529931C++软件分析工具案例集锦(正在更新中...)https://blog.csdn.net/chenlycly/category_12279968.html 有很多正在学习C++软件调试技术的朋友或者刚入门的人,希望我能详细讲讲使用Windbg静态分析dump文件的一般步骤,他们好对照这个步骤去练习去实操。所以今天就来讲讲这个主题内容,先概要性讲述Windbg静态分析dump文件的一般步骤,然后再以一个问题分析实例详细展现分析dump文件的完整过程(与本课程相关的免费视频教程,已经发布到B站,具体地址见本文的的评论区)。
基本大部分软件都内置了异常捕获模块,在软件发生异常闪退崩溃时,会弹出相关的提示框,比如PC版的微信在崩溃时,其内置的异常捕获模块会捕获到异常并生成日志及dump文件,同时会弹出如下的发送错误报告的提示框:
提示框的下方会自动带上崩溃相关的文件,其中最后一个文件就是我们要讲的dump文件,点击确定则会将这些文件发送到腾讯远端的后台服务器上。腾讯后台的运维人员会收到通知,然后到服务器上将dump等文件下载下去分析。
有些软件可能没有上传崩溃日志到服务器的功能,捕捉到异常时会自动将dump文件保存到指定的路径中,事后可以到该路径中取到对应的dump文件。如果客户机器上遇到崩溃,可以和客户联系,让客户帮忙从对应的路径中取来出dump文件发过去。
使用Windbg分析包含异常上下文的dump文件,属于静态分析,下面就来详细讲一下拿到dump文件之后如何使用Windbg去静态分析dump文件。
关于Windbg的下载安装及详细介绍,可以查看我之前写的文章:
Windbg使用详解https://blog.csdn.net/chenlycly/article/details/120631007关于Windbg的命令汇总,可以查看我之前写的文章:
Windbg调试命令汇总https://blog.csdn.net/chenlycly/article/details/51711212
可以先打开Windbg,然后将dump文件拖到Windbg中。
打开dump文件,就会显示发生异常的类型,如下所示:
根据显示的异常类型(ExceptionCode值对应的含义,比如0xC0000005对应的就是Access violation内存访问违例)。
通过这个异常类型可以初步判断出异常的种类,比如Access violation(内存访问违例)、Integer divided by zero(除0异常)、Stack overflow(线程栈溢出)等。对于Access violation内存访问违例的异常,原因比较多,很难一步确定引发异常的原因。但对于Integer divided by zero除0异常、Stack overflow线程栈溢出异常,则能给出直接的线索,可以快速地定位问题。
在查看异常类型之后,我们输入.ecxr命令切换到发生异常的线程上下文,查看发生异常的那条汇编指令及相关寄存器的值:
通过查看汇编指令及各寄存器的值,可以初步判断当前发生异常的初步原因,比如可能是访问C++类空指针、野指针或者用户态的代码访问了内核态内存地址。
汇编代码能最直接、最本真的反映出崩溃的原因,程序运行时执行的都是二进制文件中的汇编代码(或者称为二进制机器码,二进制机器码和汇编代码是等价的)。
下面讲两个典型的汇编指令中访问了不该访问的内存异常场景。
2.2.1、汇编指令中访问64KB小地址内存区,引发访问违例,导致崩溃
在Windows系统中,64KB以内的内存地址是禁止访问的,如果程序访问这个范围内的内存,则会触发内存访问违例,系统会强行将进程终止掉。
2.2.2、汇编指令中访问了很大的内核态的内存地址,引发访问违例,导致崩溃
对于32位程序,系统会给进程分配4GB的虚拟内存,一般情况下用户态和内核态会各占一半,即各占2GB,我们编写的代码基本都是运行在用户态的,用户态的代码时不能访问内核态内存地址的(内核态地址是供系统内核模块使用的),如果崩溃指令中访问了一个很大的内存地址,超过用户态的地址范围0-2GB,内存地址大于0x8000000,则会触发内存违例,因为用户态的代码是禁止访问内核态内存地址的。
接着,使用kn/kv/kp命令(三个命令,可以使用其中任何命令)去查看异常发生时的函数调用堆栈,看看是调用了什么函数触发的异常。根据函数调用堆栈,对照着源码去进行分析。
一般此时没有加载pdb文件,调用堆栈中不会显示具体的函数名和代码的行号:
因为Windbg没有加载包含函数及变量符号信息的pdb文件。要函数调用堆栈中显示具体的函数名和代码行号,则需要拿到pdb文件,然后将pdb文件的路径设置到Windbg中。然后Windbg记载到pdb符号库文件之后,重新输出函数调用堆栈,堆栈中就会显示出具体的函数名和代码行号了。
函数调用堆栈在没加载pdb文件时,会显示代码所在的模块名称,我们使用lm命令查看这些模块编译生成的时间戳:
然后通过时间戳找来这些模块对应的pdb文件,然后将pdb文件的路径设置到windbg中。如何将pdb文件的路径设置到Windbg中,下面讲实例时会详细说明,此处就不再赘述了。
Windbg加载pdb文件之后,使用kn/kv/kp命令再次输出函数调用堆栈,堆栈中就会显示具体的函数名及行号,这样对照着C++源代码去详细分析引发问题的最终原因了。
有时候为了搞清楚发生异常的本质,我们还需要使用IDA查看相关二进制文件的汇编代码,查看一下发生异常的那条汇编指令的上下文,对照着C++源码,看看那条汇编指令为啥会出现异常。
我们为了方便展开讲解,我们特意使用VisualStudio创建了一个基于MFC对话框的exe测试程序,对话框中有个名为button1的按钮,如下所示:
我们在此按钮的响应中添加了一段引发崩溃的测试代码,故意让程序产生崩溃,测试然后拿到崩溃时的dump文件。
具体的测试代码如下所示:
// 添加的一段测试代码
SHELLEXECUTEINFO *pShExeInfo = NULL;
int nVal = pShExeInfo->cbSize; // 通过空指针访问结构体成员,导致崩溃
CString strTip;
strTip.Format( _T("nVal=%d."), nVal );
AfxMessageBox( strTip );
代码中使用到的结构体SHELLEXECUTEINFO 定义如下:
typedef struct _SHELLEXECUTEINFOW
{
DWORD cbSize; // in, required, sizeof of this structure
ULONG fMask; // in, SEE_MASK_XXX values
HWND hwnd; // in, optional
LPCWSTR lpVerb; // in, optional when unspecified the default verb is choosen
LPCWSTR lpFile; // in, either this value or lpIDList must be specified
LPCWSTR lpParameters; // in, optional
LPCWSTR lpDirectory; // in, optional
int nShow; // in, required
HINSTANCE hInstApp; // out when SEE_MASK_NOCLOSEPROCESS is specified
void *lpIDList; // in, valid when SEE_MASK_IDLIST is specified, PCIDLIST_ABSOLUTE, for use with SEE_MASK_IDLIST & SEE_MASK_INVOKEIDLIST
LPCWSTR lpClass; // in, valid when SEE_MASK_CLASSNAME is specified
HKEY hkeyClass; // in, valid when SEE_MASK_CLASSKEY is specified
DWORD dwHotKey; // in, valid when SEE_MASK_HOTKEY is specified
union
{
HANDLE hIcon; // not used
#if (NTDDI_VERSION >= NTDDI_WIN2K)
HANDLE hMonitor; // in, valid when SEE_MASK_HMONITOR specified
#endif // (NTDDI_VERSION >= NTDDI_WIN2K)
} DUMMYUNIONNAME;
HANDLE hProcess; // out, valid when SEE_MASK_NOCLOSEPROCESS specified
} SHELLEXECUTEINFOW, *LPSHELLEXECUTEINFOW;
#ifdef UNICODE
typedef SHELLEXECUTEINFOW SHELLEXECUTEINFO;
typedef LPSHELLEXECUTEINFOW LPSHELLEXECUTEINFO;
#else
typedef SHELLEXECUTEINFOA SHELLEXECUTEINFO;
typedef LPSHELLEXECUTEINFOA LPSHELLEXECUTEINFO;
#endif // UNICODE
在测试代码中定义了SHELLEXECUTEINFO结构体指针pShExeInfo,并初始化为NULL,然后并没有给该指针赋一个有效的结构体对象地址,然后使用pShExeInfo访问结构体的cbSize成员的内存,因为pShExeInfo中的值为NULL,所以结构体cbSize成员的内存地址是结构体对象起始地址的偏移,因为结构体对象地址为NULL,cbSize成员位于结构体的首位,所以cbSize成员就是结构体对象的首地址,就是NULL,所以就访问64KB小地址内存块的异常,引发内存访问违例,导致程序发生崩溃闪退。
至于如何在程序中设置异常捕获模块去捕获异常、自动生成dump文件,可以尝试使用google开源的CrashRpt库,我们产品很早就有了。大家如果想集成异常捕获库,可以使用开源CrashRpt(开源版本有一些缺陷,我们进行了一些优化和改进),也可以使用Google浏览器开源代码中的BreakPad,网上有很多版本,可以去研究一下。
使用Windbg打开dump文件(直接将dump文件拖入到Windbg),首先我们查看一下发生的异常类型,如下:
然后使用.excr命令切换到发生异常的线程上下文,会自动将发生异常的那条汇编指令显示出来,如下:
我们可以先看一下这条汇编指令以及崩溃时的各个寄存器的值,可能能初步估计出发生异常的原因。如果指令中访问了一个很小的地址或者访问了一个很大的地址,都会触发内存访问违例。
从崩溃的这条汇编指令来看,是访问了小地址0x00000000地址,这是访问了小于64KB小地址内存区,这个范围的地址是禁止访问的,所以引发了内存访问违例。这个异常很可能是访问了空指针引起的。
接着输入了kn命令,将函数调用堆栈打印出来:
函数调用堆栈是从下往上看的,最上面一行就是最后调用的一个函数,也是崩溃的那条汇编指令所在的函数。从函数调用堆栈的最后一帧调用的函数来看,程序的崩溃是发生在TestDlg.exe文件模块中,不是其他的dll模块。显示的函数地址是相对TestDlg.exe文件模块起始地址的偏移,为啥看不到模块中具体函数名称呢?那是因为Windbg找不到TestDlg.exe对应的pdb文件,pdb文件中包含对应的二进制文件中的函数名称及变量等信息,Windbg加载到pdb文件才能显示完整的函数名。
查看函数调用堆栈的命令,除了kn,还有kv和kp命令,其中kv还可以看到函数调用堆栈中调用函数时传递的参数,如下所示:
我们需要取来pdb符号库文件,去查看具体的函数名及行号的,这样才好找到直接的线索的。下面就来看看如何获取到TestDlg.exe模块的pdb文件。
如何才能找到TestDlg.exe文件对应的dpb文件?我们可以通过查看TestDlg.exe文件的时间戳找到文件的编译时间,通过编译时间找到文件对应的pdb文件。在Windbg中输入lm vm TestDlg*命令,可以查看到TestDlg.exe文件的详细信息,其中就包含文件的时间戳:(当前的lm命令中使用m通配符参数,所以在TestDlg后面加上了*号)
可以看到文件是2022年6月25日8点26分23秒生成的,就可以找到对应时间点的pdb文件了。
一般在公司正式的项目中,通过自动化软件编译系统,每天都会自动编译软件版本,并将软件的安装包及相关模块的pdb文件保存到文件服务器中,如下所示:
这样我们就可以根据模块的编译时间找到对应版本的pdb文件了。
我们找到了TestDlg.exe对应的pdb文件TestDlg.pdb,将其所在的路径设置到Windbg中。点击Windbg菜单栏中的File->Symbol File Path...,打开设置pdb文件路径的窗口,将pdb文件的路径设置进去,如下所示:
点击OK按钮之前,最好勾选上Reload选项,这样Windbg就会去自动加载pdb文件了。但有时勾选了该选项,好像不会自动去加载,我们就需要使用.reload /f TestDlg.exe命令去让Windbg强制去加载pdb文件(命令中必须是包含文件后缀的文件全名)。
设置完成后,我们可以再次运行lm vm TestDlg*命令去看看pdb文件有没有加载进来:
如果已经加载进来,则会在上图中的位置显示出已经加载进来的pdb文件的完整路径,如上所示。
关于pdb符号库文件的说明,可以查看我之前写的文章:
pdb符号库文件详解https://blog.csdn.net/chenlycly/article/details/125508858 加载到TestDlg.exe文件对应的pdb文件之后,我们再次执行kn命令就可以包含具体的函数名及及代码的行号信息了,如下:
我们看到了具体的函数名CTestDlgDlg::OnBnClickedButton1,还看到了对应的代码行号312。通过这些信息,我们就能到源代码中找到对应的位置了,如下所示:
是访问了空指针产生的异常。当然上面的代码是我们故意这样写的,目的是为了构造一个异常来详细讲解如何使用Windbg进行动态调试跟踪的。
为了方便查看,我们可以直接在Windbg中设置C++源码路径,这样Windbg会自动跳转到源码对应的位置。点击Windbg菜单栏的File->Source File Path...,将源码路径设置进去:
然后在Windbg点击函数调用堆栈中的每一行记录前面的数字超链接,会自动跳转到对应的函数及行号上,如下所示:
上图中的函数调用堆栈中很多模块是系统库中的,比如mfc100u、User32等,这些库是系统库,是没有源码的。我们可以点击最下面的第23个链接,其位于我们应用程序的模块中,会自动跳转到对应的代码中,如下:
有时我们通过查看变量的值,找到排查问题的线索,比如变量中值为0或者很大的异常值。这点我们在多次问题排查中使用到,确实能找到一些线索。可以查看函数中局部变量的值,也可以查看函数所在类对象的this指针指向的类对象中变量的值。我们要查看哪个函数,就点击函数调用堆栈中每一行前面的数字超链接,如下所示:
我们看到了局部变量pShExeInfo 的值:
struct _SHELLEXECUTEINFOW * pShExeInfo = 0x00000000
我们可以点击this对象的超链接:
就能查看当前函数对应的C++类对象中成员变量的值,如下:
有时函数中相关变量的值,对于分析当前的C++软件异常,可能是关键线索,比如我之前写过的两篇项目问题排查实例文章:
通过查看Windbg中的变量值去定位C++软件异常问题https://blog.csdn.net/chenlycly/article/details/125731044通过查看Windbg中汇编指令及内存中的值去定位软件崩溃问题https://blog.csdn.net/chenlycly/article/details/125793532 但有时不一定能查看变量的值,因为当前通过异常捕获模块自动生成的dump文件一般是minidump文件,文件也就几MB左右,不可能包含所有变量的值。所以要在minidump文件中查看变量的值,要看运气的,有时能查看到,有时是看不到的。这里要讲一下dump文件的分类,主要分为minidump文件和全dump文件。
关于dump文件的分类与dump文件的生成方式,可以查看我之前写的文章:
dump文件类型与dump文件生成方法详解https://blog.csdn.net/chenlycly/article/details/127991002
有时通过函数调用堆栈和源码很难定位问题时,可能需要使用IDA打开二进制文件去查看汇编代码的上下文去辅助定位问题。关于如何使用IDA去辅助排查问题,可以参见我之前写的文章:
使用IDA查看汇编代码上下文去辅助排查C++软件异常问题https://blog.csdn.net/chenlycly/article/details/128942626