游戏异常处理(转)

    技术2022-05-11  19

    如何定位导致Crash的代码位置 (转)

    VC调试入门

     

    游戏异常处理(转)

    技术类    2009-05-31 18:10   阅读24   评论0   字号: 大   中   小 小

     

    1.      前言

     

    几 乎每个游戏都或多或少地存在着缺陷,辛辛苦苦完成的游戏要是最终在玩家那里崩溃了,对开发人员来说可能是最不好的消息了。不仅如此,在游戏发布前都需要经 过大量的测试,通常用于测试的电脑上并不会安装调试环境,因此当游戏崩溃时,往往只能得到一个错误提示。如果能够在游戏崩溃时提供更多的信息,就可以为开 发人员对此进行再现或是进一步调试带来很多方便。

    当然,最理想的情况就是在每个测试人员以及每个客户的电脑上安装调试环境,每次都让游戏在 调试模式下运行,这样一旦出现错误,只需要把当时的函数调用栈、各种局部和全部变量的当前值以及其它系统信息记下来就行了,可惜这在现实情况下通常不可 行。因此,最终用户看到的往往是这样一个窗口

     

    而开发人员听到的则是“我在走过一扇门的时候,程序突然退出了”之类的描述。

     

    2.      怎样才可以获得更有用的崩溃信息

     

    其实如果在上面的那个崩溃提示窗口中,点击“click here”进入的话,可以有机会看到一些技术信息(technical information):

     

    这 些信息包括了当前程序中包含哪些模块以及每个线程栈在崩溃时的情况等。事实上这些信息已经足以告诉开发人员崩溃的大致环境了。有很多文章提到过怎样根据这 些信息获得当时的函数调用栈以及局部变量信息。譬如说,参考文献1和参考文献2。可惜的是,这个机制只有在XP以后的版本中才有,并且这份错误报告也不会 发送回游戏开发商,而是直接被发送到微软。所以,我们必须找到一个机制,不仅可以在微软之前获得游戏崩溃的第一手信息,而且也适用于各种常用的 Windows版本。

     

    3.      全局的try/catch

     

    很多游戏采取的方法就是在应用程序的入口函数(通常是WinMain)中加入一对全局的try/catch,这样任何未被捕获到的异常都会被全局的异常处理代码捕获到,从而使得程序能够有机会记录一些系统信息并且发回给开发人员,至少也可以体面地结束游戏。

    可 是这样做并不能保证我们一定能够在程序发生崩溃时获得控制权。首先,不少游戏中存在着大量的静态初始化工作,这部分工作是在程序入口函数被调用之前由C+ +运行库进行的。由于在进行静态初始化时还没有进入我们的全局try/catch,无论发生什么情况,我们都不可能捕捉到。当然,这可以通过不在应用程序 中使用任何可能抛出异常的静态初始化来避免,把所有可能抛出异常的静态初始化都修改为局部静态初始化,并且在入口函数中强制调用(不少大型程序使用这种方 法来控制静态初始化顺序)。不过,这并不是一个通用的方法,因为使用静态初始化毕竟是大多数C++程序员的习惯。

    其次,这不能捕捉到其它线 程中抛出的异常,因为各个线程间的异常是独立的,一个线程中的try/catch块并不能够捕获到它所创建的子线程中抛出的异常。不过这个问题相对来说比 较好解决一点,稍具规模的程序大多会有自己的线程类,因此只需要把对线程函数进行调用的代码包在一对try/catch之间就可以达到目的。

    再次,全局的try/catch会对调试工作带来很大的麻烦,相信很多人都遇到过调试时某个异常被全局的异常处理程序catch到导致失去出错上下文的情况。并且,对于大多数懒惰的程序员来说,需要编写代码(哪怕只有几行)总是多少有些不适。

    本文下面将花费大量的篇幅来描述怎样一劳永逸地解决这个问题。

     

    4.      异常的终点

    对 于一个Windows平台上的C++程序来说,异常其实可以分为两种:Win32异常,也就是结构化异常(Structured Exception)以及C++异常。无论是Win32还是C++都为我们提供了一些机制来处理未被正常捕获的异常,让我们在应用程序退出之前尽一些人 事。

    在Win32中,我们可以通过SetUnhandledExceptionFilter来设置一个SEH的过滤函数。这个函数的原型是:

     

    LPTOP_LEVEL_EXCEPTION_FILTER SetUnhandledExceptionFilter(      LPTOP_LEVEL_EXCEPTION_FILTER lpTopLevelExceptionFilter );

     

    这个函数把全局SEH过滤函数设置为lpTopLevelExceptionFilter,而返回值则是上一次设置的过滤函数。LPTOP_LEVEL_EXCEPTION_FILTER其实是一个函数指针:

     

    typedef LONG (WINAPI *LPTOP_LEVEL_EXCEPTION_FILTER)(      struct _EXCEPTION_POINTERS *ExceptionInfo

    );

     

    ExceptionInfo包含了对异常的描述以及发生异常的线程在当时的处理器状态,我们的过滤函数可以通过返回不同的值来让系统在异常抛出点继续运行、继续搜索异常处理函数(可能会给调试程序一个机会)或是退出应用程序。

    每当有一个SEH发生时,如果系统找不到合适的处理代码,就会调用我们所设置的过滤函数。由于这个函数是线程无关的,我们只需要设置这个过滤函数就可以对整个游戏中未被捕获的结构化异常进行处理。

    C++中也有一个类似的函数,叫做set_terminate,其原型如下:

     

    typedef void (__cdecl *terminate_function)();terminate_function set_terminate(      terminate_function term_func

    );

     

    它也可以装载一个处理函数来处理任何未捕获的C++异常,唯一的区别是它的处理函数没有任何返回值,因此退出这个函数后C++运行库就会调用abort退出应用程序。

     

    5.      入口函数

    几 乎每个Windows下的C++程序员都应该知道main和WinMain,前者是控制台程序的入口函数,后者是Win32程序的入口函数。这些函数会在 静态初始化完毕后被调用,并且开始应用程序的主要工作。但是这些函数只不过是C/C++的入口函数,而不是应用程序真正的入口函数,也就是说,操作系统装 载应用程序后,它并不是直接调用我们的main/WinMain,而是调用另一个由CRT提供的函数,由那个函数进行一些初始化工作(包括静态初始化)以 后再调用我们的入口函数。下面的表格中列出了在通常的Windows应用程序中CRT所提供的入口函数:

     

    CRT入口函数 相应的C/C++入口函数 相应的应用程序类型 WinMainCRTStartup WinMain Win32应用程序(非Unicode) wWinMainCRTStartup wWinMain Win32应用程序(Unicode) mainCRTStartup Main Win32控制台程序(非Unicode) wmainCRTStartup wmain Win32控制台程序(Unicode)

     

    (表一)

    仅以mainCRTStartup为例,如果我们写一个这样的GEHmainCRTStartup:

     

    int GEHmainCRTStartup (){      //进行我们自己的初始化工作      return mainCRTStartup ();}

     

    并把它指定为程序的入口点,就可以让我们自己的代码在CRT入口函数之前执行。

    先试试看我们到目前为止的成果,把下面这段代码加入到一个Win32控制台项目中,并且把linker选项中的入口点设为GEHmainCRTStartup,我们可以看到静态初始化中抛出的结构化异常已经被捕获了。

     

    #include <windows.h>#include <stdio.h>extern "C" int mainCRTStartup();LONG WINAPI GEHExceptionFilter( _EXCEPTION_POINTERS* ExceptionInfo ){      printf( "caught .../n" );      ExitProcess( 0 );      return EXCEPTION_CONTINUE_SEARCH;}extern "C" int GEHmainCRTStartup(){      SetUnhandledExceptionFilter( GEHExceptionFilter );      return mainCRTStartup();}struct     Bug()      {            *( int* )0 = 0;      }}bug;int main(){}

     

    6.      C++异常

    把 上面程序中的*( int* )0 = 0;改成throw 1,然后编译运行,就会发现我们目前所使用的机制并不能够有效地捕获C++异常。这是因为VC中虽然使用SEH作为实现C++异常的机制,但是对于未捕获 的C++异常,它有自己的处理方式。这不还有一个set_terminate函数没用吗?正好用在这里!于是我们写一个C++异常处理函数: GEH_terminate,然后在GEH入口函数里面加入如下的语句:

    set_terminate( GEH_terminate)。这样做看上去似乎一切正常,甚至可以通过编译,但是这个做法并不可行,至少是不安全的。因为我们并不能像使用 SetUnhandledExceptionFilter一样使用set_terminate,SetUnhandledExceptionFilter 是一个Win32函数,而set_terminate是一个C++函数。在GEH入口函数中,C++的初始化工作还没有进行,因此过早地设置这个处理函数 很可能会导致错误,至少在VS 2002中,如果把项目的代码生成方式设为multithreaded的话,在GEH入口中调用这个函数会导致程序崩溃。

    如果我们的库只能处理SEH而不能处理C++异常,那显然是一个很大的缺憾。可是C++也的确没有提供如此低级的处理措施(因为对C++编译器而言,入口代码不是程序员应该操心的事情)。于是,我们只能通过一个更低级的方法来解决这个问题。

    在VC(VC 6/VC 2002/VC 2003)中,所有未被捕获的C++异常最终都会调用__CxxUnhandledExceptionFilter来进行处理,如果可以让程序每次调用这个函数时转而调用我们提供的函数,一切问题就迎刃而解了。

    要 做到这点,最简单的方法就是直接替换__CxxUnhandledExceptionFilter函数的前几个字节,把它修改成一个far jmp,跳转到我们提供的处理函数中。通常这样做时必须确保被修改函数的函数体超过5个字节,幸好通常都是这样的,而 __CxxUnhandledExceptionFilter也不例外。__CxxUnhandledExceptionFilter的原型是

     

    long __stdcall __CxxUnhandledExceptionFilter(struct _EXCEPTION_POINTERS *);

    因 此我们可以借用一下GEHExceptionFilter来作为它跳转的归宿。由于__CxxUnhandledExceptionFilter位于代码 段中,而代码段缺省情况下是不可写的,因此我们必须先修改相应内存段的保护属性。下面这段代码会先把 __CxxUnhandledExceptionFilter所处的内存段改成可读写的,并且把前5个字节修改为一个跳转到 GEHExceptionFilter的far jmp指令。

     

    DWORD oldProtect;VirtualProtect( __CxxUnhandledExceptionFilter, 5,      PAGE_EXECUTE_READWRITE, &oldProtect );*(char*)__CxxUnhandledExceptionFilter = ‘/xe9’;// far jmp*(unsigned int*)( (char*)__CxxUnhandledExceptionFilter + 1 ) =       (unsigned int)GEH_terminate -      ( (unsigned int)__CxxUnhandledExceptionFilter + 5 );VirtualProtect( __CxxUnhandledExceptionFilter, 5, oldProtect, &oldProtect );

     

    把这段代码加入GEHmainCRTStartup,然后编译运行。可以看到我们的程序终于能够处理未捕获的C++异常了。

     

    注:Visual C++ 2005对未捕获C++异常的处理有所改变,只需要使用结构化异常过滤函数就可以捕获到未捕获的C++异常,因此不必动态修改执行代码即可。并且,由于__CxxUnhandledExceptionFilter不复存在,因此我们必须删除上述代码,否则会导致连接错误。

     

    7.      怎样不妨碍开发人员的正常调试

    前 面提到了一个过于“积极”的异常处理器通常会给开发人员的调试工作带来很大的麻烦,因此当我们的程序正在被调试时,GEH最好不起作用。Windows Platform SDK提供了一个函数IsDebuggerPresent来判断当前是否有调试程序的存在。我们可以在GEH入口函数中使用这个函数进行判断,如果程序正 在被调试,那么就跳过我们的主函数体,直接调用CRT入口函数。

     

    8.      线程

    GEH也可以正确地捕获其它线程中的未捕获异常,无论这个线程是使用Win32函数还是CRT函数创建的,这可以通过一个简单的测试程序来验证。

    不 过对于线程来说,GEH有一个更为有趣的作用。虽然大多数游戏都不是典型的多线程程序,但是通常游戏中往往会使用多个线程,除了主线程以外,其它线程往往 起到数据读写或是音乐播放等辅助作用。如果一个音乐播放线程中出现了未捕获的异常,我们或许希望游戏可以继续运行下去,毕竟没有音乐的游戏总比崩溃的游戏 要好很多(上帝,请原谅我说的这句话)。因此,可以从逻辑上把游戏中的线程分为两种:重要的和不重要的,前者遇到未捕获异常时,我们保存当前的状态以便于 开发人员进行调试,并且退出游戏;后者遇到未捕获异常时,我们也保存当前状态,但是并不退出游戏,只是中止当前线程的运行。因此,我们需要做的就是在程序 中创建线程时,把那些不重要线程的标识保存下来。因为我们的异常过滤函数是在发生异常线程的上下文中运行的,因此如果当前线程是一个不重要线程,我们可以 选择永远挂起这个线程来让游戏继续运行下去。

     

    9.      使用lib

    前面提到过,我们希望尽量避免修改现有的代码,因此下一步就是要把这些函数放入lib中,这样每次把GEH加入新项目时只需要在linker选项里面指定一下入口点并且加一个lib就可以了。

    因为我们目前需要支持四个不同的入口函数(参见表一),我们就需要四个不同的GEH入口函数。下面的代码就可以生成这样一个lib:

     

    #include <windows.h>#include <stdio.h>extern "C" int mainCRTStartup();extern "C" int wmainCRTStartup();extern "C" int WinMainCRTStartup();extern "C" int wWinMainCRTStartup();long __stdcall __CxxUnhandledExceptionFilter(struct _EXCEPTION_POINTERS *);static LONG WINAPI GEHExceptionFilter( _EXCEPTION_POINTERS* ExceptionInfo ){      printf( "caught .../n" );      ExitProcess( 0 );      return EXCEPTION_CONTINUE_SEARCH;}static void setupHandlers(){      SetUnhandledExceptionFilter( GEHExceptionFilter );      DWORD oldProtect;     VirtualProtect( __CxxUnhandledExceptionFilter, 5, PAGE_EXECUTE_READWRITE, &oldProtect );      *(char*)__CxxUnhandledExceptionFilter = ‘/xe9’;// far jmp      *(unsigned int*)( (char*)__CxxUnhandledExceptionFilter + 1 ) =             (unsigned int)GEHExceptionFilter - ( (unsigned int)__CxxUnhandledExceptionFilter + 5 );      VirtualProtect( __CxxUnhandledExceptionFilter, 5, oldProtect, &oldProtect );}extern "C" int GEHmainCRTStartup(){      setupHandlers();      return mainCRTStartup();}extern "C" int GEHwmainCRTStartup(){      setupHandlers();      return wmainCRTStartup();}extern "C" int GEHWinMainCRTStartup(){      setupHandlers();      return WinMainCRTStartup();}extern "C" int GEHwWinMainCRTStartup(){      setupHandlers();      return wWinMainCRTStartup();}

     

    然后再创建一个简单的测试程序:

     

    int main(){      throw 1;}

     

    这 看上去非常简单,可惜在修改入口点设置并且加入前面生成的GEH.lib以后,在连接时会发生错误。这是因为我们的代码在连接时,通常是以obj文件为单 位被连入最终应用程序的,因此位于同一个obj文件(也就是同一个cpp文件)中的所有全局函数/变量只要有一个被使用到,整个obj文件都会被连接到应 用程序中,这意味着连接程序会试图找出该obj中所引用的所有外部符号,但是这里我们只会定义一个主函数,因此连接程序必然无法找到其余三个,从而导致连 接错误。这可以通过把这四个GEH入口函数放入不同的cpp文件来解决。

     

    10.  再懒一点

    现在只需要把 GEH.lib加入项目,并且把入口点设为GEH入口函数就可以截获所有的未捕获异常了。可是要点击那么多次鼠标另加按下数十次按键总是让人觉得十分费 力。好在微软总是那么善解人意,我们可以方便地用宏来把这几十次的肢体运动变为一次鼠标点击或是一个快捷键(使用声控或是意念或许会更加省力)。下面这段 宏在执行时可以自动地为所有选中的项目进行参数设置使之自动享有GEH的一切保障:

     

    Imports EnvDTEImports System.DiagnosticsImports Microsoft.VisualStudio.VCProjectEnginePublic Module GEH    Sub GEHSetProjectProperty()        Dim project As Project        Dim vcproject As VCProject        Dim configure As VCConfiguration        Dim linker As VCLinkerTool        Dim patchNum As Integer = 0        For Each project In DTE.ActiveSolutionProjects()            vcproject = project.Object            For Each configure In vcproject.Configurations                If configure.ConfigurationType = ConfigurationTypes.typeApplication Then                    linker = configure.Tools("VCLinkerTool"                    If (configure.CharacterSet = charSet.charSetUnicode) Then                        If (linker.SubSystem = subSystemOption.subSystemConsole) Then                            linker.EntryPoi ...

    收藏到:Del.icio.us


    最新回复(0)