定位window程序Crash常用工具和方法

时间:2022-08-31 17:17:21
一、引言
  任何程序正确则只有一种结果,但是错误却有千万种,而众多的错误有些是可容忍,有些则是致命的,如除零错误、堆栈溢出、内存越界等导致程序Crash。由于很多错误并不是发生在开发工作者调试阶段,而是在用户或测试工作者使用阶段;这就需要相关代码维护工作者对于程序异常捕获收集现场信息。
  当收集相关信息后,如何定位这些错误是的极为讲究的过程,工具和方法使用得当则可事半功倍,反之事倍功半,所谓工欲善其事,必先利其器,本文将讲述一些定位Windows客户端程序Crash的工具(当然这些工具不仅仅只能定位Crash问题,同时本文不详细描述工具的详细使用帮助)和方法,对定位相关问题,提供一些借鉴意义,使之能够少走弯路,快速定位问题、解决问题。
二、符号文件
  发生Crash的时机并不总存在于开发机器,即时发生在开发机器上面也不一定存在于调试状态下;当Crash不是发生在调试状态下,如果需要迅速搭建一个调试环境,这离不开符号文件。
  符号文件(Symbol Files)是一个数据信息文件,它包含了应用程序二进制文件(比如:EXE、DLL等)调试信息和项目状态信息,使用这些信息可以对程序的调试配置进行增量链接,最终生成的可执行文件在运行时并不需要这个符号文件,但你的程序中所有的变量信息都记录在这个文件中。
  在 Windows 系统中,符号文件以 .PDB(程序数据库Program Database)为扩展名,比如:每个 Windows 操作系统下有一个 GDI32.dll 文件,编译器在编译该 DLL 的时候会产生一个 GDI32.pdb 文件,一旦你拥有了这个 PDB 文件,那么便可以用它来调试并跟踪到 GDI32.dll 内部。该文件和二进制文件的编译版本密切相关,比如修改了 DLL 的输出函数(哪怕是没有何人修改),再编译该 DLL,那么原先的 PDB 文件就过时了,不能再用老的 PDB 文件来做调试工作,而必须使用最新的 PDB 文件版本。
  如何生成符号文件呢,下面以VC6为例,说明如何生成一份PDB文件。
  首先在菜单“Project”->子菜单“Project Settings”的选项卡“C/C++”中,将“Debug info”设置为“Program Database”

定位window程序Crash常用工具和方法


  其次在菜单“Project”->子菜单“Project Settings”的选项卡“Link”中,将“Genrate debug info”勾选

定位window程序Crash常用工具和方法


  然后在菜单“Project”->子菜单“Project Settings”的选项卡“Link”中,将“Category”选择为“Customize”,指定PDB文件输出路径和名称

定位window程序Crash常用工具和方法


  最后保存好相应的PDB文件
三、dump文件的获取方法
  对于定位Crash问题,特别是非调试环境下的Crash问题,这就需要采取一些方法用来获取异常退出是的dump信息,常见方法如下
  1. 使用SetUnhandledExceptionFilter函数,设置最高一级的异常处理函数,当程序出现任何未处理的异常,都会触发你设置的函数里,然后在异常处理函数中获取程序异常时的调用堆栈、内存信息、线程信息等。
  2. 使用WinDBG工具,当程序运行后,使用WinDBG工具Attach这个进程,当程序异常时,则被WinDBG捕获,使用工具相关命令导出dump文件或直接调试。
  3. 当程序运行后,使用脚本解释程序Script.exe执行WinDBG下面的adplus.vbs脚本,当程序异常退出后,则会在WinDBG目录下面生成dump文件。
四、WinDBG
  相对于Windows程序员来说,掌握WinDBG(Debugging Tools for Windows)工具的使用,可大大提升客户端开发工作者定位问题能力。
  WinDBG是微软公司提供的免费的Windows平台的调试工具。适用于Win2000以上的操作系,WinDBG可以在没有安装开发环境的机器上快速搭建一个调试环境。
  当开发工作者收集到一份Crash的dump文件后,并获取到相应符号文件(即按照上述描述方法生成的pdb文件)使用WinDBG打开相应dump文件,并在菜单“File”->“Symbols File Path”中设定好pdb文件所在的目录

定位window程序Crash常用工具和方法


  此时即刻得到下面类似的界面,然后在红圈内输入调试命令,如.ecxr、Kb之类命令则可以查看Crash时的Call Stack、内存信息等,如下面程序定位该局cout << 0x7fff / I 出现Crash

定位window程序Crash常用工具和方法


  然后查看相应内存信息

定位window程序Crash常用工具和方法


  发现是I = 0,出现除0错误,导致程序Crash
五、AQTime
  AQTime是AutomatedQA的产品,主要运用于性能分析和内存调试等。通过AQTime不仅可以得知其项目中确实存在bug和瓶颈,而且会知道具体到哪个模块、类、线程、代码行出了问题,从而快速消除错误。
  本文不涉及使用AQTime检测程序性能问题,主要谈及对Crash检测问题。
  1. 将生成的pdb文件与即将执行的exe文件放置于同一个目录下面
  2. 启动AQTime,同时新建一个Module,将所需测试的exe加入Module

定位window程序Crash常用工具和方法


  3. 然后点击菜单“Run”->“Run”,如果程序出现异常,则可在Event View窗口中则可查看到发生异常时的记录,如下例子表现为程序递归调用出现Bug,发生堆栈溢出问题

定位window程序Crash常用工具和方法


六、Application Verifier
  Application Verifier 是微软的代码验证工具,被设计为检测程序运行内存错误和其他严重的安全隐患,可以找出在正常程序代码检测中难以察觉的错误;该工具的使用也非常简单:运行程序读入代码即可,很快就能生成一个log,相关人员根据log分析相关代码即可。
  1. 使用者将需要测试的实例添加Application Verifier,并点击保存按钮

定位window程序Crash常用工具和方法


  2. 此时运行相应进程,如果程序发生异常,则点击Application Verifier的菜单“View”->“Log”,查看是否有发生错误的log日志

定位window程序Crash常用工具和方法


  3. 查看发生错误的日志,如发生Crash例子显示错误信息如下

Access violation exception.

3cc - Invalid address causing the exception

40350c - Code address executing the invalid access

12fcac - Exception record

12fcc8 - Context record

vrfcore!VfCoreRedirectedStopMessage+81

kernel32!UnhandledExceptionFilter+fb

Ex!_XcptFilter+2e

Ex!mainCRTStartup+10f

kernel32!RegisterWaitForInputIdle+49


根据log日志提供的12fcac - Exception record和12fcc8 - Context record信息,结合map文件,则可发现程序发生越界访问
void main()
{
  getch();
  int arrData[1000];
  for (short i = 0; i <= sizeof(arrData)/ sizeof(arrData[0]); ++i)
  {
    arrData = i;
  }
  cout << arrData[1000];
}
七、Purify
  IBM Rational Purify是由IBM开发一款用于对C/C++源程序中存在的内存问题进行勘察和分析,并且提供了有关的实例以便读者在实际操作中作为参考的工具。
  程序运行时的分析可以采用多种方法。Purify使用了具有专利的目标代码插入技术(OCI:Object Code Insertion)。她在程序的目标代码中插入了特殊的指令用来检查内存的状态和使用情况。这样做的好处是不需要修改源代码,只需要重新编译就可以对程序进行分析。
如下例子
   #include 
   using namespace std;
   int main(){
      char* str1="four";
      char* str2=new char[4];
      char* str3=str2;
      cout<<str2< 
      strcpy(str2,str1);
      cout<<str2<<endl;
      delete str2;
      str2[0]+=2;
      delete str3;
   }
  当编译完成后,并生成pdb,将pdb放置于应用程序同级目录,然后将需要测试的程序添加入Purify,点击“Run”,程序在运行过程中,可以在右侧窗口中不断看见相应信息输出,相关人员查看相关输出是否是有问题。

定位window程序Crash常用工具和方法


  如上述例子,则可发现发生数组越界访问。
八、BoundChecker
  BoundChecker工具对于大多数VC开发工作者来说均不陌生,该工具采用目标代码插入技术,主要应用于检测内存、资源等泄漏和内存操作操作等问题,该工具主要针对开发者使用,开发者在使用阶段使用BoundChecker编译,代码并运行,检查代码是否存在隐患导致程序出现运行异常或运行时错误。
  使用BoundChecker编译并在Debug模式下运行则可发现一些异常Errors或内存的错误操作。

定位window程序Crash常用工具和方法


  该工具存在一定的局限性,主要只能使用在开发调试阶段。
九、重载内存分配释放符
  程序发生异常其中很大肯能是因为内存的错误访问或使用所致,总的说来,与内存有关的问题可以分成两类:内存访问错误和内存使用错误。内存访问错误包括错误地读取内存和错误地写内存。错误地读取内存可能让你的模块返回意想不到的结果,从而导致后续的模块运行异常。错误地写内存可能导致系统崩溃。内存使用方面的错误主要是指申请的内存没有正确释放,从而使程序运行逐渐减慢,直至停止。这方面的错误由于表现比较慢很难被人工察觉。程序也许运行了很久才会耗净资源,发生问题,对于这类问题开发工作者可以重载开发工具提供的内存分配释放操作,加入一些定位手段,用于捕捉内存有关问题,如下例程所示:
重载new操作
void * operator new (unsigned int size) 
{
       //创建的页面个数
       size += sizeof(unsigned int);
       int page_num = (int) ( (size + 4096 - 1) / 4096 );
       //偏移量
       unsigned int offset = page_num * 4096 - size + sizeof(unsigned int);
       //在内存块后面创建一个额外的保护页面,并且将页面的属性设置为不可读写
       unsigned int allocsize = (page_num + 1) * 4096;
       void *p = VirtualAlloc(NULL, allocsize, MEM_COMMIT, PAGE_EXECUTE_READWRITE);
       *((unsigned int *)p) = allocsize;
       //定位最后一个保护页面的地址
       void *pchecker = (char *)p + page_num * 4096;
       //设置最后一页为不可读写
       DWORD old_value;
       VirtualProtect(pchecker,4096,PAGE_NOACCESS,&old_value);
       //OutputDebugString("my new ") ;
       return (char *)p + offset;
}
void main()
{
       int * p = new int[10];
       p[10] = 2;
       delete [] p;
}
  分配数组时在数组尾部多分配一段内存,并将该段内存设置为不可访问,当数组访问越界,则程序执行到p[10] = 2语句时,将发生错误,如果完善一下new的重载,则可以知道发生异常访问所在的文件和位置。
  另外如果发生内存泄漏,也可根据分配和释放的记录检查是否发生内存泄漏。
  上述方法也存在一定局限性,只能运用于开发调试阶段。
十、规范日志
  程序Crash极可能是我们自身业务运行逻辑出现问题,不是按照想象的方式运行,导致数据发生同步问题而导致程序异常问题,如
Class::FuncA()
{
      Lock();
      OutputDebugString(“Class::FuncA: 修改M_oVar之前”);
      OperationA(M_oVar);
      OutputDebugString(“Class::FuncA: 修改M_oVar之后”);
      Unlock();
}
Class::FuncB()
{
      Lock();
      OutputDebugString(“Class::FuncB: 修改M_oVar之前”);
      OperationB(M_oVar);
      OutputDebugString(“Class::FuncB: 修改M_oVar之后”);
      Unlock();
}
Class::FuncC()
{
      Lock();
      OutputDebugString(“Class::FuncC: 修改M_oVar之前”);
      OperationB(M_oVar);
      OutputDebugString(“Class::FuncC: 修改M_oVar之后”);
      Unlock();
}
  其中主线程
Mainthread()
{
      Class::FuncA();
Class::FuncB();
}
  工作线程
Workthread()
{
      Class::FuncC();
}
  如果我们本来要求AB需要同时执行,也就是需要线程安全访问执行,表面上看AB两个函数都是线程安全的,但是日志却会出现
Class::FuncA: 修改M_oVar之前
Class::FuncA: 修改M_oVar之后
Class::FuncC: 修改M_oVar之前
Class::FuncC: 修改M_oVar之前
Class::FuncB: 修改M_oVar之前
Class::FuncB: 修改M_oVar之后
与我们想象的执行顺序
Class::FuncA: 修改M_oVar之前
Class::FuncA: 修改M_oVar之后
Class::FuncB: 修改M_oVar之前
Class::FuncB: 修改M_oVar之前
Class::FuncC: 修改M_oVar之前
Class::FuncC: 修改M_oVar之后
  完全不一致,通过完善的日志即可看出工作流程是否,可以方便定位发生错误问题。
  要想通过日志分析流程正确否,则需要规范代码中的日志格式和内容,比如日志中需要包含输出时间,当前线程号,所属模块号/类名称,函数名称,当前关键变量的值等信息。
十一、代码回溯
  当无法从当前代码发现是否存在问题时,可以从最近版本中找出一个不存在相应问题和存在相应问题的两个相邻版本,通过比较代码,检查是那块代码引入,这样缩小范围后可重点排查。
  该方法简单易行,但是如果相邻两个版本改动幅度较大,则变为不可实施。
十二、功能/模块屏蔽
  如果功能/模块分配合理,则可将一块块模块或功能屏蔽,确定是何种模块或功能出现问题,类似代码回溯,重点突出,可该方法同样简单易行,但是如果模块或功能耦合比较严重,则可实施性不强。
十三、模块替代
  当怀疑某个模块存在问题,但是又无法定位或不方便定位,此时可以采用另外的具有同样功能的模块来检查验证猜想是否正确。
十四、专家咨询
  汉高祖曾经说过:夫运筹帷幄之中,决胜千里之外,吾不如子房;镇国家,抚百姓,给饷馈,不绝粮道,吾不如萧何;连百万之众,战必胜,攻必取,吾不如韩信。三者皆人杰,吾能用之,此吾所以取天下者也。项羽有一范增而不用,此所以为我所禽也。
  上述古人语在当今同样有意义,能够善于利用周边资源,包括咨询周边专家,,快速定位解决问题同样值得可取,这一点可能是众多开发工作者所不愿取的,合适时机、合适方法、放下文无第一,武无第二的心态,求教经验丰富的同事,同样可以事半功倍。
  例如前段时间,旋风经常在STL的string类中的分配释放内存是不定时间不定位置Crash,后咨询相关专家,很快发现了VC6提供string类的缺陷所致问题。
十五、PCLnt
  代码编写者如果书写不够规范、可读性差,这样非常容易引入BUG,代码编写调试完成后,可以使用当前成熟的工具PCLnt检查代码是否存在变量没有赋初值,访问是否越界;由于代码是静态检查,只能起到辅助作用,不能发现运行时错误信息。

定位window程序Crash常用工具和方法


十六、代码Review
  代码开发工作者开发功能由于很大程度是黑盒测试(我们公司可能绝大部分产品如此),这样很多问题就依赖开发人员的水平了,但是团队人员多了之后,水平参差不齐,再加上不断的开发任务,很多隐藏的BUG可能就成为一颗定时炸弹,如linux前段时间发现了一个存在10多年的BUG。
  为了保证代码质量,相同项目组应定期Review代码,特别是出现Crash后,应集中人力,安排专门的时间,大规模Review代码,尽可能将Bug现形。
总结
  程序出现Bug乃至Crash,均不可怕,可怕是不能找到快速的定位手段,上述也只是就旋风定位Crash时使用到的一些手段,希望能够对以后的定位所有帮助。
  总之,最重要的还是不断熟悉自身所维护代码,阅读高手所写代码,同时不断充电查看资料,勤修内功,尽可能使自己免于充当Bug制造者。
</str2<<endl;
</str2<