在现在C++、Java、.Net代码大行其道的时候,很多代码错误(Bug)都是通过异常的形式表现出来的。由于工期紧或者种种原因,很多程序员在碰到程序发生未处理的异常的第一反应就是try … catch (Exception e) { … }。然而代码开发到后期的时候,这种简单粗暴的解决代码错误(Bug)的方式就会在其他不相干的地方表现出来,有的时候甚至导致程序随机的不稳定,而且很难调试。比如执行下面的代码你会发现输出的值是12345.6789:
using System;
public class Class1 { public static void Main() { Console.WriteLine(Calculate("12345.6789 + 987654321l")); }
private static double Calculate(string expression) { string[] numbers = expression.Split('+');
return RedundantParseForDemoOnly(numbers[0].Trim()) + RedundantParseForDemoOnly(numbers[1].Trim()); }
private static double RedundantParseForDemoOnly(string number) { try { return double.Parse(number); } catch { return 0; } } } |
当然啦,你可以说只要强迫项目组所有程序员不要catch通用异常就可以了,但是老虎总是有打盹的时候,而且大部分项目组因为人才梯队建设的问题都会有那么几个新人……
我在工作中发现很多程序员都不理解,或者说根本就没有去注意过表2里面的输出,浪费了很多好的调试机会。因此我看到很多优秀的程序员都是非常辛苦的单步调试,一步步地跟踪代码。这个过程不仅辛苦,而且非常容易出错,因为单步跟踪代码容易让人犯晕。实际上你可以利用Windows提供的结构化异常处理(SEH)来帮助你快速找到问题所在。
在程序运行的时候,特别是程序里面发生异常的时候,如果你使用windbg.exe、cdb.exe等命令行调试器的时候,或者如果你留心Visual Studio的输出( output)窗口里的文本输出的时候,你将会看到类似下面的输出:
First-chance exception at 0x004116a9 in 异常处理.exe: 0xC0000005: Access violation writing location 0x00000000. …… First-chance exception at 0x7c812aeb in 异常处理.exe: Microsoft C++ exception: char at memory location 0x0012fd88.. |
当CPU运行到一些非法的指令,例如除零错误,访问内存页失败等指令,CPU会生成一个硬件异常,不同的异常有固定的异常代码作为标识符,异常产生以后CPU暂时不能继续执行后续的指令—因为后续的指令有可能也是无效的。当然不能让整个计算机系统就这么当掉,因此CPU内置了一个异常处理表—这个异常处理表只有运行在内核模式的代码才能访问,操作系统在启动的时候初始化这个异常处理表,为每一个异常注册一个异常处理程序,因此这个表看起来就像:
0xC0000005 |
AccessViolationExceptionHandler() |
…… |
|
0x80000003 |
LaunchOrNotifyDebugger() |
CPU产生异常以后,根据异常代码在异常处理表里面查找对应的异常处理程序,通过调用异常处理程序执行了合适的处理之后再继续执行其他的代码。
处理CPU自定义的硬件异常以外,异常处理表里面还有一些空的表项,这样操作系统可以通过添加自定义的异常代码和相应的异常处理程序实现软件异常。在Windows操作系统中,程序可以通过调用Win32的RaiseException函数来触发软件异常。因此实际上C++和.NET里面的异常都是通过调用RaiseException来实现的,然而异常处理表不是很大,只能保存不超过256个异常处理程序信息,因此Windows只定义了几个通用的异常码来表示C++ 和.NET异常。
0xE06D7363 |
表示C++ 异常 |
0xE0434f4D |
表示CLR(或者说.NET)异常 |
对于CPU来说,硬件异常、软件异常、C++ 异常和.NET异常的触发和处理的方式都是一样的,这种方式在Windows中叫做结构化异常处理(SEH)。
1. 当程序里面有一个异常发生以后;
2. SEH首先通过回溯堆栈内容,查找程序内部是否有一个异常处理块(就是我们通常知道的catch 块);如果找到一个异常处理块可以处理这个异常,那么Windows将异常处理块所在的函数下面的堆栈释放,并且执行这个异常处理块里面的代码。
3. 如果Windows没有发现任何一个异常处理块处理掉这个异常的话,也就是到程序入口(main)函数也没有找到一个合适的异常处理块的话,Windows会使用它自带的异常处理块处理这个异常;
4. Windows自带的异常块会检查你的程序是否附加了一个调试器,如果是的话,Windows中断程序并将控制权交给调试器。
5. 如果没有调试器附加到你的程序上,Windows启动注册在注册表里面的默认验尸调试器,一般情况下,Windows的默认调试器是Dr Watson,这个调试器的工作就是弹出著名的“调试还是终止,这个问题”对话框(或者是“是否将错误报告发送给微软?”对话框):
在上面的处理步骤中,第一步通过RaiseException触发异常的时候,Windows会先检查你的程序是否正在被调试,如果有一个调试正附加在你的程序上,Windows向调试发送一个调试消息,通知调试器程序里面有一个异常发生,询问调试器是否要中断程序执,默认情况下调试器会忽略这条消息—因为我们纯洁的调试器总是相信程序员都能写出优良的代码出来。这个步骤在调试术语中叫做第一次机会(First Chance)--第一次在异常发生的现场观察触发异常的环境。
而第3步以后,操作系统自己来处理这个异常的时候,在调试术语中叫做第二次机会(Second Chance),由于这个时候程序实际上已经挂了(不会有任何生命活动了)—不是病危,所以这个时候即使你将调试器附加上去来检查触发异常的环境时,就像法医在检查人非正常死亡的原因一样—验尸调试。
在程序开发的时候,我们当然是希望在程序病危(first chance异常)的时候检查代码错误(Bug)的原因啦,因此如果程序发生一些莫名其妙的Bug时,在调试程序的时候,最好通知调试器不要忽略first chance异常。因为如果异常被程序内部的代码catch掉的话,很有可能会导致我们忽略重要的线索。
在Windbg里面,使用下面的命令来通知调试器不要忽略first chance异常:
sxe 异常代码
注意sxe后面有一个空格,例如在调试.NET程序的时候,可以使用命令sxe 0xE0434f4D来使调试器在catch块执行之前中断程序的执行。
可以使用命令
sxd 异常代码
来启用忽略first chance异常的功能。
在Visual Studio里面,请参考我的另外一篇文章学习如何设置:CLR Debugger - 在程序抛出异常的时候中断程序的执行
通过查看调试器里面的输出信息,你可以了解调试器是否启用了在first chance异常触发时中断程序执行的功能。
在Visual Studio里面,可以查看“输出”窗口里面的文本输出:
这个输出就说明调试器忽略了first chance异常
First-chance exception at 0x004116a9 in 异常处理.exe: 0xC0000005: Access violation writing location 0x00000000. …… First-chance exception at 0x7c812aeb in 异常处理.exe: Microsoft C++ exception: char at memory location 0x0012fd88.. |
在Windbg里面,类似下面的输出表示调试器忽略了first chance异常:
0:000> g (f08.f38): Access violation - code c0000005 (first chance) First chance exceptions are reported before any exception handling. This exception may be expected and handled. (f08.f38): C++ EH exception - code e06d7363 (first chance) |
你可以使用下面的代码去尝试一下上面讲解的概念:
// 异常处理.cpp : Defines the entry point for the console application. //
#include "stdafx.h" #include #include #include
using namespace std;
int ExceptionFilter(unsigned int code, struct _EXCEPTION_POINTERS *ep) { if ( code == EXCEPTION_ACCESS_VIOLATION ) return EXCEPTION_EXECUTE_HANDLER; else return EXCEPTION_CONTINUE_SEARCH; }
void CppTestFunction() { try { throw "Test Cpp Exception"; } catch ( char * ) { cout << "char * exception caught" << endl; } }
int _tmain(int argc, _TCHAR* argv[]) { int *p = NULL;
__try { *p = 1; } __except ( ExceptionFilter(GetExceptionCode(), GetExceptionInformation())) { cout << "ACCESS VIOLATION CAUGHT" << endl; }
CppTestFunction();
return 0; } |