2023年1月


直接使用SEH

有些情况里直接使用SEH会更合适一些。特别是,如果需要在第一次遍历(first pass - SEH异常处理流程里的第一遍处理)时需要执行某些操作时,也就是在堆栈向上展开之前,SEH是唯一的选项。一个SEH里的 __try/__except 过滤代码除了决定是否要处理某个异常以外,还能执行任何操作。调试器通知(Debugger notification)就是在第一次遍历时需要考虑的领域。

过滤代码的编写需要极其小心。一般来讲,过滤代码需要考虑到任何随机,而且很可能不一致的状态。因为过滤代码在第一次遍历时执行,而析构函数(dtors)在第二次遍历时执行,holders还没有执行,而且也没有恢复它们的状态。

PAL_TRY / PAL_EXCEPT, PAL_EXCEPT_FILTER, PAL_FINALLY / PAL_ENDTRY

如果需要过滤代码,PAL_TRY家族代码是CLR里可移植的写法。因为过滤代码直接使用SEH,它不能在一个函数里与C++异常处理共存,因此在函数里不能使用holders。

再次强调,这种情况很少见。

__try / __except, __finally

在CLR里没有充足理由来直接使用它们。

异常和GC模式

使用COMPlusThrowXXX()抛出异常不会影响GC模式,而且在所有模式里都是安全的。当异常将堆栈展开到EX_CATCH,在栈上的所有holders都会销毁,释放它们的资源和重置它们的状态。当在EX_CATCH里恢复执行时,被holder保护的状态会恢复到EX_TRY时的状态。

转换

在托管代码,CLR,COM服务器和其它原生代码之间,有很多如调用规范(calling conventions),内存管理和异常处理机制之间的转换。关于异常,对CLR开发者来说是幸运的,大部分转换要么完全在CLR之外,或者就是被自动处理了。CLR开发人员日常需要考虑的就是三种转换。其它的都是高端话题。

托管代码进入CLR

由“fcall”、"jit helper"等触发。典型路径是CLR通过一个托管异常向托管代码报告错误。因此,如果是一个fcall函数直接或间接抛出一个托管异常,那没什么问题。一般的CLR托管异常实现会“做正确的事”而且会去找一个适合的托管异常处理代码。

从另一面来说,如果fcall函数也可以包含抛出CLR内部异常的代码(一个C++异常),这个异常不能泄漏到托管代码那边。为了处理这种情况,CLR提供了UnwindAndContinueHandler (UACH),它是捕捉C++异常并转换成托管异常并抛出的一系列代码。

任何从托管代码端调用的CLR函数都有可能抛出C++异常,必须将抛出异常的代码封装在INSTALL_UNWIND_AND_CONTINUE_HANDLER / UNINSTALL_UNWIND_AND_CONTINUE_HANDLER里。安装一个HELPER_METHOD_FRAME会自动安装UACH。安装UACH的性能代价很大,因此不能随处使用。一种技术是,在执行关键代码的时候不要使用UACH,但是在抛出异常之前安装一个UACH。

当一个C++异常被抛出时,而少了一个UACH,典型错误就是一个在CPFH_RealFirstPassHandler里“GC_TRIGGERS在一个GC_NOTRIGGER区域里被调用”的合约违规(Contract Violation)。要修复这些错误,留意托管代码到CLR的切换,并检查INSTALL_UNWIND_AND_CONTINUE_HANDLER或HELPER_METHOD_FRAME_BEGIN_XXX。

CLR代码到托管代码

从CLR切换到托管代码是跟平台很相关的。在32位Windows平台,CLR托管异常代码要求在进入托管代码之前已经安装了“COMPlusFrameHandler”。这些切换被一些专用的辅助函数处理,它们来执行恰当的异常处理。一般对托管代码的调用很少用其它方法。如果没有COMPlusFrameHander,最可能的情况就是托管代码端的异常处理代码没有被执行,finally块和catch块都不会被执行。

CLR代码切换到外部原生代码

从CLR调用其它原生代码(操作系统、CRT和其它DLL)的过程要格外注意。这是因为外部代码可能会触发一个异常。由于EX_TRY宏的实现方式,这是一个问题,特别是它们将一个非异常错误翻译或者封装异常的方式。对于C++异常,只能通过放弃捕捉到的异常的所有信息,是可以捕捉任意或者所有异常(通过"catch(...)")。捕捉到一个异常后*,这个宏有异常对象可检查,但如果捕捉到其它东西,那就没什么可检查的了,宏只能猜异常是什么。如果异常是从CLR外部来的,那宏总是会猜错。

目前的解决方案是将对外部代码的调用封装到一个“callout filter”。这个过滤代码会捕捉外部异常,并将它翻译成一个CLR内部异常:SEHException。这个过滤代码是预定义的,而且用起来也很简单。然而,使用过滤代码就意味着使用SEH,因此在同一个函数里不能使用C++异常。在使用C++异常的函数里加上“callout filter”要求将它分成两个函数。

要使用callout filter,这样的代码:
length = SysStringLen(pBSTR);

要写成:

BOOL OneShot =TRUE;

PAL_TRY
{
length
=SysStringLen(pBSTR);
}
PAL_EXCEPT_FILTER(CallOutFilter,
&OneShot)
{
_ASSERTE(
!"CallOutFilter returned EXECUTE_HANDLER.");
}
PAL_ENDTRY;

当你启动某些程序时,会收到与此错误类似的未处理异常错误:Unhandled e0434f4dh exception at 7c81eb33h此问题是由于.NET框架未正确安装或.NET框架系统中的另一个问题引起的。

最常见的情况是,可以通过重新安装相应的.NET框架先决条件来解决此问题。但是,在许多情况下,可能需要使用.NET框架清理工具完全删除.NET框架安装。

此.NET框架清理工具旨在自动执行一组步骤,从计算机中删除选定版本的.NET框架。它将删除.NET框架的文件、目录、注册表项和值以及Windows安装程序产品注册信息。该工具主要用于在遇到.NET Framework安装、卸载、修复或修补错误时将系统返回到已知(相对干净)状态,以便您可以尝试再次安装。

内存转储是查明托管.NET应用程序中异常的原因的一种极好的方法,特别是在生产应用程序中发生异常时。当您在无法使用Visual Studio的应用程序中跟踪异常时,cdb和sos.dll的使用技术就变成了它们自己的技术。它们可能也不是你需要经常使用的技能,但在某些时候,它们将是无价的。Edward提供了内存转储示例,并为您提供了一个简单的介绍。

Windows的调试工具占主导地位,sos.dll将CLR内部变成了一个真正的知识源泉。具体来说,我将讨论现有的异常处理框架,讨论.Net异常,如System.exception,以及我所称的“CPU异常”,如访问冲突(C0000005):您可能知道它们是Win32或硬件异常。然后,我将解释如何创建和使用内存转储来查找和修复事后的错误;换句话说,事后调试。 最后,我将展示一个使用cdb和Visual Studio调试的示例。在第一篇文章中(不要给我任何VS),我提倡使用cdb,但是我现在意识到这是一个很大的飞跃:因此,在本文中,我将展示一个使用cdb和sos.dll的示例,然后继续讨论如何在Visual Studio中实现相同的功能,尽管在这个特定的示例中,你需要一定程度的耐心。不过,我想说清楚的是,我个人并不反对Visual Studio或微软,我认为它是一个非常好的IDE,我每天都在使用它,并且对它很满意,我只是相信,Windows的调试工具在调试时给了您更多的控制和动力,当您没有安装Visual Studio(即生产服务器)的选项,或者您没有现成的源代码时,您就无法击败cdb和sos.dll来调试托管应用程序。

什么是异常?

让我们从头开始。异常是指在正常操作下,不应在任何时间发生但可能发生的事件。如果不进行处理,它会阻止应用程序运行。一个例外类似于你的车在你开车的时候停了下来。停车有很多可能的原因;你可能踩了刹车,发动机可能卡住了,车内的乘客可能拉了手刹,或者外部有人把一些相当坚固的东西挡在你的路上。不管发生了什么,如果你处理好了,有些事情你可以恢复,有些事情你不能。

为什么要使用内存转储进行调试?

在两种主要情况下,您可能需要使用内存转储进行调试。第一种情况是应用程序有一个未处理的异常并崩溃,而您只有一个内存转储。第二种情况是,在生产环境中出现异常或特定行为,并且在排除故障时不能将调试器保留在附件中,因为调试器可能会中断用户服务。相反,您可以附加cdb,在正确的位置创建转储文件,然后分离调试器,这意味着应用程序只在服务中有一个小的中断时继续运行。当然,取决于你的环境,这并不总是可能的,但有时可能是你唯一的选择。让我们来看看如何在cdb和Visual Studio中使用内存转储进行调试。这来自本文附带的example2.exe应用程序。它有很多选项,但我们将要讨论的是为什么cdb比Visual Studio更快。首先,您应该启动一个命令提示符并使用调试工具切换到目录,然后运行“doDebug.cmd”文件,如前一篇文章所述(也附在zip文件中)。然后是“cdb-z c:\pathToDumpFile\stackOverflow.dmp”。应按照以下要求打开:

这表明我们有一个内存充足的小型转储,所以我们有我们需要的部分。我们有一个异常,在这种情况下是“堆栈溢出”异常。 我们知道存在堆栈溢出,但不知道这是托管还是非托管问题,所以让我们获取本机堆栈跟踪“kf”

这显示了mscorwks!CallDescrWorker调用了未知的方法。未知的原因是调试器使用模块列表来确定模块的名称(从“lm”获取模块列表),因为CLR JIT的MSIL进入程序集,所以当前模块中存在,因为它挂在进程中的某个位置。

因为mscorwks调用了未知函数,所以这很可能是一个托管问题。在上面的堆栈跟踪中,我使用了kf,因为这些显示了每个堆栈帧使用了多少内存。它实际上计算出了两帧之间的距离,因此第一帧没有内存使用。内存之所以重要,是因为有两种类型的堆栈溢出:第一种是在堆栈上创建太多或太大的对象;第二种是函数递归并耗尽所有内存。

那我们在哪?我们知道这可能是托管代码问题,所以让我们加载so s.dll,'loadby sos mscorwks'并获取堆栈跟踪。注意,因为这是递归的问题,如果你做了!CLRStack“调试器将在cdb中显示一帧又一帧。从Ctrl+C停止它,它将中断跟踪。使用cdb的一个技巧是,如果你需要做一些能输出大量数据的事情,那么就把窗口降到最低,这样就可以让整个团队受益。

 

 

所以我们现在有一个显示导致堆栈溢出的函数名的堆栈跟踪:如果我们有源代码,我们可以检查它并查看可能发生的情况,但是让我们快速查看一下MSIL。 和往常一样,我们需要获得helper方法的方法描述;所以我们通过执行“!Name2EE * example2.StackOverflow“。然后我们从“MethodTable:”中获取值并执行“!DumpMT -md 019631a4“。

 

那么,要得到MSIL,就做“!DumpIL 001963190“我们得到:

 

所以这很有趣,“ldarg.1”抓取第一个参数,“ldc.i4.1”抓取值1,“add”将它们相加;所以它递增某种类型的计数器,然后它根据_max_RecurseLevel检查结果,如果它大于或等于它,则它退出函数(“bge.s IL_0016”)。这是一个相当简单的例子,但是现在我们需要做的只是看看maxRecurseLevel和传入的值,看看我们在哪里。

顺便说一句,这些是msil文档的链接,当您不了解msil正在做什么时,使用msdn查看每个操作员的操作相当简单:

Ldarg.1 http://msdn.microsoft.com/en-us/library/system.reflection.emit.opcodes.ldarg_1.aspx

Ldc.i4.1 http://msdn.microsoft.com/en-us/library/system.reflection.emit.opcodes.ldc_i4_1.aspx

Bge_s http://msdn.microsoft.com/en-us/library/system.reflection.emit.opcodes.bge_s.aspx

Add http://msdn.microsoft.com/en-us/library/system.reflection.emit.opcodes.add.aspx

好吧,让我们回头看看我们在哪里,传递了什么参数。再次使用ctrl+c准备并执行“!clrstack-a“显示所有参数和局部变量:

将来,任何开发人员都将需要调试应用程序,并且将无法访问Visual Studio,在某些情况下甚至无法访问源代码。例如,在生产web或应用服务器上调试问题时,我真的不想安装Visual Studio并跨所有源代码进行复制;这是不实际的,有时甚至是不允许的。正是在这种时候,我们需要另一个工具,一个调试windows应用程序的工具,而微软正好提供了一系列这样的调试器,非常适合这种情况。在本文中,我将解释哪些调试器可用,以及在Visual Studio不实用或不可用的情况下,如何使用它们来简化调试.NET应用程序的过程。在本文中,我将解释哪些调试器可用,以及如何调试一个简单的、相当常见的示例。我希望这将展示如何以简单直接的方式调试代码。