□ 叶信子
上海维宏电子科技股份有限公司 上海 200140
人们总是希望编写出优秀的代码,以便在生产环境中稳定而持久的运行。然而,不同的客户所处的环境各不相同,很难保证代码一直正确运行。因此,工程项目中,代码的编写需要更加注重稳定性、容错性和扩展性。
一般而言,程序的容错性主要体现在对于异常的处理上。应用程序抛出的异常,如果没有得到及时的处理,公共语言运行库(CLR)会自动停止该应用程序的运行。在处理异常的过程中,可以在线程中捕获这个异常进行处理,然后继续在另一个线程中重新抛出处理后的异常,以便堆栈的上一级继续处理该异常。在异常发生时,CLR会在发生异常的堆栈上,由下往上依次查找对应类型异常的catch块。如果找到了对应的catch块,那么将异常交由该catch块处理。处理完成后,应用程序继续运行。否则,该异常将被视为未经处理的异常。CLR在任何地方检测到未经处理的异常时,都会立即终止进程,导致应用程序崩溃。
.Net标准的异常处理过程通常由try、catch、finally三个代码块完成,他们之间可以相互嵌套,组合起来处理系统的各种异常。
try块是异常处理必需的代码块,这一代码块内部通常包含可能抛出异常的代码。try块还可以用来清理系统资源,使系统从异常中恢复过来。每个try块后面必须跟有至少一个catch或finally块。
catch块仅在对应的try块抛出异常后才会执行。若未发生任何异常,系统会跳过所有catch块中的内容,继续向下执行。
catch块需要显示所处理异常的类型。所有的异常均继承自System.Exception。因此,catch块后面的异常类型可以直接写System.Exception。但是,为了能够更好地处理不同类型的异常,仍然应该在catch块后面放上具体的异常类型,以便CLR在异常发生后,能够匹配到类型相同的catch块执行。
finally块中的内容,无论是否发生异常都会执行。通常而言,finally块中的代码用来清理try块中的资源。finally块不是必需的,但如果需要用finally块,那么其必须在所有的catch块之后,并且一个try块仅能有一个finally块。
总体而言,这三个代码块是.Net提供的异常处理机制的基础。在try块中尝试执行一些代码。如果发生了错误,那么需要在catch块中进行处理,以便从错误中恢复并继续执行。或者通过catch块进行补偿,来撤销一些状态更改,并向调用者上报错误。最终,通过finally块确保清理操作无论如何都能执行。
.Net程序需要在CLR虚拟机中运行,属于托管代码。使用C或C++所编写的组件,可以直接访问和修改内存,属于非托管代码。托管代码和非托管代码的运行机制存在差异,导致非托管代码无法在托管代码中使用。因此.Net推出了P/Invoke机制,这实际上是一种函数调用机制,可以消除.Net托管代码和非托管代码之间的鸿沟。
在.Net平台开发出来前,在Windows平台工作的程序员,基于Win32 API花费了大量精力编写各种库和com等非托管组件。为了避免程序重复,项目组一般通过P/Invoke直接在托管代码中调用非托管DLL中的函数,提高工作效率,减少维护这部分功能的人力和时间成本。
在设计.Net时,.Net团队发现现有的Win32 API经过多年完善,规模实在过于庞大,导致.Net没有为所有的Win32 API编写基于.Net的托管实现文档。而通过P/Invoke,则可以方便地使用较为完善的Win32 API函数。
由于机制原因,托管代码需要通过CLR间接访问内存,因此,它的运行效率比非托管代码稍低。在某些对效率要求较高的项目中,程序员通过C或C++编写核心算法,并封装成库。在非托管代码中,程序员可以通过P/Invoke直接调用,提升软件的整体效率。
在托管代码中,需要通过调用函数,来执行非托管代码。这一过程主要有以下步骤:
(1)获得需要调用的非托管函数的信息,包括函数名、形参、返回值等内容;
(2)在托管代码中,声明对应的非托管函数,并通过DLLImport特性,设置P/Invoke过程中所需要的属性,主要为非托管库的名称、形参和返回值的内存布局方式;
(3)在托管代码中,直接调用声明之后的非托管函数。
实际执行过程中,P/Invoke做了以下工作:
(1)查找包含对应函数的DLL;
(2)将对应的DLL装载到内存中;
(3)查找函数在内存中的地址,并将其参数推入堆栈,以便封送非托管函数所需的数据;
(4)将控制权转移给非托管函数,确认调用完成,非托管函数返回。
CLR第一次调用函数时,会装载对应的DLL,并查找函数的内存地址。当该函数发生第一次调用之后,CLR会将该函数的内存地址进行缓存,从而可以提高P/Invoke的使用效率。
在应用程序被卸载之前,装载过的非托管DLL会一直留在内存中。因此,在关闭软件时,需要释放非托管DLL申请的内存。
随着计算机技术的发展,软件的规模日益扩大。在这样的背景下,.Net技术虽然在各种大型软件中应用越来越多,但是这些项目中不可避免地会存在许多第三方库。
在大型项目中,软件的稳定性是尤为重要的,因此,如何保证这些第三方库能够稳定运行,是项目维护人员不得不面对的工作。所幸,.Net平台有一套完善且强大的异常处理机制。上述异常处理机制在通过.Net直接操作内存的过程中即使产生越界和内存泄漏,也有较为有效的解决措施。因此,大部分通过.Net技术搭建的软件稳定性是有保证的。
但是,代码如果没有通过CLR运行,而是直接通过P/Invoke机制调用C或C++库操作内存而产生了异常,那么上述.Net框架提供的异常处理机制将无法进行工作,导致应用程序运行出错,甚至崩溃。
有两种情况无法通过这种方法捕获。
(1)垃圾回收时产生的异常。这种异常一般在Finalize函数中被抛出,但是没有被捕获处理。除了这种情况,类似内存耗尽时,也会造成系统产生未经处理的异常,导致应用程序崩溃。
(2)主线程无法捕获的未处理异常。通常会在应用程序的入口中添加try块、catch块来处理程序运行时产生的所有异常,但是应用程序在调用外部组件提供的接口函数或者Win32提供的某些API时,在这些组件内部产生了异常,这些异常无法直接被主线程中的try块、catch块捕获。
因为有上述两种情况,所以try块、catch块是无法捕获所有异常的。由此可见,即使代码中所有可能出现问题的地方都加上了try块、catch块,也无法杜绝未经捕获的异常导致应用程序崩溃。因此,要提高程序的健壮性和可维护性,使应用程序能够长时间稳定运行,软件工程师需要一种用能截获未经处理异常的机制,来帮助应用程序从异常中恢复,或者记录有价值的错误信息。
在非托管代码中,异常的处理会经过以下步骤。
(1)调试器处理。异常发生的时候,如果程序正在被调试,那么异常会被交给调试器。调试器收到异常后,判断该异常是否需要处理。
(2)执行VEH。异常发生时,如果应用程序不在调试过程中,或者调试器此时返回值为0,那么操作系统会将该异常交给VEH处理。VEH是一个链表,可以挂接多个VEH的处理过程,在系统调用时,按顺序调用链表中的多个函数,依次处理异常。
VEH的返回值有三个:1、0、-1。返回1时,表示无效的处理,实际处理同0。返回0时,将把异常交给VEH链表中的下一个进行处理。返回-1时,表示异常已处理完成,此时系统会退出异常处理器,在异常指令处继续往下执行。
(3)执行SEH。当所有VEH执行完成后,异常会被SEH处理。SHE基于线程栈的异常处理机制,仅处理自身线程内部发生的异常。
(4)顶层异常处理。顶层异常处理实际也是通过SEH实现的。在最顶层的SEH中,可以注册一个顶层异常处理器,它可以处理所有线程抛出的异常。因此,SEH发现无法处理的异常时,会检查是否注册了顶层异常处理,如果注册了,则调用顶层异常处理。
.Net中,提供了Unhandled Exception Event Handler事件,这一事件可以注册到顶层异常处理中。通过这一事件,可以截获系统中未捕获的异常,并进行处理。
Unhandled Exception Event Handler的事件参数Unhandled Exception Event Argse具体有两个属性,分别为 Exception Object和 Is Terminating。Is Terminating表示如果异常不处理,是否会终止当前应用程序。Exception Object为截获异常的对象。当系统捕获到未经处理的异常时,通过该对象就可以记录异常在何处被引发。通过这一信息,软件设计人员可以在操作该对象的地方做出改进,降低异常发生的可能性。此外,依赖这一事件,可以在程序崩溃退出之前,做好数据备份,记录错误日志工作,以便排查问题。
在顶层异常处理中,可以记录下程序的Dump文件,保存未处理异常现场,以便后续分析和改进。
在应用程序中挂接Unhandled Exception事件。事件的响应函数中,构建描述Mini Dump信息的结构体Mini Dump Exception Information,然后通过Marshal.Get Exception Pointers函数获取异常的位置,通过Win32 API的Get Current Thread Id函数获取当前的非托管线程号。最后,通过Windbg的Dbghelp.dll中提供的Mini Dump Write Dump函数,将当前应用程序的信息写入对应的文件中,形成一个Dump文件。
在程序发生异常崩溃时,通过Dump文件,可以分析程序崩溃时的CallStack。配合应用程序对应的Pdb文件和源码,可以在程序崩溃后继续调试当前异常。