以文本方式查看主题

-  计算机科学论坛  (http://bbs.xml.org.cn/index.asp)
--  『 C/C++编程思想 』  (http://bbs.xml.org.cn/list.asp?boardid=61)
----  C++异常中的堆栈跟踪  (http://bbs.xml.org.cn/dispbbs.asp?boardid=61&rootid=&id=37287)


--  作者:卷积内核
--  发布时间:8/28/2006 12:39:00 PM

--  C++异常中的堆栈跟踪

C++语言的运行时环境是基于栈的环境,堆栈跟踪(trace stack)就是程序运行时能够跟踪并打印所调用的函数、变量及返回地址等,C++异常中的堆栈跟踪就是当程序抛出异常时,能够把导致抛出异常的语句所在的文件名和行号打印出来,以及把调用抛出异常的语句的函数以及其它上层函数信息都打印出来。
1. 为什么需要堆栈跟踪
当你在开发程序时,你是否曾遇到过程序运行过程中突然当机,而你不知道哪一行代码出的问题;你是否曾遇到过程序调试过程中突然抛出异常,而你不知道哪一行代码出的问题;你是否曾遇到过当你在单步调试时突然抛出异常而你却忘了单步执行到哪一步时抛出的异常,于是你只好重来一次。Beta程序在客户那里试运行当中,突然当机,而你不能调试,只能依据客户报告的一些信息来找bug,而客户大多不熟悉程序开发,所以他们报告的信息太少使你感觉无从下手、一筹莫展。
如果你碰到过以上情况,你就只好痛苦地一条一条单步执行语句,看抛出异常的语句在哪,检查非法访问内存的语句在哪里,糟糕的是根据海森堡不确定原理,有时当你调试时又不出问题了。所以幸运的话,你能很快就找到bug,不幸的话,几小时或几天都不能找出问题所在,并将成为你的梦魇。我在程序开发过程中,就经常碰到以上这些情况。
众所周知,在程序开发中发现一个bug将比改正这个bug难度大很多。所以如果有一个方法能够在程序出错时把出错信息打印出来,这样将大大方便找到bug,加快程序开发速度,提高程序的质量。这样,当客户报告程序出错时,你只需要客户把日志发送给你,你根据这个日志里的异常堆栈信息就能轻松发现问题所在。
在java中就有堆栈跟踪功能,它能在程序抛出异常时,能够打印出能够把导致抛出异常的语句所在的文件名和行号,C#中也有这个功能。很多人认为用java开发程序比用C++开发程序要快,我认为java有抛出异常时能够跟踪堆栈这个功能是其中的一个重要原因。

2. 如何实现C++异常中的堆栈跟踪
要实现堆栈跟踪,必须依赖于底层机制即操作系统或虚拟平台,java与jvm虚拟平台绑定,C#与.NET虚拟平台绑定,它们都提供了堆栈跟踪的功能,而C++与操作系统或平台无关,所以没有提供这个功能,但是否能够利用操作系统的系统函数实现这个功能呢?下面简要介绍如何在Windows2000下实现C++异常中的堆栈跟踪。
在Windows中,C++异常底层的实现是通过Windows中的结构化异常SEH来实现的,结构化异常包括如除0溢出、非法内存访问、堆栈溢出等,虽然用catch( … )能够捕获结构化异常,但不能知道是哪种结构化异常,所以第一步就是要把结构化异常转化为C++异常,Windows中的_set_se_translator()函数可以实现这个功能。先建立一个转化函数:void _cdecl TranslateSEHtoCE( UINT code, PEXCEPTION_POINTERS pep ) ;在这个转化函数中抛出一个继承C++标准异常的类,如CRecoverableSEHException(可以恢复的结构化异常类)和CUnRecoverableSEHException(不可以恢复的结构化异常类),这两个类继承CSEHException,CSEHException继承标准C++异常的基类exception。然后在main函数开始处调用 _set_se_translator(TranslateSEHtoCE ),这样就可以把结构化异常转换为C++异常。
另外,由于VC中默认new失败时并不抛出异常,所以需要让new失败时抛出异常,这样可以统一处理,可以使用WINDOWS中的_set_new_handler( )转化,让new失败时抛出异常。同上,先建立一个转化函数 int NewHandler( size_t size ),在这个转化函数中抛出C++标准异常的类bad_alloc,在main函数开始处调用 _set_new_handler (NewHandler)。
接着在CSEHException的构造函数中跟踪堆栈,把导致抛出结构化异常的语句所在的文件名和行号打印出来,调用void ShowStack( HANDLE hThread, CONTEXT& c )。ShowStack函数封装了跟踪堆栈所需调用的各种系统API。它的功能就是根据参数c(线程的上下文),得到当前程序的路径,枚举所调用的系统动态连接库,然后按照从里到外的顺序打印出所有执行的函数名及其所在的文件名和行号。
创建自己的异常类使其具有堆栈跟踪的功能,定义自己使用的异常基类如CMyException(当然,如果你愿意,你可以修改其命名),令其继承标准C++异常类domain_error(当然也可以继承exception),然后在CMyException的构造函数中调用void ShowStack( HANDLE hThread, CONTEXT& c ),这样就可以实现堆栈跟踪,其它自定义的异常继承CMyException,就自动获得堆栈跟踪的功能。这样就形成了一个完整的类层次。

    exception

logic_error    runtime_error   
length_error
    out_of_range          bad_alloc          bad_cast            range_error
     invalid_argument        bad_exception                overflow_error
       domain_error                 ios_base:failure     underflow_error
    
CMyException(自定义异常基类)      CSEHException(结构化异常基类)      
         
                          CRecoverableSEHException   CUnRecoverableSEHException

CSocketException(与socket相关的异常)   
CConfigException(与配置文件相关的异常)
注:CMyException上面的异常类均为标准C++的异常类。
注:以上异常类的基类均为exception。


本人实现的具有堆栈跟踪的C++异常类库和测试程序可以从www.smiling.com.cn中的umlchina小组中下载StackTraceInC.zip文件。
3. 如何使用C++异常中的堆栈跟踪类库
下载的文件包括Exception.h Exception.cpp(具有堆栈跟踪功能的异常类库), main.cpp, Test1.h, Test1.cpp (测试代码)。
让我们先感受一下堆栈跟踪的威力,运行下载的示例程序,将打印出如下结果(因为输出太长,所以只节选了其中一部分)。主程序为:
void main(){

// 在每个线程函数的入口加上以下语句。
// 检查内存泄露。
CWinUtil::vCheckMemoryLeak();
// 使new函数失败时抛出异常。
CWinUtil::vSetThrowNewException();
// 把WINDOWS中的结构化异常转化为C++异常。
CWinUtil::vMapSEHtoCE();
// 初始化。
CWinUtil::vInitStackEnviroment();
try {
  // 捕获非法访问内存的结构化异常。
  int* pInt;  // 故意不分配内存
  *pInt = 5;  // 应该显示出这一行出错。
}
// 捕获可恢复的结构化异常。
catch ( const CRecoverableSEHException &bug ) {
  cout << bug.what() << endl;
}
// 捕获不可恢复的结构化异常。
catch ( const CUnRecoverableSEHException &bug ) {
  cout << bug.what() << endl;
}
// 捕获标准C++异常。
catch ( const exception& e ) {
  cout << e.what() << endl;
}
// 捕获其它不是继承exception的异常。
catch ( ... ) {
  cout << " else exception." << endl;
}
try {
  // 捕获自定义的异常。
  throw CMyException( " my exception" ); // 应该显示出这一行出错。
}
catch ( const CRecoverableSEHException &bug ) {
  cout << bug.what() << endl;
}
catch ( const CUnRecoverableSEHException &bug ) {
  cout << bug.what() << endl;
}
catch ( const exception& e ) {
  cout << e.what() << endl;
}
catch ( ... ) {
  cout << " else exception." << endl;
}
try {
  // 捕获函数中的异常。
  vDivideByZero(); // 应该显示出这个函数抛出的异常。
  int i = 1;
}
catch ( const CRecoverableSEHException &bug ) {
  cout << bug.what() << endl;
}
catch ( const CUnRecoverableSEHException &bug ) {
  cout << bug.what() << endl;
}
catch ( const exception& e ) {
  cout << e.what() << endl;
}
catch ( ... ) {
  cout << " else exception." << endl;
}
try {
  // 捕获另一源文件Test1.cpp中的函数抛出的异常。
  vTestVectorThrow();// 应该显示出在这个函数抛出的异常。
  int i = 1;
}
catch ( const CRecoverableSEHException &bug ) {
  cout << bug.what() << endl;
}
catch ( const CUnRecoverableSEHException &bug ) {
  cout << bug.what() << endl;
}
catch ( const exception& e ) {
  cout << e.what() << endl;
}
catch ( ... ) {
  cout << "else exception." << endl;
}
int i;
cin >> i; // 防止无意中按键使程序退出。
}
对于第1个异常输出为:
0 .V 004066d5 0040779f 0012ff70 00000000 _main + 85 bytes
    Sig:  _main
    Decl: _main
    Line: H:\C++ Test\StackWalk\Test\main.cpp(50) + 3 bytes
    Mod:  Test[H:\C++ Test\StackWalk\Test\Debug\Test.exe], base: 0x00400000h
Sym:  type: PDB, file: H:\C++ Test\StackWalk\Test\Debug\Test.exe
从上面第4行可以知道在main.cpp文件的第50行抛出的异常,找到这一行就是*pInt = 5;然后检查上下文,哦,没有分配内存,于是“臭名昭著”的非法内存访问就轻易发现了!Is it powerful?
对于第2个异常输出为:
1 .V 0040683d 0040779f 0012ff70 00000000 _main + 445 bytes
    Sig:  _main
    Decl: _main
    Line: H:\C++ Test\StackWalk\Test\main.cpp(72) + 49 bytes
    Mod:  Test[H:\C++ Test\StackWalk\Test\Debug\Test.exe], base: 0x00400000h
Sym:  type: PDB, file: H:\C++ Test\StackWalk\Test\Debug\Test.exe
从上面第4行可以知道在main.cpp文件的第72行抛出的异常,找到这一行就是throw CMyException( " my exception" ); 哦,是自定义异常。
对于第3个异常输出为:
0 .V 004065f5 0040697c 0012fe98 00000000 void __cdecl vDivideByZero(void) + 37 b
ytes
    Sig:  ?vDivideByZero@@YAXXZ
    Decl: void __cdecl vDivideByZero(void)
    Line: H:\C++ Test\StackWalk\Test\main.cpp(26) + 6 bytes
    Mod:  Test[H:\C++ Test\StackWalk\Test\Debug\Test.exe], base: 0x00400000h
    Sym:  type: PDB, file: H:\C++ Test\StackWalk\Test\Debug\Test.exe

1 .V 0040697c 0040779f 0012ff70 00000000 _main + 764 bytes
    Sig:  _main
    Decl: _main
    Line: H:\C++ Test\StackWalk\Test\main.cpp(100) + 0 bytes
    Mod:  Test[H:\C++ Test\StackWalk\Test\Debug\Test.exe], base: 0x00400000h
    Sym:  type: PDB, file: H:\C++ Test\StackWalk\Test\Debug\Test.exe
从上面第5行可以知道在main.cpp文件的第26行抛出的异常,找到这一行就是int iRet = 5 / iZero; 哦,是除零异常。然后从上面第12行可以知道调用这个函数是在main.cpp文件的第100行,就是vDivideByZero();的下一行(注意因为vDivideByZero();函数已经调用了,所以显示的行数都是它的下一行)。这样,我们就可以知道一个异常发生的完整过程。
对于第4个异常输出为:
  0 .V 004070ca 00406ab9 0012fe98 00000000 void __cdecl vTestVectorThrow(void) + 7
4 bytes
    Sig:  ?vTestVectorThrow@@YAXXZ
    Decl: void __cdecl vTestVectorThrow(void)
    Line: h:\c++ test\stackwalk\test\test1.cpp(13) + 10 bytes
    Mod:  Test[H:\C++ Test\StackWalk\Test\Debug\Test.exe], base: 0x00400000h
    Sym:  type: PDB, file: H:\C++ Test\StackWalk\Test\Debug\Test.exe

1 .V 00406ab9 0040779f 0012ff70 00000000 _main + 1081 bytes
    Sig:  _main
    Decl: _main
    Line: H:\C++ Test\StackWalk\Test\main.cpp(118) + 0 bytes
    Mod:  Test[H:\C++ Test\StackWalk\Test\Debug\Test.exe], base: 0x00400000h
    Sym:  type: PDB, file: H:\C++ Test\StackWalk\Test\Debug\Test.exe
从上面第5行可以知道在test1.cpp文件的第13行抛出的异常,找到这一行就是 vectInt[ 3 ] = 100; 检查上下文,发现没有给vectInt分配空间。然后从上面第12行可以知道调用这个函数是在main.cpp文件的第118行,就是vTestVectorThrow();的下一行。
那么如何使用这个类库呢?对于新工程,首先把exception.h和exception.cpp加入工程,你需要把自定义的异常类继承自CMyException,这样自定义的异常类就具有堆栈跟踪功能,其次在每个线程的入口函数加上以下几个函数调用(注意:必须在每个线程的入口都要调用,当然如CWinUtil::vInitStackEnviroment();不需要,只需要在main入口即可,但如果在每个线程的入口都要调用也不会有副作用):
` // 在每个线程函数的入口加上以下语句。
// 检查内存泄露。
CWinUtil::vCheckMemoryLeak();
// 使new函数失败时抛出异常。
CWinUtil::vSetThrowNewException();
// 把WINDOWS中的结构化异常转化为C++异常。
CWinUtil::vMapSEHtoCE();
// 初始化。
CWinUtil::vInitStackEnviroment();
然后如下所示捕获异常:
try {
  vTest();// 假设要捕获vTest()函数可能抛出的异常。
}
catch ( const CRecoverableSEHException &bug ) {// 用于捕获可恢复的结构化异常。
  cout << bug.what() << endl;
}
catch ( const CUnRecoverableSEHException &bug ) {// 用于捕获不可恢复的结构化异常。
  cout << bug.what() << endl;
}
catch ( const exception& e ) {// 用于捕获标准C++异常及其子类。
  cout << e.what() << endl;
}
catch ( ... ) { // 用于捕获那些抛出非结构化异常和不是继承exception的异常。
  cout << " else exception." << endl;
}
当然你对于结构化异常没有其它特别的处理策略,也可以简化为:
try {
  vTest();// 假设要捕获vTest()函数可能抛出的异常。
}
// 用于捕获标准C++异常及其子类。因为结构化异常继承自exception,所以这里也能捕获//   结构化异常。
catch ( const exception& bug ) {
  cout << bug.what() << endl;
}

     对于已有的工程,首先把exception.h和exception.cpp加入工程,把原来的自定义的异常类继承自CMyException,然后同上的方法捕获异常,每个线程入口增加初始化函数即可,可以与你原来的异常处理完美集成。
对于在MFC中的用法,可以按如下方式捕获异常:
try  {
  vTest();
}
catch ( const exception& e ) {
  cout << e.what() << endl;
}
// CException是MFC中异常基类,MFC中的异常通常从堆中分配,所以应通过指针捕获,// 而且使用完之后还应该调用delete函数清除内存。
catch ( CException* e ) {
  // hadle exception
  e->delete();  
}
即在MFC异常上加一层捕获标准C++异常和结构化异常以及自定义异常。另外由于MFC中已经自动有了处理内存泄露的机制,所以需要删除exception.h文件的第34行到第40行(有关内存泄露的说明见下面),由于在MFC中每个.cpp文件开始处都要包含stdafx.h,所以还需要在exception.cpp文件开始处加上#include “stdafx.h”,不然会编译不通过。
如果你希望把堆栈信息输出在文件中,以防丢失,可以使用IO重定向功能(有关IO重定向可参考Jim Hyslop and Herb Sutter的文章http://www.cuj.com/experts/1903/hyslop.htm ),即在main()函数开头加入以下语句:
ofstream ofLog( "exception.txt", ios_base::app );
streambuf *outbuf = cout.rdbuf( ofLog.rdbuf() );
    这样,所有输出到console的信息就重定向到exception.txt文件中了。如果你想恢复,则可以加入以下语句:
  // restore the buffers
    cout.rdbuf( outbuf );
对于release版本,如果你运行,你会发现程序不能捕获非法内存访问、除零等结构化异常,这是因为VC在release版默认是同步异常,不捕获结构化异常,只能捕获C++的异常,所以你需要修改编译选项,采用异步异常模型,在project->setting->c/c++->project options框中增加/EHa的编译选项。另外,release版默认不生成调试符号文件,这样你就不能不能打印出抛出异常的代码的行号等信息,所以你需要修改编译配置,方法如下:Project->Settings->c/c++页中的debug info列表选项中选择program database项。这样release版本也能实现堆栈跟踪。当然,这样会使release版本减慢速度,而且还要带一个debug info文件,因为有些bug只有在release版本中才会出现,而且release版是真正给客户使用的,所以必须测试release版,可以考虑release的beta1和beta2版本带这些调试信息,这样的话,因为debug版和release版都测试通过,发行给客户的最终正式版可以通过设置一个宏注释掉这些调试信息,恢复成同步异常模型,即恢复成VC默认的release版配置。
4. 其他需要注意的问题
本类库还有检查内存泄露的功能,只要你在每个.cpp文件的所有#include之后,加上以下语句:
// 以下几行是能够定义到发生内存泄露的代码行。在每个.cpp文件都应该声明。
#include “Exception.h”
#ifdef _DEBUG
#define new DEBUG_NEW
#undef THIS_FILE
static char THIS_FILE[] = __FILE__;
#endif
然后以debug方式启动程序,当程序正常退出或关闭时(注意不能用stop debug的命令停止,否则将不会打印出内存泄露的信息),在VC的debug窗口将会打印出有可能产生内存泄露的源代码信息,包括文件名和行号。由于MFC程序自动会生成这些代码,所以在MFC程序中不需要手工添加这些代码。例如,当你以debug方式运行下载的测试程序,当程序正常退出后,在debug窗口会显示如下语句:
Detected memory leaks!
Dumping objects ->
H:\C++ Test\StackWalk\Test\main.cpp(88) : {184} normal block at 0x00632D50, 100 bytes long.
Data: <                > CD CD CD CD CD CD CD CD CD CD CD CD CD CD CD CD
Object dump complete.
从上面第3行可以知道在main.cpp文件的第88行产生了内存泄露,找到这一行就是 char* pcLeak = new char[ 100 ]; 检查上下文,发现果然没有释放内存。
当然如同你使用Purify、BoundsChecker等工具检查内存泄露一样,它也会谎报军情,有些不会内存泄露的地方,它也告诉你内存泄露了,尤其当你使用了大量STL类库时,这就需要你细心检查上下文,以确定是否是内存泄露了。
本类库由于使用了一些VC中特有调试符号特性,所以可能不能在其它编译器下通过。另外,本文讨论的堆栈跟踪实现都是基于Windows 2000以上,Win98和Win95将不能输出导致抛出异常的语句所在的文件名和行号。本类库也不能在Unix或Linux下运行。有在Unix或Linux平台工作的读者朋友如果有兴趣,可以实现一个在Unix或Linux平台运行的C++异常堆栈跟踪类库。
本类库不能跟踪标准C++异常及其它你不能控制的异常的堆栈信息,即当这些异常抛出时,不能输出抛出异常语句的文件名和行号信息。这是因为标准C++异常是语言内置的,而其它类库的异常你不能控制其构造函数。这是一个小小的遗憾,不过你可以通过异常说明知道它是哪一种异常,可能在程序的哪一些C++函数中抛出,这样你也能很快地找到错误之处。
有了完善的异常类层次,你可以在程序中干任何事,异常过滤机制和堆栈跟踪会忠实的记录任何错误,除了你在析构函数抛出异常以及重复删除不为NULL的指针,这两种情况下程序还是会当掉,而且不能记录堆栈信息。当然,对于缓冲区溢出、堆写越界等情况,本类库还是无能为力,不过,你可以借助Purify、BoundsChecker等工具来检查程序运行中是否有这类问题。
5. 小结
谁说C++就不能有堆栈跟踪的功能。有了这个具有堆栈跟踪功能的异常类库,你将如虎添翼,必将加快你开发程序的进程。


W 3 C h i n a ( since 2003 ) 旗 下 站 点
苏ICP备05006046号《全国人大常委会关于维护互联网安全的决定》《计算机信息网络国际联网安全保护管理办法》
1,964.844ms