C#的Interop在C/C++应用插件领域开发的优越性

C/C++应用程序插件通常使用模块注入的方式使代码挂载到进程,并对程序内的内存空间,对象和函数进行操作。这通常要求我们使用较为底层的编程语言进行开发。但同时,我们也希望插件开发尽量简易,使用C/C++虽然能保持很好的内存一致性,但是对代码开发的要求通常较高,且不稳定。

C#作为高级语言,从设计理念上就引入了托管与非托管的概念,拥有着良好的内存互操作性,能够在享受高级语言所带来的开发减负以外,获得较舒适的内存一致性支持。

结构体

C/C++最常见的内存布局来自于结构体。C#同样拥有着原生结构体的支持,可以使用 struct 关键字进行声明。

通常来说,只要满足特定条件(字段都为非空非托管类型), struct 就可以成为非托管类型。非托管类型会直接分配在栈上,不进入托管堆,复制通常使用值拷贝,而非引用拷贝。值类型可以通过显式ref进行引用传递,ref传递也允许你写出链式的修改字段操作。函数可以通过 unmanaged 泛型约束限制传入的类型必须为非托管类型。

对于ref变量的操作,也需要注意如果发生赋值传递,左侧的类型必须为 ref var 才能显式声明为引用传递,否则则会发生默认的值传递。

C#的指针也可以被应用于值类型。你可以直接将一个nint类型的指针转为非托管类型指针,再通过Unsafe方法转为ref进行操作。(或者直接在unsafe代码块中通过指针运算符进行操作)。这一部分的编程体验和C/C++类似。

C#的ref struct是限制更为严苛的结构体。他要求这个结构体只允许被分配在栈上。一个典型的例子是 Span<T> 。他是C#标准库中提供的一个用于高性能访问连续内存的类型,也从设计上使开发者能够更简单的实现高性能零拷贝操作。

从插件框架API设计的角度,如果你希望向用户提供一个非托管的结构体,但是你希望一部分访问被包装(将指针包装为可以在安全代码块中操作的ref,索引访问器等),可以使用C#的属性语法(Property)进行优雅的包装。

内存一致性的实现也存在一些限制。C++允许你写出复杂的多继承和const template泛型。但是C#的结构体禁止继承。这一点可以通过组合字段的方式进行内存排列,但是实践上往往我们不会选择包装过于复杂的结构体。

C#提供了一些注解,如 [StructLayout][FieldOffset] 允许你精准控制结构体字段的内存排列方式。特别的,你可以对多个字段类型声明相同的 FieldOffset,用于模拟C结构体中的联合(union)数据类型。

内存布局也存在着一些你可能跳进的坑。一个需要注意的例子便是C#的string内部使用UTF16字符进行编码。所以在操作c风格字符串时,你需要使用 Marshal.PtrToStringUTF8 等方法进行操作。

C#的 StructLayout 属性的Pack字段允许你控制结构体内部的字段如何进行内存对齐。但是目前没有办法对结构体实现栈地址分配对齐。这意味着,在Linux系统上,有些SIMD优化指令要求传入的地址为16字节对齐,如果传入简单的栈指针则会导致崩溃。C#提供了 NativeMemory.AlignedAlloc 方法指定对齐的内存分配。例如 NativeMemory.AlignedAlloc(256, 16); 就可以分配一个16字节对齐的256字节大小的内存空间。

示例

一个简单的向量实现:

[StructLayout(LayoutKind.Sequential, Pack = 4, Size = 12)]
public struct Vector
{
    public float X;
    public float Y;
    public float Z;
}

等同于:

float* vec = stackalloc float[3];

如果需要16字节内存对齐分配:

var ptr = NativeMemory.AlignedAlloc((nuint)sizeof(Vector), 16);
NativeMemory.Free(ptr);

你也可以为指针创建扩展,进行更方便的内存操作,一个真实项目中的扩展样例如下:

internal static class PtrExtensions
{

  [MethodImpl(MethodImplOptions.AggressiveInlining)]
  public static T Read<T>( this nint ptr ) where T : unmanaged
  {
    unsafe { return Unsafe.Read<T>((void*)ptr); }
  }

  [MethodImpl(MethodImplOptions.AggressiveInlining)]
  public static T Read<T>( this nint ptr, int offset ) where T : unmanaged
  {
    unsafe { return Unsafe.Read<T>((void*)(ptr + offset)); }
  }

  [MethodImpl(MethodImplOptions.AggressiveInlining)]
  public static T Read<T>( this nint ptr, nint offset ) where T : unmanaged
  {
    unsafe { return Unsafe.Read<T>((void*)(ptr + offset)); }
  }

  [MethodImpl(MethodImplOptions.AggressiveInlining)]
  public static ref T AsRef<T>( this nint ptr ) where T : unmanaged
  {
    unsafe { return ref Unsafe.AsRef<T>((void*)ptr); }
  }

  [MethodImpl(MethodImplOptions.AggressiveInlining)]
  public static ref T AsRef<T>( this nint ptr, int offset ) where T : unmanaged
  {
    unsafe { return ref Unsafe.AsRef<T>((void*)(ptr + offset)); }
  }

  [MethodImpl(MethodImplOptions.AggressiveInlining)]
  public static ref T AsRef<T>( this nint ptr, nint offset ) where T : unmanaged
  {
    unsafe { return ref Unsafe.AsRef<T>((void*)(ptr + offset)); }
  }

  [MethodImpl(MethodImplOptions.AggressiveInlining)]
  public static ref T Deref<T>( this nint ptr ) where T : unmanaged
  {
    return ref ptr.Read<nint>().AsRef<T>();
  }

  [MethodImpl(MethodImplOptions.AggressiveInlining)]
  public static ref T Deref<T>( this nint ptr, int offset ) where T : unmanaged
  {
    return ref ptr.Read<nint>(offset).AsRef<T>();
  }

  [MethodImpl(MethodImplOptions.AggressiveInlining)]
  public static ref T Deref<T>( this nint ptr, nint offset ) where T : unmanaged
  {
    return ref ptr.Read<nint>(offset).AsRef<T>();
  }

  [MethodImpl(MethodImplOptions.AggressiveInlining)]
  public static void Write<T>( this nint ptr, T value ) where T : unmanaged
  {
    unsafe { Unsafe.Write((void*)ptr, value); }
  }

  [MethodImpl(MethodImplOptions.AggressiveInlining)]
  public static void Write<T>( this nint ptr, int offset, T value ) where T : unmanaged
  {
    unsafe { Unsafe.Write((void*)(ptr + offset), value); }
  }

  [MethodImpl(MethodImplOptions.AggressiveInlining)]
  public static void Write<T>( this nint ptr, nint offset, T value ) where T : unmanaged
  {
    unsafe { Unsafe.Write((void*)(ptr + offset), value); }
  }

  [MethodImpl(MethodImplOptions.AggressiveInlining)]
  public static void CopyFrom( this nint ptr, byte[] source )
  {
    unsafe
    {
      fixed (byte* sourcePtr = source)
      {
        Unsafe.CopyBlockUnaligned((void*)ptr, sourcePtr, (uint)source.Length);
      }
    }
  }


  [MethodImpl(MethodImplOptions.AggressiveInlining)]
  public static void CopyFrom( this nint ptr, nint source, int size )
  {
    unsafe { Unsafe.CopyBlockUnaligned((void*)ptr, (void*)source, (uint)size); }
  }

  [MethodImpl(MethodImplOptions.AggressiveInlining)]
  public static void CopyFrom( this nint ptr, int offset, nint source, int size )
  {
    unsafe { Unsafe.CopyBlockUnaligned((void*)(ptr + offset), (void*)source, (uint)size); }
  }

  [MethodImpl(MethodImplOptions.AggressiveInlining)]
  public static bool IsValidPtr( this nint ptr )
  {
    return ptr != 0 && ptr != IntPtr.MaxValue;
  }

  [MethodImpl(MethodImplOptions.AggressiveInlining)]
  public static T AsHandle<T>( this nint ptr ) where T : INativeHandle, ISchemaClass<T>
  {
    return T.From(ptr);
  }
}

调用非托管函数

C#也有函数指针的概念,可以通过P/Invoke调用非托管函数。你可以使用Marshal类中的方法将一个指针转换成带类型的函数delegate。

// 声明函数类型
delegate void TestDelegate(int a);

// 获取委托
var func = Marshal.GetDelegateForFunctionPointer<TestDelegate>(ptr);

之后的func可以直接被调用。不得不说这一块C#做的确实是挺方便的。

你也可以直接声明一个delegate*函数指针,语法如下。

unsafe delegate* unmanaged<int, int> TestPtr = (delegate* unmanaged<int, int>)(void*)0x00000000;

非常hack的语法。泛型最后一个参数指的是返回值类型,传入void代表无返回值。

导入函数

C#提供了LibraryImport和DllImport注解帮助你导入符号函数。符号名和实际符号名相统一(可能是extern "C"的原始符号名,也可以是C++的mangle name)。LibraryImport较新引入,推荐使用,具体实现细节这里不多赘述。

调用约定

如果你像我一样,开发范围仅限于x64,那么恭喜你,能摆脱调用约定的困扰。但是如果你也需要x86支持,不用担心,C#也提供了对不同调用约定的支持。

在delegate上,你可以使用UnmanagedFunctionPointer属性指定一个delegate的调用约定类型。

[UnmanagedFunctionPointer(CallingConvention.FastCall)]
delegate void TestDelegate();

对于函数指针,你也可以使用如下语法。

 unsafe delegate* unmanaged[Fastcall]<int, int> TestPtr = (delegate* unmanaged[Fastcall]<int, int>)(void*)0x00000000;

Marshalling

对于一些特殊的值类型,C#提供了自定义的Marshal方案,方便用户指定该如何进行参数的传递。

这里特别要说明的是C#的bool类型。C#对bool类型的Marshal默认行为是以4字节方式传递。这和目前绝大部分bool设计不符合,所以开发者在处理时必须小心。可替代方案是将所有bool替换为byte类型,或者使用 MarshalAs 属性进行指定。如果无法处理妥当,则可能出现难以追踪的内存错误(特别是在非托管调用托管函数时)。

在结构体章节中也提到string的底层是UTF16。如果你试图使用fixed语句将string转为char*(这在C#是完全允许的),并且直接转为byte*传递给一个接受char*的C函数时,将会造成错误。正确方法是使用 Encoding.UTF8.GetBytes 进行转换。

ABI

虽然C#对于Linux的支持已经大部分完善,但是仍然存在许多坑需要注意。一个典型的例子是Linux参数的16字节结构体寄存器展开。当一个小于等于16字节的结构体在值传递时,SystemV ABI往往会将其展开到两个寄存器进行直接传递,而C#不会默认进行这种行为。你需要时刻注意,并且确保寄存器统一,否则将会出现难以追踪的内存错误。

虚函数

在处理C++虚函数时,需要注意第一个参数为this指针。

非托管调用托管函数

C#也允许你创建非托管函数指针,传递给非托管代码进行调用。大部分关于函数的概念前面已经叙述过,这里介绍一下创建函数指针的方法。

第一种是在static函数上设置UnmanagedCallerOnly属性,并且直接通过 & 操作符取这个函数的指针(之后需要进行类型转换)。

第二种是使用Marshal函数:

nint ptr = Marshal.GetFunctionPointerForDelegate(Func);

需要注意的是,这些托管函数必须保证不被GC。否则C#将会抛出导致崩溃的异常。

C#有GCHandle允许你完善的管理一个callback的生命周期。

Hook

对于插件场景,一个常用的需求是inline hook。得益于C#的非托管函数指针,你可以配合其与Inline hook库(如safetyhook)无缝使用。你可以直接将函数指针作为一个hook的target,将其install在非托管函数上,实现inline hook。注意必须保证托管函数的参数签名与非托管函数必须完全一致,否则将会导致寄存器不匹配,最终导致内存错误。典型的例子有忘记加返回值,导致返回垃圾值致使后续函数调用出现错误。这一部分也与前面提到的小结构体展开有关。

SafeHandle

C#提供了SafeHandle类帮助你管理非托管对象的生命周期。他通过 IDisposable 和引用计数实现handle的生命周期管理。你可以继承SafeHandle类实现自己的SafeHandle。SafeHandle可以作为LibraryImport或DllImport导入函数的返回值,C#会自动处理类型。使用SafeHandle管理指针生命周期是最佳实践。

除了让你的结构体继承SafeHandle,也可以组合式的实现SafeHandle(将自定义的SafeHandle类设置为wrapper的字段)。这一模式常用于Native对象的Wrapper类。当Dispose被调用时,一同调用字段里SafeHandle的Dispose,即可实现生命周期管理。这一模式的好处是SafeHandle相关的方法不会被暴露给外部API。

内存分配器

C#有多种内存分配器:CoTaskMemAlloc/Free,Alloc/FreeHGlobal和NativeMemory类。为了分清楚区别,建议直接参考CoreCLR的源代码和C#底层实现(数据来自当前文章发布日期)。

CoTask

Windows

导入combaseapi.h的 CoTaskMemAlloc 等函数:

CoTaskMemAlloc(
    _In_ SIZE_T cb
);

Linux (minipal)


#ifndef HOST_WINDOWS
inline void* CoTaskMemAlloc(size_t cb)
{
    return malloc(cb);
}

inline void CoTaskMemFree(void* pv)
{
    free(pv);
}
#endif

HGlobal

Windows (Kernel32)

public static unsafe IntPtr AllocHGlobal(nint cb)
{
    void* pNewMem = Interop.Kernel32.LocalAlloc((nuint)cb);
    if (pNewMem is null)
    {
        throw new OutOfMemoryException();
    }
    return (nint)pNewMem;
}

Linux

public static unsafe IntPtr AllocHGlobal(nint cb)
{
    return (nint)NativeMemory.Alloc((nuint)cb);
}

NativeMemory

Windows

public static void* Alloc(nuint byteCount)
{
    // The Windows implementation handles size == 0 as we expect
    void* result = Interop.Ucrtbase.malloc(byteCount);

    if (result == null)
    {
        ThrowHelper.ThrowOutOfMemoryException();
    }

    return result;
}

Linux

public static void* Alloc(nuint byteCount)
{
    // The C standard does not define what happens when size == 0, we want an "empty" allocation
    void* result = Interop.Sys.Malloc((byteCount != 0) ? byteCount : 1);

    if (result == null)
    {
        ThrowHelper.ThrowOutOfMemoryException();
    }

    return result;
}

可以看到三者差异并不明显,在Linux操作系统上更是全部使用了malloc/free函数。作为推荐方式,当下建议全部使用NativeMemory。

宿主程序分配器

有些时候插件的宿主程序会使用自己的分配器(例如mimalloc),并且重定向所有的ucrt分配函数和malloc/free分配函数。这类情况下建议在自己的native里动态链接到宿主程序相同的分配函数,并在C#使用,以保证内存一致性。

虚函数表

通过struct和非托管函数的结合,你可以在C#中实现模拟C++的虚函数表。将struct +0x0位置的字段设置为虚表结构体指针,虚表结构体里再定义一系列unmanaged函数指针,即可实现类似效果。

例子:

[StructLayout(LayoutKind.Sequential)]
public unsafe struct VTable
{
    public delegate* unmanaged<nint, void> Destructor;
    public delegate* unmanaged<nint, int> GetValue;
}

[StructLayout(LayoutKind.Sequential)]
public struct CppObject
{
    public VTable* VTablePtr;
    // ...
}

崩溃分析

C#原生提供.pdb文件作为符号,在Windbg中可以直接加载并查看callstack。如果一个托管调用导致内存崩溃,C#的调用者也会出现在callstack上。这一功能给debug带来极大的便利。

BenchmarkDotnet有反汇编支持,你可以通过这个框架来观察C#编译出的汇编代码,这里不多赘述。

在Linux上LLDB也有较好的.NET调试支持,可以通过安装相应插件来实现对托管堆的分析和callstack分析。

此外,.NET也提供了一系列工具进行dump分析和运行时分析(如dotnet-monitor,dotnet-sos)。此类工具也可用于分析内存占用,GC,死锁等问题。

CoreCLR Profiling API也是.NET的一大亮点。它提供了一系列详细的接口,允许你对CoreCLR侧的事件进行全面监控和分析。在实践中,结合breakpad,可以实现用户侧的崩溃追踪。

未完待续...

Comments