通过Windbg动态调试去捕获C++软件异常的完整过程介绍

Posted dvlinker

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了通过Windbg动态调试去捕获C++软件异常的完整过程介绍相关的知识,希望对你有一定的参考价值。

目录

1、概述

2、将Windbg附加到已经启动起来的目标进程上,或者用Windbg启动目标程序

2.1、将Windbg附加到已经启动起来的目标进程上

3.2、用Windbg启动目标程序

3、分析实例说明

4、分析测试程序的崩溃

4.1、启动程序,将Windbg附加到目标进程上

4.2、使用Windbg初步分析崩溃

4.3、找到相关模块的pdb文件,设置到Windbg中

4.4、加载pdb后查看包含完整信息的函数调用堆栈

4.5、设置C++源代码的路径,Windbg会自动跳转到源代码对应的行号上

4.6、有时需要查看函数中变量的值 

4.7、使用.dump命令导出包含异常上下文的dump文件


C++软件异常排查从入门到精通系列教程(专栏文章列表,欢迎订阅,持续更新...)https://blog.csdn.net/chenlycly/category_11397492.html       软件运行过程中发生的部分崩溃或闪退,在软件中设置的异常捕获模块是捕获不到的,这种情况下就需要尝试使用Windbg的动态调试了。今天我们就来详细讲述一下如何使用Windbg进行动态调试。

1、概述

       软件运行过程中发生的大多数崩溃或闪退,软件中设置的异常捕获模块都能捕获到,都会生成包含异常上下文的dump文件。我们只需要事后取来dump文件,然后用Windbg打开进行静态分析就可以了。

       但有少部分崩溃或闪退,异常捕获模块是捕获不到的,或者是捕获到后产生了二次崩溃,没有生成dump文件,也就无法使用Windbg进行静态分析了。这个时候就需要使用Windbg的动态调试了,将Windbg附加到目标进程上,即Windbg跟着目标进程一起运行,一旦目标进程发生异常,Windbg能第一时间感知到并中断下来,就可以使用kn/kv/kp命令查看到崩溃时的函数调用堆栈,就能进行分析排查了。

       虽然开启Windbg动态调试的过程很简单,但很多刚入门的新人不太清楚,所以我们在此处以一个崩溃实例来详细介绍一下整个操作的过程。

2、将Windbg附加到已经启动起来的目标进程上,或者用Windbg启动目标程序

       要使用Windbg动态调试,要将Windbg关联到目标进程上,可以先将程序启动起来,然后将Windbg附加到目标进程上;也可以用Windbg启动目标程序。两种方式的使用场景略有区别。

2.1、将Windbg附加到已经启动起来的目标进程上

        如果目标程序已经启动,则可以将Windbg附加到进程上。打开Windbg,点击菜单栏File->Attach to a Process...,如下所示:

在打开的窗口中,在进程列表中找到目标程序,点击确定,完成进程的附加:

       当程序发生卡死或者弹出系统报错框时,再去附加到进程可能也不会晚的,也能查看到有用的信息。如果客户环境安全保密等级比较高,不能远程过去,没法使用Windbg去分析,可以在此时打开任务管理,在进程列表中找到目标进程,点击右键,在弹出的右键菜单中点击“创建转储文件”菜单项,将进程的全dump文件导出到文件中,然后取来这个dump文件再进行分析。

3.2、用Windbg启动目标程序

       有时程序异常崩溃发生在程序启动的时候,没法在启动程序后再将Windbg附加到进程上,因为已经晚了。此时可以使用Windbg启动程序,这样就能从程序启动时就去监测程序了。

       打开Windbg,点击菜单栏File->Open Executable...,如下所示:

 找到目标程序的路径打开目标程序即可。

3、分析实例说明

       为了讲述Windbg动态调试的完整过程,我们使用Visual Studio创建了一个基于MFC框架的对话框工程,在对话框中添加了一个测试按钮:

我们在测试按钮的相应函数中故意添加一段会 崩溃的代码:

// 添加的一段测试代码
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小地址内存块的异常,引发内存访问违例,导致程序发生崩溃闪退。

4、分析测试程序的崩溃

4.1、启动程序,将Windbg附加到目标进程上

        下面我们就以上面讲到的测试程序为例,讲解一下使用Windbg动态调试的完整过程。测试程序如下:

在Button1按钮的响应函数中故意添加了一段会引发崩溃的代码,代码上面已经给出并进行讲解了。

       启动程序后,打开Windbg,点击菜单栏File->Attach to a Process...,在进程列表中找到TestDlg.exe:

点击OK按钮即完成附加。

       完成附加操作后,Windbg会自动中断下来,如下所示:

目标程序会因Windbg的中断被挂起,此时目标程序是没法操作的。此时需要在最下面的命令输入框中输入命令g,按下回车键,执行该命令,让Windbg跳过该中断,让目标程序恢复运行。这样目标程序就可以操作了。

4.2、使用Windbg初步分析崩溃

       点击程序窗口的Botton1的响应函数,即去运行引发崩溃的代码,程序进而产生异常,Windbg会感知到异常,自动中断下来,如下所示:

 从输出的信息中可以看到,程序发生了Acess violation内存访问违例的异常,并可以看到崩溃时的eax、ebx等各个寄存器中的值,并能看到发生崩溃的那条汇编指令。

       程序最终是崩溃在某条汇编指令上,从汇编指令中我们可以看出是访问了0x0000000内存地址,在Windows系统中小于64KB的内存是禁止访问的。这块禁止访问的内存地址,是Windows系统故意预留的一块小地址内存区域,是为了方便程序员定位问题使用的。一旦访问到该内存区就会触发内存访问违例,系统就会强制将进程强制结束掉。

       关于64KB禁止访问的小地址内存区域,在《Windows核心编程》一书中内存管理的章节,有专门的描述,相关截图如下所示:

4.3、找到相关模块的pdb文件,设置到Windbg中

       仅仅通过查看崩溃时的各个寄存器的值以及发生崩溃的那条汇编指令是远远不够的,我们一般还需要查看崩溃时的函数调用堆栈。

       于是我们在Windbg中输入kn命令查看崩溃时的函数调用堆栈,如下所示:

查看函数调用堆栈的命令,除了kn之外,还有kv和kp,使用kv可以查看到调用函数时的参数信息,如下:

       从函数调用堆栈的最后一帧调用的函数来看,程序的崩溃是发生在TestDlg.exe文件模块中,不是其他的dll模块。显示的函数地址是相对TestDlg.exe文件模块起始地址的偏移,为啥看不到模块中具体函数名称呢?那是因为Windbg找不到TestDlg.exe对应的pdb文件,pdb文件中包含对应的二进制文件中的函数名称及变量等信息,Windbg加载到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文件的完整路径。,如上所示。

4.4、加载pdb后查看包含完整信息的函数调用堆栈

       加载到TestDlg.exe文件对应的pdb文件之后,我们再次执行kn命令就可以包含具体的函数名及及代码的行号信息了,如下:

 我们看到了具体的函数名CTestDlgDlg::OnBnClickedButton1,还看到了对应的代码行号312。通过这些信息,我们就能到源代码中找到对应的位置了,如下所示:

是访问了空指针产生的异常。当然上面的代码是我们故意这样写的,目的是为了构造一个异常来详细讲解如何使用Windbg进行动态调试跟踪的

4.5、设置C++源代码的路径,Windbg会自动跳转到源代码对应的行号上

      为了方便查看,我们可以直接在Windbg中设置C++源码路径,这样Windbg会自动跳转到源码对应的位置。点击Windbg菜单栏的File->Source File Path...,将源码路径设置进去:

然后Windbg会自动跳转到对应的函数及行号上:

      然后点击函数调用堆栈中每行最前面的数字超链接,就可以自动切换到对应的函数中。上图中的函数调用堆栈中很多模块是系统库中的,比如mfc100u、User32等,这些库是系统库,是没有源码的。我们可以点击最下面的第23个链接,其位于我们应用程序的模块中,会自动跳转到对应的代码中,如下:

4.6、有时需要查看函数中变量的值 

       有时我们在排查异常时,需要查看函数调用堆栈中某个函数中的变量值,去辅助排查。在某些场景下这种操作方式非常有用,我们在项目中多次使用过。可以查看函数中局部变量的值,也可以查看函数所在类对象的this指针指向的类对象中变量的值。我们要查看哪个函数,就点击函数调用堆栈中每一行前面的数字超链接,如下所示:

 我们看到了局部变量pShExeInfo 的值:

struct _SHELLEXECUTEINFOW * pShExeInfo = 0x00000000

我们可以点击this对象的超链接:

就能查看当前函数对应的C++类对象中成员变量的值,如下:

4.7、使用.dump命令导出包含异常上下文的dump文件

       比如问题出现在客户的电脑上,我们在使用Windbg初步分析后没找出引发崩溃的原因,我们可以使用.dump命令将包含异常上下文的信息导出到dump文件中,事后让客户将dump文件发给我们,我们在公司电脑做进一步分析排查。

       使用.dump /ma D:\\0625.dmp命令,将当前的异常信息保存到D:\\0625.dmp文件中,命令执行效果如下:

 会显示dump文件导出成功。

        这里有两个概念的区别,一个是mini dump文件,一个是全dump文件。我们将windbg附加到进程上使用.dump命令导出的dump文件,是全dump文件,全dump文件中包含了所有的信息,可以查看到所有变量的信息。另外通过任务管理器导出的dump文件:

也是全dump文件。全dump文件因为包含了所有的信息,所以会比较大,会达到数百MB,甚至上GB的大小。但如果通过安装在程序的异常捕获模块CrashReport导出的dump文件就是非全dump文件,是mini dump文件,大概只有几MB左右,因为异常捕获模块捕获到异常后,会自动导出dump文件,保存到磁盘上,如果都导出体量很大的全dump文件,很大量消耗用户的磁盘空间,所以我们会设置生成mini dump文件。

在异常捕获模块中我们是通过调用系统API函数MiniDumpWriteDump导出dump文件的,我们通过设置不同的函数调用参数去控制生成mini dump文件的。

Windbg等工具的下载链接如下:

链接:https://pan.baidu.com/s/1ID6_0RSYKbiy_tzfYDX3Ew 
提取码:tn6i

以上是关于通过Windbg动态调试去捕获C++软件异常的完整过程介绍的主要内容,如果未能解决你的问题,请参考以下文章

通过查看windbg中变量值去定位C++软件异常的又一典型案例分享

C++栈回溯原理

使用IDA查看汇编代码上下文去辅助排查C++软件异常问题

排查软件异常的常见思路与方法

Windows和Linux下排查C++软件异常的常用调试器与内存检测工具详细介绍

C++异常分析将Windbg附加到软件进程上排查异常闪退的问题