2015年12月22日

内存崩塌:寻找并解决妖怪类问题

作者 Gil Gribb

在写错代码的创新的路上没有尽头,随机的内存覆盖几乎是动态内存分配相关的无法避免的问题。 有些系统会分配一些内存,使用一段时间,并返还给内存分配管理器,然后再使用的时候就会出现致命错误。 同时,内存分配管理器也会回收内存区块,再给其他系统使用。内存是一种共享资源,因此它可以被一个子系统,一个线程,甚至面向主机 GPU 的内存使用。

指向已经返还给内存分配控制器的内存区块的指针被称为过期指针(Stale)。无论是从过期指针中读取数据,还是通过它写入数据,都很可能引起错误甚至奔溃。 从过期指针中读取可能引起的问题并没有那么严重,因为通常会导致使用该指针的子系统立刻奔溃。 而通过过期指针写回数据则会造成比较奇怪的问题,并且往往看似和这个内存指针无关。

通常解决这类因为使用了过期内存而导致问题的方法,是采用一个特别的内存分配器,通过各种策略来定位问题。以下这些步骤可能对查找原因有所帮助:

  • 每当一个内存区块被释放的时候,都用一些特殊的字节去填充该区块,使得其他通过过期指针读它的系统较早的暴露问题(比如可以填 0xDEADBEEF)。
  • 每当一个内存区块释放时,用特殊字节去填充它,并闲置在那里。等一段时间后,再去检查这个区块内的字节是否符合释放时填入的字节模式。如果字节模式发生了改变,便说明有某些代码通过一个过期指针在访问这块内存。
  • 每当分配新的内存区块时,在该区块的前后增加额外的填充区块(PADDING),并在填充区块内填入特殊的字节模式。这种叫做金丝雀(Canary),正如“煤矿里的金丝雀”一般。当该内存区块释放时(或者其他运行时刻),可以检查前后填充区块(金丝雀),如果和预期的内容不同的话,就能认为某处代码要么填写了所需内存的额外头部,要么就是额外的尾部。这并不意味着一定是过期指针导致的问题,但这个做法能提前发现更糟糕的神秘错误行为。 (译者按:关于“煤矿里的金丝雀”的说法,据说矿工们带着鸟笼装着金丝雀进矿,如果二氧化碳这种有害气体浓度过高,金丝雀就会提前出线异样,这样作为给矿工们的警告以便立即撤出矿洞)
  • 现代的 CPU 都具备硬件上允许的内存范围,叫做页面,进行读写保护。这在接下来本文中重点讨论。

通过使用内存保护检查内存错误

现代 CPU 的虚拟内存系统提供了很多新功能,这些已经远超本贴的范围。为了检查内存的问题,我们使用这样两个功能:

  • 能够为内存分配宽广的地址空间。在某种意义上,这意味着我们不需要再同一区间的内存地址使用两次。这使得可以很容易的将合法内存访问从错误中区分出来。
  • 能够标记特定的内存地址区段不可读和不可写。任何由于过期指针而企图对这些区块进行读写访问的操作都会导致 CPU 的奔溃。

CPU 在内存保护上具有低粒度。内存被分配、映射、释放、保护,都是在页面(Page)内完成的。大部分 CPU 能支持不同的页面大小,但通常典型的最小页面是 4096 字节(bytes)。 因此,为了使用 CPU 的虚拟内存特性检查内存的问题,我们需要按照 4K 的大小来分配。当我们只需要 16 字节时也要分配 4096 是很大的浪费。因此如果是通过页面分配来使用内存保护的形式的内存分配器,将会使用大量内存。 一个通常情况下需要 2GB 的游戏,在这种情况下可能会需要占用 30GB 的内存。并且由于每个分配和释放都调用操作系统层面的操作,性能也会受到影响。

还能利用 CPU 的硬件特性来避免跨内存区块的读写访问。具体的做法是把内存区块放置到页面的末尾,并对下一个页面进行读写访问的保护。相应的可以把内存区块放到页面的最前面并对上一个页面进行读写保护来避免向上的跨区访问。对于一些奇怪的区块大小,或者小区块而言, 由于页面自身的粒度问题,则无法保护它们。

最近,Paragon 就遭受了看似由随机内存冲突引起的问题。幸运的是,在我们的开源社区有一位活跃的用户实现了一个很棒的内存分配器,并发起了提交申请,还有一个很棒的博文贴的讨论

我们采用了这个实现,立刻发现了五个严重的错误,一些更实际的错误要复杂得多,但简单看类似于这种:

GameplayCueDataMap.Add(ThisGameplayCueTag) = GameplayCueDataMap.FindChecked(Parent);

这里的 GameplayCueDataMap 是一个关联数组,虚幻 4 中的一种 TMap。首先 FindChecked 定位了一个 Parent 的元素,并返回了该元素的引用(也就是指针)(译者:这里可以忽略C++语义里的指针和引用的区别,这里的描述仅仅是一个指针的说辞,虽然它未必是传统意义的狭义指针的概念)。然后就调用了 Add,这导致了 TMap 内部的数据结构重新调整大小,这个过程释放了先前返回出来的指针指向的内存空间。最后又试图把这个过期指针进行引用。如果没有一个特别的用于调试的内存分配器,这样的错误很有可能发现不了,甚至都有可能无法暴露这个错误的代码行为。几乎可以肯定这也许会在半夜导致出一些奇怪的问题,或者好几天甚至一年后才会遇到它。

解决方法很简单,在可能摧毁容器前先强制获取引用,这样就不会存在过期的指针了。

int32 ParentValue = GameplayCueDataMap.FindChecked(Parent);
GameplayCueDataMap.Add(ThisGameplayCueTag, ParentValue);

幸运的是这类错误并不常见,当发生问题时,使用正确的工具是解决问题的关键。