虚幻引擎的业务逻辑开发基本上都是用C++/蓝图,当因为项目代码写的不好遇到Crash等问题时,如果不了解Native程序和引擎底层的一些机制,相比用C#开发业务的Unity或其他完全基于脚本虚拟机的游戏确实要难处理一些。因为业务和引擎代码本身都是基于C++,所以对于解决常规C++的Crash的方法虚幻引擎完全适用,除此外引擎在异常处理上相比于普通的C++程序还是提供了一些额外的方法和工具。本文主要介绍虚幻引擎在处理Crash时的一些做法和经验技巧。

常规崩溃定位

当游戏崩溃时,对于开发来说肯定是希望能定位到哪行代码崩了,发生崩溃当时的内存是什么样的,在虚幻引擎里这个工作是引擎自动做的。引擎会将崩溃的dump文件保存在Saved/Crashes/下面,编辑器的位置如下图

运行时的游戏包的位置也类似,PC版就是在游戏目录,安卓在Android/data/(游戏包名)/下面,iOS就在app对应的Documents目录下。当有多次崩溃时,可以自己按照修改日期排序,找最新的即可

打开后可以看到有这么多信息。

  • dmp文件:这个文件崩溃的dump信息,可以把这个文件直接拖到visual studio里,就会自动跳到崩溃现场的那一行代码上。
  • log文件:这个文件就是崩溃时的log信息,可以根据打出的日志做一些崩溃辅助判断。比如在崩溃之前做了哪些关键操作。
  • runtime-xml文件:这个文件用文本记录了崩溃时的现场,包括堆栈,崩溃的代码等,本质上和dmp文件差不多,因为dmp是二进制文件并不可读,在手上没有符号文件时,这个文件可以用于分析崩溃。如下图可以看到

了解了引擎的这一个功能,基本上80%的崩溃都可以定位出来。像是最常见的Signal 11,靠这个就初步能查到现场是哪里。

卡死检测

有时候我们很难根据崩溃的现场查到是什么原因崩溃的想在一些关键位置输出堆栈或内存等信息。或者不一定是崩溃,而是死循环卡死了,那么肯定不会有上面这样的dump信息输出。引擎接入了Lua或其他脚本语言,想在脚本出异常时,肯定也有想要顺便输出一下C++堆栈的情况。因此肯定还是希望能够自己有一些办法在代码里主动输出当前的堆栈。引擎也提供了这样的方法,可以见StackWalk类:

这里有一系列的函数,比如可以通过StackWalkAndDump函数将当前的堆栈输出到字符串里。当然如果不是GameThread,这个类也提供了dump指定线程的堆栈。比如lua脚本里的代码崩溃了,但因为lua的崩溃有一个通用函数兜底,C++肯定不会直接崩,我们这时就可以手动调用这样的函数,将C++的堆栈写到log里。

对于业务卡死,虚幻引擎也封装了一个单独的守护线程ThreadHeartBeat,当检测到某个线程的心跳超时时,内部也是调用上面的函数将卡死的线程堆栈输出到log里,如下图。

当然自己的业务也可以仿照上面的做法封装。这个功能默认是关闭的,毕竟有额外线程的开销,需要项目自己根据情况把USE_HANG_DETECTION宏打开。

内存随机崩溃或泄漏

内存写坏,程序随机崩溃的这个问题,我想应该是大多数项目最苦恼的问题了。其实虚幻底层也对解决这些问题提供了一些辅助定位的代码。因为本身是内存问题,因此这些工具代码也是在内存上下功夫的。

我们知道虚幻本身有在全局重载C++的new和delete,在业务分配和释放内存时,实际调用的是引擎的FMemory类中的Malloc和Free。而引擎会根据情况从内存池去获取内存。而内存池本身的内存,是引擎根据时机去向系统要内存。如果你用过引擎的LLM统计内存,应该就知道LLM有两个Tracker,Default和Platform,这两个口径也就对应业务向引擎要内存和引擎向操作系统要内存这两种情况。

这张图片来源于网络,如侵权请告知删除

其中LLM Default和LLM Platform就如下图所示的关系。我们平常一直说UE4/UE5的项目不要使用STL也是因为这个机制。因为STL内部有自己的allocator,在没有指定allocator时所有的内存分配都不受引擎管理,而且因为STL本身只有头文件,即使明显指定了allocator,在跨dll使用时也可能因为疏忽造成一些内存问题。

这里重点是FMemory内部可以使用多种分配器,且有的分配器是可以嵌套的,对于上层业务来说无感知的,引擎默认一般会使用Binned2或Binned3,内部会按照size做内存池,而内存池不够时,每次向系统申请的都是固定大小的Chunk。因为本文重点不是讲内存管理以及LLM,就不过多展开了。

为了查内存写坏的问题,就需要在这里打开一些特殊的分配器。最常用的就是下面几个:

  • Ansi:这个是标准的分配器,也就是让UE4不使用任何额外的内存管理,就直接走平台原生的new和delete,有时候需要用到平台的一些内存工具,开到这个模式会非常好。比如在iOS平台上需要查内存泄漏问题,如果使用默认的Binned2/Binned3,那么用Xcode自带的Instruments肯定查不到泄漏的具体代码在哪,看到的都是内存池在申请,而开到Ansi就可以定位到内存泄漏的现场。

  • Stomp:这是引擎提供的查内存写坏的利器,一般开了这个模式,崩溃时的第一现场就是内存写坏的代码位置。具体原理是利用了操作系统的虚拟地址这个概念,我们知道向系统要内存时,拿到的指针其实只是一个虚拟地址,真正是否分配了物理内存是会根据情况来决定的。可以看下图注释,能解决这几种问题:

在这个模式下,每次分配内存的指针地址只会增加,不会减少。真正用到的内存,会要求系统分配对应的物理内存,而用完的内存需要释放时,就只取消虚拟地址和物理内存之间的映射关系,并不回收对应的虚拟地址。因为这样的操作很特殊,所以不能直接使用malloc等函数向系统要内存。在windows上是用VirtualAlloc函数,其他平台是用mmap函数。在回收时windows使用VirtualFree,其他平台用munmap函数。可以看下面的说明。

我们知道,内存写坏随机崩溃,基本就是因为崩溃的时候都不是第一现场。在正常模式下,第一现场很大概率就是正常的内存,因为内存本身合法是不会崩的,但却写坏了正常内存的数据。而Stomp模式下,地址只增不减,一定不会出现地址被复用的情况,那么只要写到不该写的内存,比如回收后只有虚拟地址的这些指针,就会第一时间崩溃。UE5也提供了Stomp2可以运行时通过命令行-stomp2malloc打开

  • PoisonProxy:这个模式就像名字说的一样,把内存都涂上毒。主要是解决没使用没初始化或free掉的内存,如下图注释所说。

原理就是在分配或释放后,通过Memset把内存填充成下面这样的神奇编码

如果是老程序员应该对0xcd,0xcc,0xdd这种值很有印象,毕竟windows系统也是这么干的,一堆0xcccc的GBK中文看到就是烫烫烫,0xcdcd的GBK中文看到就是屯屯屯。当出现崩溃时,会显示这样的地址,那么可以根据是0xcc和0xcd区分出来是没初始化还是用了释放的内存,这样就能定位到了代码出问题的第一现场。

  • ReplayProxy:这个是用来录内存分配到文件的,有时候查一些性能问题可能会有用吧,我自己没有用过。

当然除此外,还可以使用一些外部工具来查内存问题,比如最常用的ASan,这里就不细说了。

降低野指针导致崩溃的技巧

引擎在判断UObject是否合法时,提供了依据编程经验或者说指针特性来检测野指针的思路,我们也可以拿来参考,比如下面这个IsValidLowLevelFast

可以看到前面这3个if的代码很有意思。假如this是合法对象,那么指针一定不为nullptr或者不会小于0x100,且指针数值一定是8的倍数,且指针的指针也就是虚函数表一定不是nullptr。仔细想想,正常Object对象的指针确实都要满足这些情况,小于0x100的一般都是系统内部使用,系统都是按字节对齐的方式分配内存所以一定是8的倍数,经过这样的经验操作,基本上把八分之七以上的野指针都排除了,所以我也很建议把这个判断方法推广到业务使用。如果能明确当前的平台,也可以再加上一些额外判定条件

Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐