在 C++/CLI 中包装非托管指针时堆损坏

Heap corruption when wrapping unmanaged pointers in C++/CLI

我在使用本机 C 代码、C++/CLI 和 C# 的 .NET 应用程序中遇到堆损坏问题。这是我第一次真正深入这里的杂草。

应用程序的结构是用于 GUI 和整体控制流的 C#,用于包装本机 C 函数的 C++/CLI,以及用于处理数据的本机 C 函数。这些原生 C 函数通常接受指向数组(例如:int*)和维度的原生指针作为输入。 C++/CLI 将这些低级函数封装成更高级的组合处理函数,C# 调用高级函数。

有时,我确实需要在 C# 级别分配非托管内存,然后将同一块内存传递给几个不同的 C++/CLI 函数。

为了通过我的 C# 和 C++/CLI 层自由传递这些数组,我围绕托管指针创建了一个精简包装器 class。这个包装器称为 ContiguousArray,在 C++/CLI 层定义,看起来像这样:

template <typename T>
public ref class ContiguousArray
{
public:
  ContiguousArray<T>(int size)
  {
    _size = size;
    p = (T*) calloc(_size,sizeof(T));
  }

  T& operator[](int i)
  {
    return p[i];
  }

  int GetLength()
  {
    return _size;
  }
  ~ContiguousArray<T>()
  {
    this->!ContiguousArray<T>();
  }

  !ContiguousArray<T>()
  {
    if (p != nullptr)
    {
      free(p);
      p = nullptr;
    }
  }

  T* p;
  int _size;
};

// Some non-templated variants of ContiguousArray for passing out to other .NET languages
public ref class ContiguousArrayInt16 : public ContiguousArray<Int16>
{
  ContiguousArrayInt16(int size) : ContiguousArray<Int16>(size) {}
};

我在几个方面使用这个包装器 class。

用例 1 (C++/CLI):

{
  // Create an array for the low level code
  ContiguousArray<float> unmanagedArray(1024);

  // Call some native functions
  someNativeCFunction(unmanagedArray.p, unmanagedArray.GetLength());
  float* unmanagedArrayPointer = unmanagedArray.p;
  anotherNativeCFunction(unmanagedArrayPointer, unmanagedArray.GetLength());
  int returnCode = theLastNativeCFunction(unmanagedArray.p, unmanagedArray.GetLength());

  return returnCode;
} // unmanagedArray goes out of scope, freeing the memory

用例 2 (C++/CLI):

{
  // Create an array for the low level code
  ContiguousArray<float>^ unmanagedArray = gcnew ContiguousArray<float>(1024);
  cliFunction(unmanagedArray);
  anotherCLIFunction(unmanagedArray);
  float* unmanagedArrayPointer = unmanagedArray->p;
  int returnCode = nativeFunction(unmanagedArrayPointer, unmanagedArray->GetLength());
  return returnCode;
} // unmanagedArray goes out of scope, the garbage collector will take care of it at some point

用例 3 (C#):

{
  ContiguousArrayInt16 unmanagedArray = new UnmanagedArray(1024);
  cliFunction(unmanagedArray);
  unmanagedArray = anotherCLIFunctionThatReplacesUnmanagedArray(unmanagedArray); // Unmanaged array is possibly replaced, original gets collected at some point
  returnCode = finalCLIFunction(unmanagedArray);
  // Do something with return code like show the user
} // Memory gets freed at some point

我认为我在使用这个包装器 class 处理非托管内存时非常小心,但我一直在我的应用程序中看到堆损坏和访问冲突问题。我从不在 ContiguousArray 对象有效的范围之外保留指向非托管内存的本机指针。

这三个用例中的任何一个在理论上有什么问题会导致堆损坏吗?我是否遗漏了 ContiguousArray 实现中的关键内容?我担心垃圾收集器可能有点过分热心,在我真正处理完托管对象之前清理它们。

用例 1:我能保证在右大括号之前不会调用终结器吗? .NET 是否有可能决定不再使用该对象并在我仍然有指向其内部内存的指针时将其清理干净? GC::KeepAlive 是否需要用于堆栈对象?

用例 2:我是否需要在最后使用 GC::KeepAlive 来保证对象在第三次函数调用之前不被释放?如果我改为写:我还需要它吗? nativeFunction(unmanagedArray->p, unmanagedArray->GetLength());

用例 3:我看不出这里有什么问题,但也许我遗漏了什么?

首先,我假设 ContiguousArray<T> 中的成员被称为 size 而不是 _size 只是一个拼写错误。

就访问冲突而言,我在案例 3 中没有发现任何错误。在案例 2 中,数组肯定可以在 nativeFunction 使用其指针完成之前被垃圾回收。我不确定案例 1 是否有同样的问题。如果您使用 GC::KeepAlive,是否可以解决访问冲突问题?

堆损坏可能意味着内存在 !ContiguousArray<T>() 中被释放时已经被释放。本机方法是否曾经释放数组或 ContiguousArrays 是否曾交换拥有的数组?

P.S.,最好检查一下 calloc 是否返回 nullptr.

感谢写出我的问题(最好的老师)的魔力以及 tsandy 和 Hans 的建议,我详细研究了垃圾收集器在处理非托管资源时的行为。这是我的发现:

我使用的设计模式有缺陷。如果垃圾收集器决定不再使用托管对象句柄 (^),即使该句柄仍在范围内,它也可以被垃圾收集。一个适当的(但较慢的)设计模式不允许访问非托管资源,除非通过其托管包装器 class 的方法。如果允许指向非托管资源的指针或引用从包装器中泄漏,获取它们的代码需要非常小心以确保拥有它们的包装器不会获取 collected/finalized。出于这个原因,像 ContiguousArray 这样设计的包装器 classes 不是一个好主意。

也就是说,这个模式很快!因此,这是逐案挽救事物的方法。

Use Case 1 其实还可以!在 C++/CLI 中使用堆栈语义可确保在包装器超出范围时确定性完成。在包装器超出范围后保留指针仍然是一个错误,但总的来说是安全的。我更改了大量 C++/CLI 代码以强烈支持堆栈语义,包括尽可能使用句柄引用 (%) 作为仅由我的 C++/CLI 代码调用的函数的参数。

用例 2 很危险,需要修复。有时您无法避免使用句柄,因此您需要使用 GC::KeepAlive(unmanagedArray) 强制垃圾收集器保留对象直到调用 KeepAlive。

{
  // Create an array for the low level code
  ContiguousArray<float>^ unmanagedArray = gcnew ContiguousArray<float>(1024);
  cliFunction(unmanagedArray);
  anotherCLIFunction(unmanagedArray);
  float* unmanagedArrayPointer = unmanagedArray->p;
  int returnCode = nativeFunction(unmanagedArrayPointer, unmanagedArray->GetLength());
  GC::KeepAlive(unmanagedArray); // Force the wrapper to stay alive while native operations finish.
  return returnCode;
}

用例 3 在技术上不安全。在调用 finalCLIFunction 之后,.NET 垃圾收集器可能立即决定它不再需要 unmanagedArray(取决于 finalCLIFunction 的实现)。但是如果我们不需要的话,用像 KeepAlive 这样的实现细节来增加 C# 代码的负担是没有意义的。相反,永远不要尝试从 C# 代码访问任何非托管的内容,并确保我们所有 C++/CLI 函数的实现都为它们自己的参数调用 KeepAlive,如果这些参数是句柄的话。

int finalCLIFunction(ContiguousArrayInt16^ unmanagedArray)
{
  // Do a bunch of work with the unmanaged array
  Int16* ptr = unmanagedArray->p;
  for(int i=0; i < unmanagedArray->GetLength(); i++)
  {
    ptr[i]++;
  }

  // Call KeepAlive on the calling arguments to ensure they stay alive
  GC::KeepAlive(unmanagedArray);

  return 0;
}

那就这样吧。尽可能使用堆栈语义。如果不能,请在需要对象存活的最后一行之后使用 GC::KeepAlive() 。请记住在调用 C++/CLI 函数的参数时也这样做。让所有这些垃圾收集争论远离您的 C# 代码,它不需要知道这些实现细节。

我遵循了所有这些约定,我的堆损坏和访问冲突都消失了。希望这会对某人有所帮助。