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

本文关键字:指针 损坏 C++ CLI 包装 | 更新日期: 2023-09-27 17:56:00

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

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

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

为了在 C# 和 C++/CLI 层中自由传递这些数组,我围绕托管指针创建了一个精简包装类。这个包装器称为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) {}
};

我以几种方式使用这个包装类。

用例 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

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

从理论上讲,这三个用例中的任何一个都可能导致堆损坏,这有什么问题吗?我是否在我的连续阵列实现中缺少一些关键的东西?我担心垃圾收集器可能变得有点过分热心,并在我真正完成托管对象之前清理它们。

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

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

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

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

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

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

也就是说,这种模式很快!因此,以下是逐案挽救的方法。

用例 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 垃圾回收器可能会立即决定不再需要非托管数组(取决于 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# 代码之外,C# 代码不需要知道这些实现细节。

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

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

在访问违规方面,我认为案例 3 没有任何问题。 在情况 2 中,在使用数组指针完成之前nativeFunction数组绝对可以进行垃圾回收。 我不确定案例 1 是否有同样的问题。 如果您使用 GC::KeepAlive ,是否可以解决访问冲突?

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

附言,最好检查calloc是否没有返回nullptr.