Procedimientos recomendados de interoperabilidad nativa

.NET ofrece diversas formas de personalizar el código de interoperabilidad nativa. En este artículo se incluye la guía que siguen los equipos de .NET de Microsoft para realizar la interoperabilidad nativa.

Orientación general

La guía de esta sección se aplica a todos los escenarios de interoperabilidad.

  • ✔️ USE la misma nomenclatura y uso de mayúsculas y minúsculas para los métodos y parámetros que el método nativo al que quiere llamar.
  • ✔️ PLANTÉESE usar la misma nomenclatura y uso de mayúsculas y minúsculas para los valores constantes.
  • ✔️ USE los tipos de .NET que se asignen lo más cerca posible del tipo nativo. Por ejemplo, en C#, utilice uint cuando el tipo nativo es unsigned int.
  • ✔️ SÍ prefiera expresar tipos nativos de nivel superior mediante structs de .NET en lugar de clases.
  • ✔️ [In] Use y [Out] atributos en los parámetros de matriz.
  • ✔️ USE solamente atributos [In] y [Out] cuando quiera que el comportamiento sea diferente al predeterminado.
  • ✔️ PLANTÉESE usar System.Buffers.ArrayPool<T> para agrupar los búferes de matriz nativa.
  • ✔️ PLANTÉESE encapsular las declaraciones P/Invoke en una clase con el mismo nombre y uso de mayúsculas y minúsculas que la biblioteca nativa.
    • De esta forma, los atributos [DllImport] usan la característica de lenguaje nameof de C# para pasar el nombre de la biblioteca nativa y garantizar que no escribió mal el nombre de la biblioteca nativa.

Configuración del atributo DllImport

Parámetro Default Recomendación Detalles
PreserveSig true Mantenga el valor predeterminado Cuando se establece explícitamente en false, los valores devueltos de HRESULT con errores se convierten en excepciones (y el valor devuelto en la definición se convierte en NULL).
SetLastError false Depende de la API Establezca este valor en true si la API utiliza GetLastError y use Marshal.GetLastWin32Error para obtener el valor. Si la API establece una condición que indica que tiene un error, obtenga el error antes de realizar otras llamadas para evitar que accidentalmente se sobrescriba.
CharSet Definido por el compilador (especificado en la documentación de juegos de caracteres) Usar explícitamente CharSet.Unicode o CharSet.Ansi cuando haya cadenas o caracteres en la definición Esto especifica el comportamiento de serialización de cadenas y lo que hace ExactSpelling cuando false. Tenga en cuenta que CharSet.Ansi es realmente UTF8 en Unix. La mayoría de las veces Windows utiliza Unicode, mientras que Unix usa UTF8. Obtenga más información en la documentación de juegos de caracteres.
ExactSpelling false true Establezca este valor en true y obtenga una ventaja de rendimiento ligero, ya que el entorno de ejecución no busca nombres de función alternativos con un sufijo "A" o "W" en función del valor de la configuración de CharSet ("A" para CharSet.Ansi y "W" para CharSet.Unicode).

Parámetros de cadena

Cuando el juego de caracteres es Unicode o el argumento se marca explícitamente como [MarshalAs(UnmanagedType.LPWSTR)]y la cadena se pasa por valor (ref ni out), la cadena se anclará y se usará directamente mediante código nativo (en lugar de una copia).

❌ NO use parámetros [Out] string. Los parámetros de cadena pasados por valor con el atributo [Out] pueden llegar a desestabilizar el entorno de ejecución si la cadena es una cadena internalizada. Obtenga más información sobre el internamiento de cadenas en la documentación de String.Intern.

✔️ CONSIDERE establecer la propiedad CharSet en [DllImport] para que el tiempo de ejecución conozca la codificación de cadena esperada.

✔️ CONSIDERE las matrices char[] o byte[] de ArrayPool cuando se espera que el código nativo rellene un búfer de caracteres. Esto requiere pasar el argumento como [Out].

✔️ CONSIDERE evitar los parámetros StringBuilder. La serialización StringBuildersiempre crea una copia del búfer nativo. Por lo tanto, puede ser extremadamente ineficaz. Siga el escenario típico de una llamada a una API de Windows que toma una cadena:

  1. Cree un parámetro StringBuilder de la capacidad deseada (asigna la capacidad administrada) {1}.
  2. Invoke:
    1. Asigna un búfer nativo {2}.
    2. Copia el contenido si [In](el valor predeterminado de un parámetroStringBuilder).
    3. Copia el búfer nativo en una matriz administrada recién asignada si [Out]{3}(también el valor predeterminado deStringBuilder).
  3. ToString()asigna otra matriz {4} administrada.

Es decir, asignaciones {4} para obtener una cadena del código nativo. Lo mejor que puede hacer para limitar esto consiste en reusar StringBuilder en otra llamada, pero esta todavía solo guarda una asignación. Es mucho mejor usar y copiar en caché un búfer de caracteres desde ArrayPool. Después, se puede centrar en la asignación de ToString() en las llamadas siguientes.

El otro problema con StringBuilder es que siempre copia la copia de seguridad del búfer de retorno en el primer valor NULL. Si la cadena pasada anterior no está terminada o es una cadena terminada en doble NULL, el valor P/Invoke será incorrecto en el mejor de los casos.

Si usaStringBuilder, un último problema es que la capacidad no incluyen un valor NULL oculto, que siempre se tiene en cuenta en la interoperabilidad. Es habitual equivocarse, ya que la mayoría de las API quieren que el tamaño del búfer incluyan el valor NULL. Esto puede dar lugar a asignaciones innecesarias. Además, este problema impide que el entorno de ejecución optimice la serialización StringBuilder para minimizar las copias.

Para obtener más información sobre la serialización cadenas, vea Cálculo de referencias predeterminado para cadenas y Personalización de la serialización de campos de cadena.

Específico para Windows Para las cadenas [Out], CLR utilizará CoTaskMemFree de forma predeterminada para liberar las cadenas o SysStringFree para las cadenas que están marcadas como UnmanagedType.BSTR. Para la mayoría de las API con un búfer de cadena de salida: El número de caracteres pasados debe incluir el valor NULL. Si el valor devuelto es menor que el número de caracteres pasados, la llamada se realiza correctamente y el valor es el número de caracteres sin el carácter NULL. En caso contrario, el número es el tamaño del búfer necesario incluyendo el carácter NULL.

  • Pase 5 y obtenga 4: la cadena tiene 4 caracteres de longitud con un valor NULL final.
  • Pase 5 y obtenga 6: la cadena tiene 5 caracteres de longitud y necesita un búfer de 6 caracteres para contener el valor NULL. Tipos de datos de Windows para cadenas

Parámetros y campos booleanos

Los valores booleanos se desordenan fácilmente. De forma predeterminada, .NET bool se serializa en un valor BOOL de Windows de 4 bytes. Sin embargo, los tipos _Bool y bool en C y C++ tienen un solo byte. De este modo, puede ser complicado realizar un seguimiento de los errores porque la mitad del valor devuelto se descarta, con lo que posiblemente se modifica el resultado. Para obtener más información sobre la serialización de valores bool de .NET en tipos bool de C o C++, consulte la documentación sobre cómo personalizar la serialización de campos booleanos.

GUID

Los GUID se pueden usar directamente en las firmas. Muchas de las API de Windows toman los alias de tipo GUID& como REFIID. Cuando la firma del método contiene un parámetro de referencia, coloque una palabra clave ref o un atributo [MarshalAs(UnmanagedType.LPStruct)] en la declaración del parámetro GUID.

GUID GUID por referencia
KNOWNFOLDERID REFKNOWNFOLDERID

❌ NO USE [MarshalAs(UnmanagedType.LPStruct)] para parámetros GUID que no sean ref.

Tipos que pueden transferirse en bloque de bits

Los tipos que pueden transferirse en bloque de bits son tipos que tienen la misma representación de nivel de bits en código administrado y nativo. Por lo tanto, no es necesario convertirlos a otro formato para serializarlos con código nativo como punto de partida o destino, y como se mejora el rendimiento, deben preferirse estos tipos. Algunos tipos no pueden transferirse en bloque de bits, pero se sabe que contienen contenido que sí. Estos tipos tienen optimizaciones similares como tipos que pueden transferirse en bloque de bits cuando no están incluidos en otro tipo, pero no se consideran que se puedan transferir en bloque de bits cuando se encuentran en campos de estructuras o con fines de UnmanagedCallersOnlyAttribute.

Tipos que pueden transferirse en bloque de bits al habilitar la serialización en runtime

Tipos que pueden transferirse en bloque de bits:

  • byte, sbyte, short, ushort, int, uint, long, ulong, single, double
  • Estructuras con distribución fija que solo tienen tipos de valor que pueden transferirse en bloque de bits, por ejemplo, campos
    • La distribución fija requiere [StructLayout(LayoutKind.Sequential)] o [StructLayout(LayoutKind.Explicit)].
    • De forma predeterminada, las estructuras son LayoutKind.Sequential

Tipos con contenido que puede transferirse en bloque de bits:

  • Matrices unidimensionales no anidadas de tipos primitivos que pueden transferirse en bloque de bits (por ejemplo, int[])
  • Clases con distribución fija que solo tienen tipos de valor que pueden transferirse en bloque de bits, por ejemplo, campos
    • La distribución fija requiere [StructLayout(LayoutKind.Sequential)] o [StructLayout(LayoutKind.Explicit)].
    • De forma predeterminada, las clases son LayoutKind.Auto

Tipos que no se pueden transferir en bloque de bits:

  • bool

Tipos que A VECES se pueden transferir en bloque de bits:

  • char

Tipos con contenido que A VECES puede transferirse en bloque de bits:

  • string

Cuando los tipos que pueden transferirse en bloque de bits se pasan por referencia con in, ref o out, o cuando los tipos con contenido que puede transferirse en bloque de bits se pasan por valor, simplemente se anclan mediante el serializador en lugar de copiarse en un búfer intermedio.

char puede transferirse en bloque de bits en una matriz unidimensional o si forma parte de un tipo que lo contiene, se marca explícitamente con [StructLayout] con CharSet = CharSet.Unicode.

[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
public struct UnicodeCharStruct
{
    public char c;
}

string contiene contenido que puede transferirse en bloque de bits si no está contenido en otro tipo y si se pasa como un argumento que se marca con [MarshalAs(UnmanagedType.LPWStr)] o [DllImport] tiene CharSet = CharSet.Unicode establecido.

Puede ver si un tipo puede transferirse en bloque de bits o contiene contenido que puede transferirse en bloque de bits al intentar crear un controlador GCHandle anclado. Si el tipo no es una cadena o no puede transferirse en bloque de bits, GCHandle.Alloc producirá una excepción ArgumentException.

Tipos que pueden transferirse en bloque de bits cuando la serialización en runtime está deshabilitada

Cuando la serialización en tiempo de ejecución está deshabilitada, las reglas que determinan qué tipos pueden transferirse en bloque de bits son significativamente más simples. Todos los tipos que son tipos de C# unmanaged y no tienen ningún campo marcado con [StructLayout(LayoutKind.Auto)] pueden transferirse en bloque de bits. Todos los tipos que no son tipos de C# unmanaged no pueden transferirse en bloque de bits. El concepto de tipos con contenido que puede transferirse en bloque de bits, como matrices o cadenas, no se aplica cuando la serialización en runtime está deshabilitada. Cualquier tipo que no se considere que puede transferirse en bloque de bits por la regla mencionada anteriormente no se admite cuando la serialización en runtime está deshabilitada.

Estas reglas difieren del sistema integrado principalmente en situaciones en las que se usan bool y char. Cuando la serialización está deshabilitada, bool se pasa como un valor de 1 byte y no se normaliza, y char siempre se pasa como un valor de 2 bytes. Cuando la serialización en runtime está habilitada, bool puede asignarse a un valor de 1, 2 o 4 bytes y siempre se normaliza y char se asigna a un valor de 1 o 2 bytes en función de CharSet.

✔️ CREE estructuras que puedan transferirse en bloque de bits cuando sea posible.

Para obtener más información, consulte:

Forma de mantener activos los objetos administrados

GC.KeepAlive() asegurará que un objeto permanezca dentro del ámbito hasta que se alcance el método KeepAlive.

HandleRef permite que el serializador mantenga un objeto activo para la duración de P/Invoke. Se puede usar en lugar de IntPtr en firmas de método. SafeHandle reemplaza esta clase de forma eficaz y se debe usar en su lugar.

GCHandle permite anclar un objeto administrado y obtener el puntero nativo. El patrón básico es el siguiente:

GCHandle handle = GCHandle.Alloc(obj, GCHandleType.Pinned);
IntPtr ptr = handle.AddrOfPinnedObject();
handle.Free();

Anclar no es la acción predeterminada para GCHandle. El otro patrón principal se usa para pasar una referencia a un objeto administrado a través de código nativo y devolverse al código administrado, normalmente con una devolución de llamada. Este es el patrón:

GCHandle handle = GCHandle.Alloc(obj);
SomeNativeEnumerator(callbackDelegate, GCHandle.ToIntPtr(handle));

// In the callback
GCHandle handle = GCHandle.FromIntPtr(param);
object managedObject = handle.Target;

// After the last callback
handle.Free();

No olvide que GCHandle debe liberarse explícitamente para evitar fugas de memoria.

Tipos de datos de Windows comunes

Esta es una lista de los tipos de datos que se usan frecuentemente en las API de Windows y los tipos de C# que se deben usar al llamar al código de Windows.

Los siguientes tipos tienen el mismo tamaño en Windows 32 bits y 64 bits, a pesar de sus nombres.

Ancho Windows C# Alternativa
32 BOOL int bool
8 BOOLEAN byte [MarshalAs(UnmanagedType.U1)] bool
8 BYTE byte
8 UCHAR byte
8 UINT8 byte
8 CCHAR byte
8 CHAR sbyte
8 CHAR sbyte
8 INT8 sbyte
16 CSHORT short
16 INT16 short
16 SHORT short
16 ATOM ushort
16 UINT16 ushort
16 USHORT ushort
16 WORD ushort
32 INT int
32 INT32 int
32 LONG int Consulte CLong y CULong.
32 LONG32 int
32 CLONG uint Consulte CLong y CULong.
32 DWORD uint Consulte CLong y CULong.
32 DWORD32 uint
32 UINT uint
32 UINT32 uint
32 ULONG uint Consulte CLong y CULong.
32 ULONG32 uint
64 INT64 long
64 LARGE_INTEGER long
64 LONG64 long
64 LONGLONG long
64 QWORD long
64 DWORD64 ulong
64 UINT64 ulong
64 ULONG64 ulong
64 ULONGLONG ulong
64 ULARGE_INTEGER ulong
32 HRESULT int
32 NTSTATUS int

Los siguientes tipos, que son punteros, siguen el ancho de la plataforma. Use IntPtr/UIntPtr para estos.

Tipos de puntero con signo (use IntPtr) Tipos de puntero sin signo (use UIntPtr)
HANDLE WPARAM
HWND UINT_PTR
HINSTANCE ULONG_PTR
LPARAM SIZE_T
LRESULT
LONG_PTR
INT_PTR

Un tipo PVOID de Windows, que es un tipo void* de C, se puede serializar como IntPtr o UIntPtr, pero prefiere void* cuando sea posible.

Tipos de datos de Windows

Intervalos de tipo de datos

Tipos admitidos integrados anteriormente

Hay instancias poco frecuentes cuando se quita la compatibilidad integrada con un tipo.

La compatibilidad serializada e integrada de UnmanagedType.HString y UnmanagedType.IInspectable se quitó en la versión .NET 5. Debe volver a compilar archivos binarios que usen este tipo de serialización y que tengan como destino un marco anterior. Todavía es posible serializar este tipo, pero debe serializarlo de forma manual, como se muestra en el ejemplo de código siguiente. Este código funcionará al avanzar y también es compatible con marcos anteriores.

public sealed class HStringMarshaler : ICustomMarshaler
{
    public static readonly HStringMarshaler Instance = new HStringMarshaler();

    public static ICustomMarshaler GetInstance(string _) => Instance;

    public void CleanUpManagedData(object ManagedObj) { }

    public void CleanUpNativeData(IntPtr pNativeData)
    {
        if (pNativeData != IntPtr.Zero)
        {
            Marshal.ThrowExceptionForHR(WindowsDeleteString(pNativeData));
        }
    }

    public int GetNativeDataSize() => -1;

    public IntPtr MarshalManagedToNative(object ManagedObj)
    {
        if (ManagedObj is null)
            return IntPtr.Zero;

        var str = (string)ManagedObj;
        Marshal.ThrowExceptionForHR(WindowsCreateString(str, str.Length, out var ptr));
        return ptr;
    }

    public object MarshalNativeToManaged(IntPtr pNativeData)
    {
        if (pNativeData == IntPtr.Zero)
            return null;

        var ptr = WindowsGetStringRawBuffer(pNativeData, out var length);
        if (ptr == IntPtr.Zero)
            return null;

        if (length == 0)
            return string.Empty;

        return Marshal.PtrToStringUni(ptr, length);
    }

    [DllImport("api-ms-win-core-winrt-string-l1-1-0.dll")]
    [DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
    private static extern int WindowsCreateString([MarshalAs(UnmanagedType.LPWStr)] string sourceString, int length, out IntPtr hstring);

    [DllImport("api-ms-win-core-winrt-string-l1-1-0.dll")]
    [DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
    private static extern int WindowsDeleteString(IntPtr hstring);

    [DllImport("api-ms-win-core-winrt-string-l1-1-0.dll")]
    [DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
    private static extern IntPtr WindowsGetStringRawBuffer(IntPtr hstring, out int length);
}

// Example usage:
[DllImport("api-ms-win-core-winrt-l1-1-0.dll", PreserveSig = true)]
internal static extern int RoGetActivationFactory(
    /*[MarshalAs(UnmanagedType.HString)]*/[MarshalAs(UnmanagedType.CustomMarshaler, MarshalTypeRef = typeof(HStringMarshaler))] string activatableClassId,
    [In] ref Guid iid,
    [Out, MarshalAs(UnmanagedType.IUnknown)] out object factory);

Consideraciones sobre el tipo de datos multiplataforma

Hay tipos en el lenguaje C/C++ que tienen latitud en cómo se definen. Al escribir interoperabilidad multiplataforma, pueden surgir casos en los que las plataformas difieren y pueden causar problemas si no se tienen en cuenta.

C/C++ long

El elemento long de C/C++ y el elemento long de C# no tienen necesariamente el mismo tamaño.

El tipo long en C/C++ se define para tener "al menos 32" bits. Esto significa que hay un número mínimo de bits necesarios, pero las plataformas pueden optar por usar más bits si lo desean. En la tabla siguiente se muestran las diferencias en los bits proporcionados para el tipo de datos de C/C++ long entre plataformas.

Plataforma 32 bits 64 bits
Windows 32 32
macOS/*nix 32 64

En cambio, el elemento long de C# siempre es de 64 bits. Por este motivo, es mejor evitar el uso del elemento long de C# para interoperabilidad con el long de C/C++.

(Este problema con el long de C/C++ no existe para el char, short, inty long long de C/C++, ya que son 8, 16, 32 y 64 bits respectivamente en todas estas plataformas).

En .NET 6 y versiones posteriores, use los tipos CLong y CULong para la interoperabilidad con los tipos de datos C/C++ long y unsigned long. El ejemplo siguiente es para CLong, pero puede usar CULong para abstraer de unsigned long de forma similar.

// Cross platform C function
// long Function(long a);
[DllImport("NativeLib")]
extern static CLong Function(CLong a);

// Usage
nint result = Function(new CLong(10)).Value;

Al establecer como destino .NET 5 y versiones anteriores, debe declarar firmas independientes de Windows y que no sean de Windows para controlar el problema.

static readonly bool IsWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);

// Cross platform C function
// long Function(long a);

[DllImport("NativeLib", EntryPoint = "Function")]
extern static int FunctionWindows(int a);

[DllImport("NativeLib", EntryPoint = "Function")]
extern static nint FunctionUnix(nint a);

// Usage
nint result;
if (IsWindows)
{
    result = FunctionWindows(10);
}
else
{
    result = FunctionUnix(10);
}

Estructuras

Las estructuras administradas se crean en la pila y no se quitan hasta que el método se devuelve. Por definición, se anclan (la recolección de elementos no utilizados no las mueve). Puede simplemente tomar la dirección en bloques de código no seguros si el código nativo no utilizará el puntero más allá del final del método actual.

Las estructuras de bits/bytes son mucho más eficaces, ya que simplemente puede utilizarlas directamente la capa de serialización. Trate de crear estructuras que pueden transferirse en bloque de bits (por ejemplo, evite bool). Para obtener más información, consulte la sección Tipos que pueden transferirse en bloque de bits.

Si la estructura se puede transferir en bloque de bits, use sizeof() en lugar de Marshal.SizeOf<MyStruct>() para mejorar el rendimiento. Como se mencionó anteriormente, puede validar que el tipo se pueda transferir en bloques de bits al intentar crear un controlador GCHandle anclado. Si el tipo no es una cadena o no puede transferirse en bloque de bits, GCHandle.Alloc producirá una excepción ArgumentException.

Los punteros a las estructuras en definiciones deben pasarse por ref o usar unsafe y *.

✔️ HAGA COINCIDIR la estructura administrada lo máximo posible con la forma y los nombres que se usan en el encabezado o la documentación oficial de la plataforma.

✔️ USE el tipo sizeof() de C# en lugar de Marshal.SizeOf<MyStruct>() para las estructuras que pueden transferirse en bloque de bits con el fin de mejorar el rendimiento.

❌ EVITE el uso de clases para expresar tipos nativos complejos a través de la herencia.

❌ EVITE el uso de campos System.Delegate o System.MulticastDelegate para representar campos de puntero de función en estructuras.

Dado que System.Delegate y System.MulticastDelegate no tienen una firma obligatoria, no garantizan que el delegado que se pasa coincida con la firma que el código nativo espera. Además, en .NET Framework y .NET Core, la serialización de una estructura que contenga System.Delegate o System.MulticastDelegate desde su representación nativa a un objeto administrado puede desestabilizar el runtime si el valor del campo en la representación nativa no es un puntero de función que encapsula un delegado administrado. En .NET 5 y versiones posteriores, no se admite la serialización de un campo System.Delegate o System.MulticastDelegate de una representación nativa a un objeto administrado. Use un tipo de delegado específico en lugar de System.Delegate o System.MulticastDelegate.

Búferes fijos

Una matriz como INT_PTR Reserved1[2] tiene que serializarse a dos campos IntPtr, Reserved1a y Reserved1b. Cuando la matriz nativa es un tipo primitivo, podemos usar la palabra clave fixed para escribir de forma más limpia. Por ejemplo, SYSTEM_PROCESS_INFORMATION tiene este aspecto en el encabezado nativo:

typedef struct _SYSTEM_PROCESS_INFORMATION {
    ULONG NextEntryOffset;
    ULONG NumberOfThreads;
    BYTE Reserved1[48];
    UNICODE_STRING ImageName;
...
} SYSTEM_PROCESS_INFORMATION

En C#, podemos escribirlo así:

internal unsafe struct SYSTEM_PROCESS_INFORMATION
{
    internal uint NextEntryOffset;
    internal uint NumberOfThreads;
    private fixed byte Reserved1[48];
    internal Interop.UNICODE_STRING ImageName;
    ...
}

Sin embargo, existen los búferes fijos tienen algunas trampas. Los búferes fijos de tipos que no pueden transferirse en bloques de bits no se serializarán correctamente, por lo que la matriz en contexto debe expandirse a varios campos individuales. Además, en .NET Framework y .NET Core antes de la versión 3.0, si una estructura que contiene un campo de búfer fijo se anida dentro de una estructura que no puede transferirse en bloque de bits, el campo de búfer fijo no se serializará correctamente al código nativo.