在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./?tinyfun,工具目录下VCMapper.exe。
另外上篇主要参考两篇文章:
http://www./document/viewdoc/?id=908
http://www./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./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
);