zoukankan      html  css  js  c++  java
  • 调试Release发布版程序的Crash错误(转)

    http://blog.sina.com.cn/s/blog_48f93b530100fsln.html

    在Windows平台下用C++开发应用程序,最不想见到的情况恐怕就是程序崩溃,而要想解决引起问题的bug,最困难的应该就是调试release版本了。因为release版本来就少了很多调试信息,更何况一般都是发布出去由用户使用,crash的现场很难保留和重现。本文将给出几个解决方案,完成对release版应用程序crash错误的调试。(本文只讨论Windows平台MSVC环境下的调试,对于其他平台和开发环境没有关注,请大家自己借鉴和尝试。)

        方案一:崩溃地址 + MAP文件

        这种方案只能对VC7以前的版本开发的程序使用。 

        1、崩溃地址

         所谓崩溃地址就是引起程序崩溃的内存地址,在WinXP下应用程序crash的对话框如下图:

     

     

     

        上面第2张图中画红线的值为crash的代码偏移地址,第3张图为即crash绝对地址;一般引起crash的原因多为内存操作错误,我们用这两个地址和MAP文件就能定位出错的代码行。

        2、MAP文件

        MAP文件是记录应用程序信息的文件(文本文件),里面大概包含了程序的全局符号、源码模块名、源码文件和行号等信息,而这些信息能够帮助我们定位出错的代码行。

        怎样生成MAP文件呢?以VC6为例,在 Project Settings -> C/C++ -> Debug info中,选择 Line Numbers Only ;在 Project Settings -> Link 中,选择 Generate mapfile项,并在Project Options 里面输入 /MAPINFO:LINES 和 /MAPINFO:EXPORTS,重新编译程序就会生成.map文件。

        以上设置对应的编译链接选项分别分:

        /Zi — 表示生成pdb调试信息;

        /MAP[:filename] — 表示生成map文件名;

        /MAPINFO:EXPORTS — 表示生成的map文件中加入exported functions(生成DLL文件时);

        /MAPINFO:LINES — 表示生成的map文件中加入代码行信息。

        由于/MAPINFO:LINES选项在VC8以后的版本中不再支持,因此通过MAP文件中的信息和crash地址定位出错代码行就比较困难了,所以这种方案只能在VC7及以前的版本中使用。

        一个MAP文件片段示例如下: 

          

        

        图中Rva+Base列的地址为该行函数对应的函数绝对地址,Address列中冒号后面的地址为函数相对偏移地址。   

        3、定位crash代码

        有了上面的介绍,定位crash代码就很简单了。用下面的公式来进行定位:

        崩溃行偏移 = 崩溃地址 - 崩溃函数绝对地址 + 函数相对偏移

        我们首先根据崩溃地址(绝对地址),按照找到第2张图中Rva+Base列的地址找到发生崩溃的函数(即崩溃地址大于该函数行的Rva+Base地址且小于下个函数的地址),然后找到该行对应的函数相对偏移地址,带入公式中,就得到了崩溃行偏移,该值表示崩溃行的代码相对于代码所在函数的偏移量。用该值去与第3张图中对应函数冒号后面的偏移量去比较,最接近的值前面的那个十进制数即为代码所在函数中的行号。

        ok,到此我们已经成功找到了崩溃的代码行,只不过这种方法还是比较费力,并且限制比较多,我们看看下面的方案。

    上篇给出的方案一还要补充几句。通过“crash地址 + MAP文件”来定位出错代码位置虽然需要经过比较复杂的地址计算,但却是最简单实现的方式。如果仅仅想通过崩溃地址定位出错的函数,就更加方便了。我在网上找到一个解析MAP文件的小工具,可以非常清晰的列出每个函数的地址,并且可以将分析表格导出为Excel文件。工具下载地址:http://e.ys168.com/?tinyfun,工具目录下VCMapper.exe。

        另外上篇主要参考两篇文章:

        http://www.vckbase.com/document/viewdoc/?id=908

        http://www.vckbase.com/document/viewdoc/?id=1473

        方案二:崩溃地址 + MAP文件 + COD文件

        由于VC8以后的版本都不再支持MAP文件中产生代码行信息,因此我们寻找另一种定位方式:COD文件。

        1、COD文件

        COD文件是一个包含了汇编码、二进制机器码和源代码对应信息的文件,每一个cpp都对应一个COD文件。通过这个文件,我们可以非常方便地进行定位。

        在VC6中生成COD文件的设置方式为:Project Settings -> C/C++,在 Category 中选 Listing Files,在 Listing file type 组合框中选 Assembly,Machine code,and source。在VC8中生成COD文件的设置方式为:Project Properties -> C/C++ -> Output Files -> Assembler Output 项,选择 Assembly,Machine code,and Source(/Facs)。

       

        2、定位崩溃行

        下面通过举例进行说明。现在我有一个基于对话框的MFC应用程序CrashTest,在CCrashTestDlg::OnInitDialog函数中写入导致crash的代码语句(第99行),源文件如下:

        

        根据崩溃地址(0x004012A3)以及MAP文件(定位片段图片如下),定位crash函数为OnInitDialog;并且我们可以很容易地计算出崩溃地址相对于崩溃函数的偏移量为 0x004012A3 - 0x004011E0 = 0xC3。

        

        再来看看CrashTestDlg.cod文件,我们根据文件中源码信息找到OnInitDialog函数信息片段:

        

        可以看到图片中第一行为OnInitDialog函数汇编代码的起始行;找到“int * p = NULL;”这一句源码,其前面的98表示这行代码在源文件中的行号,下面的000c1表示相对于函数开始位置的偏移量,后面的“33 c0”为机器码,“xor eax,eax”为汇编码。那么我们根据前面算出来的偏移量0xC3,找到对应出错的语句为99行:“*p = 5;”。

        总结一下定位步骤:

        1) 根据公式 崩溃语句在函数中偏移地址 = 崩溃地址 - 崩溃函数地址 计算出偏移量X;

        2) 根据公式 崩溃语句在COD文件中地址 = 崩溃函数在COD文件中地址 + X 计算出地址Y。其中崩溃函数在COD文件中地址为COD文件中函数起始括号“{”后面表明的地址,一般情况下为0x0000;

        3) 根据Y在COD文件中找到对应代码行。

       

        ok,方案二介绍完了。这种方法最大的好处是没有VC开发环境版本限制,而且COD文件里面包含的信息更加丰富,不但可以帮助我们定位crash,还能帮我们分析很多东西。当然,这也导致编译生成了很多信息文件。

    根据前面两篇博文,我们要定位崩溃行代码,必须要自己根据相关信息文件进行计算。如果需要处理的量比较大,恐怕会很费力气。有没有更简单快速的办法呢?

        最直接的想法就是写一个小工具,根据规则和信息进行自动定位,不过开发起来也是要费一番功夫的。令人开心的是,我们可以找到类似的工具,而且是开源免费的!程序员的世界也许很多时候都是这么单纯而乐于分享!

       

        方案三:崩溃地址 + PDB文件 + CrashFinder

        CrashFinder是一个开源工具,作者是John Robbin,大家可以去他的blog上去找关于CrashFinder的信息。我们这里以CrashFinder2.5版本为例介绍,相关文章链接为:http://www.wintellect.com/CS/blogs/jrobbins/archive/2006/04/19/crashfinder-returns.aspx

        1、PDB文件

        PDB(Program Database)文件中包含了exe程序所有的调试相关信息,具体可以查阅MSDN。当编译选项设置为/Zi,链接选项设置为/DEBUG,/OPT:REF时,就会生成工程的.pdb文件。具体到VC2005中,就是 Project Propertise -> C/C++ -> General -> Debug Information Format 项设置为 Program Database(/Zi),Linker -> Debugging -> Generate Debug Info 项设置为 Yes(/Debug),Linker -> Optimization -> References 项设置为 Eliminate Unreferenced Data(/OPT:REF)。

        只要设置以上选项,release版本也能生成PDB文件。当然,对应的应用程序也会稍大。

        2、CrashFinder

        CrashFinder能够运行需要两个条件:一是系统必须要有dbghelp.dll文件;二是PDB文件必须与exe文件在一个路径下。对于dbghelp.dll,一般在系统system32路径下都有,如果没有下载一个放到这个目录下就可以了。

        先看一下CrashFinder的界面。

       

     

        用起来也非常简单。首先选择File->New或点击工具栏新建按钮,选择要调试的exe文件打开,会发现exe及所依赖的dll文件信息都已经加载进来。在下半部分的编辑框中输入崩溃地址(16进制),点右边的“Find”按钮,就会在下面显示崩溃的源文件路径、名称以及崩溃所在行号了,如下图所示。

     

        用CrashFinder进行crash定位真的非常方便。但是我在使用过程中发现了一个bug,每次启动程序后,直接新建的话加载进来的exe模块都显示叉,提示找不到debug symbols。但是用打开按钮随便打开一个文件失败后,再新建就能成功。猜测可能是直接新建,定位PDB文件时的路径不对引起的。有源码,但是懒的看了呵呵,大家感兴趣可以试一下。

        好了,方案三就介绍到这里,后面还有更加强大的方案 : )

    前面几个方案都是直接定位crash的代码位置,但是在比较大型的程序中,只知道这个信息还是远远不够的,我们希望知道更多关于调用函数顺序及变量值等信息,也就是crash时调用堆栈信息。

        方案四:SetUnhandledExceptionFilter + StackWalker

        这个方案需要自己动手往工程里添加代码了。要实现上面的想法,需要做两件事情:1、需要在crash时有机会对程序堆栈进行处理;2、对堆栈信息进行收集。

        1、SetUnhandleExceptionFilter函数

        Windows平台下的C++程序异常通常可分为两种:结构化异常(Structured Exception,可以理解为与操作系统相关的异常)和C++异常。对于结构化异常处理(SEH),可以找到很多资料,在此不细说。对于crash错误,一般由未被正常捕获的异常引起,Windows操作系统提供了一个API函数可以在程序crash之前有机会处理这些异常,就是SetUnhandleExceptionFilter函数。(C++也有一个类似函数set_terminate可以处理未被捕获的C++异常。)

        SetUnhandleExceptionFilter函数声明如下:

        LPTOP_LEVEL_EXCEPTION_FILTER WINAPI SetUnhandledExceptionFilter(
          __in          LPTOP_LEVEL_EXCEPTION_FILTER lpTopLevelExceptionFilter
        );

        其中 LPTOP_LEVEL_EXCEPTION_FILTER 定义如下:

        typedef LONG (WINAPI *PTOP_LEVEL_EXCEPTION_FILTER)(
            __in struct _EXCEPTION_POINTERS *ExceptionInfo
        );
        typedef PTOP_LEVEL_EXCEPTION_FILTER LPTOP_LEVEL_EXCEPTION_FILTER;

        简单来说,SetUnhandleExceptionFilter允许我们设置一个自己的函数作为全局SEH过滤函数,当程序crash前会调用我们的函数进行处理。我们可以利用的是 _EXCEPTION_POINTERS 结构类型的变量ExceptionInfo,它包含了对异常的描述以及发生异常的线程状态,过滤函数可以通过返回不同的值来让系统继续运行或退出应用程序。

        关于 SetUnhandleExceptionFilter 函数的具体用法和示例请参考MSDN。

        2、StackWalker
        现在我们已经有机会可以在crash之前对程序状态信息进行处理了,只需要生成并保存堆栈信息就大功告成了。Windows的dbghelp.dll库提供了一个函数可以得到当前堆栈信息:StackWalk64(在Win2K以前版本中为StackWalk)。该函数声明如下:

        BOOL WINAPI StackWalk64(
          __in          DWORD MachineType,
          __in          HANDLE hProcess,
          __in          HANDLE hThread,
          __in_out      LPSTACKFRAME64 StackFrame,
          __in_out      PVOID ContextRecord,
          __in          PREAD_PROCESS_MEMORY_ROUTINE64 ReadMemoryRoutine,
          __in          PFUNCTION_TABLE_ACCESS_ROUTINE64 FunctionTableAccessRoutine,
          __in          PGET_MODULE_BASE_ROUTINE64 GetModuleBaseRoutine,
          __in          PTRANSLATE_ADDRESS_ROUTINE64 TranslateAddress
        );
        该函数的具体用法可以参考MSDN。在这里推荐一个牛人写好的StackWalker,可以直接拿来用,开源的。StackWalker提供了一个基类,给出了几个简单的接口,可以方便地生成堆栈信息,并且支持一系列VC版本,非常好用。我们可以自己写一个子类,并重载虚函数OnOutput,就可以将堆栈信息输出为特定格式了。StackWalker的地址为:http://www.codeproject.com/KB/threads/StackWalker.aspx

        不过对于Release版本来说,StackWalk64函数获得的堆栈信息有可能不完整。如果异常是由MFC的模块抛出,那么获得的堆栈可能缺少前面调用模块信息。另外,StackWalk64需要最新的dbghelp.dll文件支持才能工作;要正确输出crash的函数名和行号,需要要pdb文件支持。以上不足有可能影响输出信息的完整性和效果,而对于发布在外的程序,要带上pdb文件几乎不可能,因此这个方案还是有缺憾的,比较适用于本地的release版本调试。

        下一篇我们将介绍一个更加完善的解决方案

    当我们把自己的release版本程序发布出去以后,一般都是在用户的机器上运行。这种情况下,对于第四种方案,因为需要pdb文件才能够正确生成堆栈调用的函数行号及代码行号,因此方案四只适用于本地release版的调试,否则只能生成不完整的堆栈信息。对于前三种方案,其实只需要用户告知崩溃地址,然后在本地查找crash地址就可以了,但是定位crash的过程非常不方便,如果crash的情况比较多,前三种方案都不合适。而且,前三种方案均不能生成堆栈调用信息,对于debug的作用有限。

        下面我们就来看一个更加完善的解决方案。

        方案五:SetUnhandledExceptionFilter + Minidump

        SetUnhandleExceptionFilter函数我们已经介绍过了,本方案的思路还是要利用我们自己的异常处理函数,来生成minidump文件。

        1、Minidump概念

        minidump(小存储器转储)可以理解为一个dump文件,里面记录了能够帮助调试crash的最小有用信息。实际上,如果你在 系统属性 -> 高级 -> 启动和故障恢复 -> 设置 -> 写入调试信息 中选择“小内存转储(64 KB)”的话,当系统意外停止时都会在C:WindowsMinidump路径下生成一个.dmp后缀的文件,这个文件就是minidump文件,只不过这个是内核态的minidump。

       我们要生成的是用户态的minidump,文件中包含了程序运行的模块信息、线程信息、堆栈调用信息等。而且为了符合其mini的特性,dump文件是压缩过的。

        2、生成minidump文件

        生成minidump文件的API函数是MiniDumpWriteDump,该函数需要dbghelp.lib支持,其原型如下:

        BOOL WINAPI MiniDumpWriteDump(
          __in          HANDLE hProcess,
          __in          DWORD ProcessId,
          __in          HANDLE hFile,
          __in          MINIDUMP_TYPE DumpType,
          __in          PMINIDUMP_EXCEPTION_INFORMATION ExceptionParam,
          __in          PMINIDUMP_USER_STREAM_INFORMATION UserStreamParam,
          __in          PMINIDUMP_CALLBACK_INFORMATION CallbackParam
        );

        在我们的异常处理函数中加入以下代码:

        HANDLE hFile = ::CreateFile( _T("E:\dumpfile.dmp"), GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
         if( hFile != INVALID_HANDLE_VALUE)
         {
             MINIDUMP_EXCEPTION_INFORMATION einfo;
             einfo.ThreadId = ::GetCurrentThreadId();
             einfo.ExceptionPointers = pExInfo;
             einfo.ClientPointers = FALSE;

            ::MiniDumpWriteDump(::GetCurrentProcess(), ::GetCurrentProcessId(), hFile, MiniDumpNormal, &einfo, NULL, NULL);
            ::CloseHandle(hFile);
         }

        其中,pExInfo变量为异常处理函数PEXCEPTION_POINTERS类型的参数。具体请参考MSDN。

        3、调试minidump

        调试dump文件首先需要pdb文件,因此我们build程序时需要设置 Debug Infomation Format 为 “Program Database(/Zi)”。其次,我们还要确保所用的dump文件与源代码、exe、pdb文件版本是一致的,这要求我们必须维护好程序版本信息。

        调试minidump最方便的环境就是VS了,我们只要将.dmp、.exe、.pdb文件放在一个路径下,保证源代码文件的路径与编译时的路径一致就可以了,剩下的就是VS帮我们完成。双击.dmp文件或者在文件打开工程中选择“dump files”,加载dump文件,然后按F5运行就能直接恢复crash时的现场了,你可以定位crash的代码,可以查看调用堆栈,可以查看线程和模块信息...一切都跟你设置断点调试一样,太强大了!看个截图吧。

        需要注意的是,对于release版的程序来说,很多代码是经过编译器优化过的,因此定位的时候可能会有所偏差,大家可以考虑设置选项去掉代码优化。

        其他可以调试minidump的工具还有WinDbg等,大家可以查阅相关资料。

        本文主要参考了这篇文章:http://vicchina.51.net/research/other/seh/minidumps/intro.htm

        下一篇,我们将给出一个调试release发布程序的完美解决方案,适合用户量较大的应用发布程序的调试。

    上一篇我们已经给出了方案,能够非常方便的通过dump文件对crash错误进行调试和定位;从整个流程上看还差最后一步,即怎样拿到crash时产生的dump文件。如果可以让用户把文件发送过来自然不错,但对于类似免费共享软件等在互联网上发布的程序呢?我们的用户是不确定的,而且用户量有可能非常大,即使我们能想办法联系到用户,总不能挨个去收集crash信息吧。

        我们需要一种方案,能够提供crash信息汇报功能。

        我们可以架设一台服务器专门进行信息收集,只要客户端在crash时正确汇报即可,但是相应的维护成本和开发难度也不可忽视。有没有更简单的方法呢?还记得我的博文“为程序添加自动发送Email功能”吗?这就是简单有效的方法!

        方案六:minidump + email

        我们只需要在异常处理时,先生成minidump信息文件,再用email方式将文件发送到指定邮箱就行了。剩下的就是我们每天查看邮箱,提取dump文件进行调试了。

        1、Email功能

        首先我们来看一下email发送都需要哪些相关信息。

        a、发送端邮箱帐户;

        b、接收端邮箱帐户;

        c、email标题,一般应有软件名称及版本信息;

        d、email正文,一般应有简单的crash信息提示,以区别不同原因造成的crash;

        e、email附件,当然就是我们的dump文件了,还可以加上软件生成的log文件等。

        当然,对于标题应该尽量多加一些信息区别引起crash的原因,比如将crash的地址信息加到标题中;因为当每天有成百上千的crash汇报上来,重复的crash占大多数,把时间都花在区分它们身上有点太浪费。由此看来,前面方案中提到的StackWalker还是有些用处的,我们可以用它来生成一些crash的文字描述信息,写到标题或正文中去。

        dump文件的大小是否适合作为邮件的附件呢?实际上minidump产生的文件一般在几K到几十K之间,作为email的附件没有任何问题。

        关于发送email相关技术细节,已经在“为程序添加自动发送Email功能”文中介绍了,大家可以参考。其实,对接受邮箱中邮件的处理还是很费时费力的,大家可以考虑写一些脚本将处理流程自动化,提高效率。

        2、google breakpad

        google breakpad是一个开源的跨平台crash report系统,光从开源和跨平台这两个特点上来看,它就足以称的上是一个完善而有效的工具了。其实,breakpad在整个crash report层次上给出了一个系统级的解决方案,也就是说它几乎能适应各种软件、各种平台的应用要求。

        breakpad的整体思路跟上面介绍的方案是相似的,只不过最后提交dump文件的方式更加完善。大家有兴趣可以去它的官方网址查阅相关资料:http://code.google.com/p/google-breakpad/

        ok,关于调试release发布程序的crash错误系列文章就写完了。这几篇文章给出的方案由简单到复杂,由简陋到完善,对crash调试有了一个比较全面的总结。当然,其中涉及到的概念和技术还很多,需要我们去不断学习和领悟,也希望大家能够互相交流。

  • 相关阅读:
    三元表达式 列表和字典推导式 函数对象 名称空间 作用域 global和nonlocal 函数装饰器 枚举对象
    函数参数 打散机制 字符串比较 返回值
    函数简介
    三种字符串的介绍 文件的读写
    字符编码
    数据类型及其常用方法 数据类型转换 可变与不可变 值拷贝与深浅拷贝
    流程控制 while和for循环
    变量命名规范 常量 输入和输出 注释 数据类型 运算符 逻辑运算符
    语言分类 编译型和解释型语言分析 环境变量 代码执行的方式 pip介绍 变量
    Python django tests
  • 原文地址:https://www.cnblogs.com/kungfupanda/p/9006037.html
Copyright © 2011-2022 走看看