Cómo llamar a la API de Win32 desde C# de forma segura — Guía práctica de P/Invoke (DllImport / LibraryImport / CsWin32)

· Actualizado el: · · P/Invoke, DllImport, LibraryImport, CsWin32, C#, .NET, Win32, SafeHandle, Interoperabilidad nativa, Desarrollo en Windows, Consultoría técnica

En este blog hemos escrito ya varios artículos sobre interoperabilidad nativa: Cuándo usar un wrapper de C++/CLI y cuándo P/Invoke, Cómo llamar a una DLL de C# Native AOT desde C/C++, Puente COM para llamar a una DLL de 64 bits desde una aplicación de 32 bits y Cómo funciona la resolución de nombres de DLL en Windows. Sin embargo, todavía no habíamos dedicado un artículo a P/Invoke en sí mismo, que es la base de todos ellos.

P/Invoke tiene la comodidad de que basta con declarar extern una función de una DLL para poder llamarla, pero también es una técnica en la que, tarde o temprano, se termina tropezando con algo: el marshaling de cadenas, el ciclo de vida de los handles, la obtención del código de error o el layout de las structs. En este artículo repasamos, con LibraryImport como eje —la opción predeterminada desde .NET 7—, los puntos que hay que dominar en la práctica.

Terminología usada en este artículo

Antes de continuar, resumimos aquí la terminología que usaremos sin más explicación en el resto del artículo.

Término Significado
P/Invoke Platform Invoke (invocación de plataforma). El mecanismo de .NET para llamar, desde código administrado, a funciones de una DLL no administrada
Marshaling La conversión mutua de la representación de un tipo en el límite entre código administrado y no administrado. El paso de cadenas, structs, arreglos y delegados entra en esta categoría
Stub de IL Código intermedio, que incluye el procesamiento de marshaling, que el runtime genera en tiempo de ejecución para una llamada con DllImport. Se compila con el JIT antes de usarse en la llamada real1
Native AOT Un modo de publicación que compila una aplicación .NET a código nativo por adelantado, en el momento de la publicación. Como no se puede usar la generación de código en tiempo de ejecución, este modo no combina bien con el mecanismo de stub de IL1
Trimming (recorte) Una función que, al publicar, elimina el código no utilizado para reducir el tamaño de la implementación. No puede rastrear el código generado dinámicamente en tiempo de ejecución1
Tipo blittable Un tipo cuya representación en bits es la misma en código administrado y no administrado, por lo que puede pasarse tal cual, sin conversión. Más detalles en el capítulo 72

1. Conclusión primero

Como el artículo es largo, empezamos por los cuatro puntos donde más suelen ocurrir accidentes.

  • A partir de .NET 7, adopte LibraryImport como opción predeterminada en lugar de DllImport. Al generar el código de marshaling en tiempo de compilación, es compatible con Native AOT y con el trimming, no tiene el costo de generar un stub de IL en tiempo de ejecución, y el código generado puede depurarse paso a paso. El analizador SYSLIB1054 señala dónde reescribir el código que usa DllImport.13
  • Mantenga los handles en una clase derivada de SafeHandle, no en un IntPtr crudo. Es la práctica básica de la interoperabilidad nativa de .NET para evitar la liberación prematura de un handle por el GC, la doble liberación y el “ataque de reciclaje”.45
  • Especifique explícitamente StringMarshalling para las cadenas y evite StringBuilder. El marshaling de StringBuilder siempre implica copiar hacia un búfer nativo, un mecanismo ineficiente y propenso a errores en el manejo del terminador.6
  • En cuanto use SetLastError = true, lea Marshal.GetLastPInvokeError() inmediatamente después de la llamada. Hay que capturarlo antes de que la ejecución de otro código administrado sobrescriba el código de error.7

Que estos cuatro puntos aparezcan juntos no es casualidad. La declaración de un P/Invoke, en apenas unas pocas líneas, está prometiendo simultáneamente el tipo, las cadenas, el ciclo de vida de la memoria y los handles, y la forma de recibir errores al cruzar el límite.

Lado nativo (API de Win32 / DLL en C propia)Límite (marshaler)Lado administrado (.NET)la forma de pasarlo depende de la naturaleza del valorpasa el valor copiadola declaración no dice quién liberahay que decidirlo según la especificación de la APIFunción exportadaMemoria y handles nativosDeclaración de DllImport / LibraryImport= el contrato del límite es exactamente lo que está escrito aquíConversión de tipos y ajuste de la convención de llamadaBúfer temporal de destinoCódigo C# que realiza la llamadaObjetos que mueve el GC• Arreglo blittable → se fija (pin) y se pasa tal cual• No blittable (cadenas, etc.) → se convierte y se copia• Delegado → se mantiene vivo durante la llamadaSafeHandleposee el ciclo de vida del handle nativo

Figura 1: unas pocas líneas de declaración prometen simultáneamente el tipo, las cadenas, el ciclo de vida y los errores. La forma de pasar el valor depende de su naturaleza (fijarlo, copiarlo o solo mantenerlo vivo), y si cualquiera de estos falla, el síntoma es que «a veces se cae»

El resto son puntos del tipo «si no los conoce, tropieza; si los conoce, se resuelven en pocas líneas». A continuación reunimos solo las conclusiones; consulte el capítulo correspondiente para los detalles.

Punto Conclusión Detalle
Firma de la API de Win32 No la escriba a mano: deje que la genere CsWin32. Basta con enumerar en NativeMethods.txt los nombres de las funciones que quiere llamar, y las firmas, constantes y structs se generan a partir de los metadatos oficiales de Win328 Capítulo 3
Layout de la struct Use LayoutKind.Sequential como valor predeterminado y decida conscientemente si especifica Pack o no. Pack = 0 (el valor predeterminado) no significa «sin límite», sino «el tamaño de empaquetado predeterminado de la plataforma actual»; el límite existe, y su valor se determina de forma distinta al valor predeterminado de /Zp de C++. También puede cambiar entre .NET Framework y .NET 5+9 Capítulo 7
Callback (delegado) Gestione el ciclo de vida para que el GC no lo recolecte antes de que el lado nativo termine de usarlo. Consérvelo en un campo static o use GC.KeepAlive, y prefiera UnmanagedCallersOnly cuando sea posible10 Capítulo 8
32 bits/64 bits Reciba los tipos de tipo puntero con IntPtr/nint. Como en un mismo proceso no pueden coexistir DLL de distinta arquitectura, ese requisito no se resuelve con P/Invoke Capítulo 9
Alternativas a P/Invoke P/Invoke para una interfaz en C directa, C++/CLI cuando intervienen clases de C++, propiedad de recursos o excepciones, y COM cuando hay que cruzar el límite de proceso. No son técnicas competidoras, sino complementarias Capítulo 10

2. DllImport y LibraryImport — cuál usar

DllImport es el mecanismo tradicional: en tiempo de ejecución, el runtime genera un stub de IL para el marshaling, lo compila con el JIT y solo entonces realiza la llamada. Como la generación ocurre en tiempo de ejecución, combina mal con configuraciones que precompilan el ensamblado, como Native AOT o el trimming, y el propio costo de generación tampoco es nulo.1

LibraryImport es un generador de código fuente agregado en .NET 7 que genera el código de marshaling en tiempo de compilación para un método partial. Como el código generado existe como código fuente C#, puede ejecutarse paso a paso en el depurador, y los errores en la firma se detectan tempranamente como errores de compilación.1

using System.Runtime.InteropServices;

internal static partial class NativeMethods
{
    [LibraryImport("nativelib", EntryPoint = "to_lower", StringMarshalling = StringMarshalling.Utf16)]
    internal static partial string ToLower(string str);
}

Este valor de retorno string esconde una premisa fácil de pasar por alto. El marshaler, después de copiar la cadena a la que apunta el puntero devuelto, siempre intenta liberar esa memoria. En Windows se usa CoTaskMemFree, así que si el lado nativo reservó ese puntero con algo distinto de CoTaskMemAlloc (un búfer estático, malloc, new[], implementaciones nada raras en una API en C), el marshaler terminará liberando la memoria con el asignador equivocado, lo que puede provocar corrupción del heap o un cierre inesperado.11 A menos que la cabecera o la documentación del otro lado indiquen explícitamente que la reserva es compatible con CoTaskMemAlloc, diseñe la llamada para recibir el valor de retorno como IntPtr en lugar de string, y libere la memoria usted mismo llamando a la función de liberación correspondiente (o al procedimiento que exija el otro lado). Reservar el búfer en el lado que llama y pasarlo (el arreglo de caracteres que sustituye a StringBuilder, mencionado antes, o el patrón de búfer [Out] descrito más adelante) evita de entrada este tipo de ambigüedad sobre la propiedad.

Las principales diferencias respecto a DllImport son las siguientes.12

  • CharSet desaparece y se sustituye por StringMarshalling (Utf16 / Utf8 / personalizado). ANSI queda eliminado y UTF-8 pasa a ser una opción de primera clase.
  • CallingConvention se sustituye por UnmanagedCallConvAttribute.
  • No hay equivalente para ExactSpelling ni PreserveSig: el nombre del punto de entrada siempre debe especificarse con la ortografía exacta, y la conversión del valor de retorno siempre se realiza de forma directa.
  • Tanto la clase como el método llamado deben marcarse partial, y el proyecto necesita AllowUnsafeBlocks.

DllImport sigue siendo necesario cuando se usa una configuración que LibraryImport todavía no admite (por ejemplo, ciertas especificaciones de MarshalAs). El analizador avisa con un error en cuanto se intenta usar una configuración no compatible, así que el enfoque más práctico es escribir primero LibraryImport y, si el analizador lo rechaza, volver a DllImport.12

3. CsWin32 — la opción para no escribir las firmas a mano

Declarar a mano cada API de Win32 con DllImport/LibraryImport, una por una, acumula el riesgo de equivocarse en el tipo de un parámetro, el valor de una constante o el orden de los campos de una struct. CsWin32 (Microsoft.Windows.CsWin32) es un generador de código fuente que, a partir de los metadatos oficiales de la API de Win32, genera automáticamente la firma, las constantes relacionadas y las structs de las funciones que se quieran llamar.8

El uso es sencillo: basta con agregar el paquete NuGet al proyecto y enumerar los nombres de las funciones que se quieren llamar en un archivo de texto llamado NativeMethods.txt.

GetDpiForWindow
SetWindowPos
CreateFileW
CloseHandle

En cada línea de NativeMethods.txt se puede escribir, además del nombre del método, un nombre de tipo, de constante, de espacio de nombres o de módulo; anteponer - al inicio lo convierte en una exclusión.13

En el momento de la compilación se generan las firmas P/Invoke de estas funciones (incluidos el valor de retorno, los parámetros y la especificación de SetLastError). Tenga en cuenta que, por defecto, la generación se basa en el tradicional DllImport. Si se apunta a Native AOT o al trimming, colocando un NativeMethods.json en la raíz del proyecto y deshabilitando allowMarshaling se puede cambiar a una generación de código que no depende del marshaler del runtime.13 Con las siguientes dos líneas en el archivo es suficiente.

{
  "$schema": "https://aka.ms/CsWin32.schema.json",
  "allowMarshaling": false
}

La línea $schema no es obligatoria, pero si se incluye, la mayoría de los editores de JSON habilitan autocompletado, descripciones y validación, y desde ahí también se puede llegar a la lista completa de opciones disponibles.13

Como HANDLE se genera como el tipo derivado de SafeHandle adecuado y las cadenas se generan con el CharSet/StringMarshalling correcto, de entrada resulta imposible introducir los errores habituales al escribir a mano, como confundir el CharSet o el orden de los campos de una struct.

Como se explicó en Los casos en los que un wrapper de C++/CLI resulta útil, para una DLL compleja en la que intervienen clases de C++, propiedad de recursos o excepciones conviene interponer un wrapper delgado, pero si el otro lado es una API de Win32 directa (o una DLL con una interfaz en C equivalente), generar la firma automáticamente con CsWin32 es el camino más corto y con menos accidentes. CsWin32 no se puede usar con las DLL propias de la empresa, pero incluso en ese caso el estilo del código generado sirve como plantilla de referencia.

4. Trampas del marshaling de cadenas

Los compiladores de C#, VB y F# asignan por defecto CharSet.None a una declaración P/Invoke que no especifica CharSet. El comportamiento real de CharSet.None es idéntico al de CharSet.Ansi: en Windows se hace el marshaling como no Unicode (página de códigos localizada). Si la API de Win32 que se llama espera la versión Unicode (sufijo W), llamarla con este valor predeterminado provoca caracteres corruptos o la pérdida de caracteres multibyte.14

En LibraryImport, la forma básica es especificar explícitamente StringMarshalling.Utf16. Como la propia opción ANSI ha desaparecido, estructuralmente resulta mucho más difícil que ocurra el accidente típico de la época de DllImport: dejar el valor predeterminado y terminar usando ANSI sin querer.12

Otra trampa es el parámetro StringBuilder. Se usa con frecuencia en APIs del tipo «el lado nativo escribe y devuelve un búfer de cadena», pero el marshaling de StringBuilder siempre implica una copia hacia un búfer nativo, y ToString() provoca todavía otra asignación más. Si el búfer es [Out] (el valor predeterminado), se trata de un mecanismo ineficiente que acumula varias asignaciones en cada llamada. Además, tiene el defecto de comportarse mal cuando el búfer devuelto no está terminado en NUL, o cuando la cadena tiene una doble terminación en NUL. En llamadas de alta frecuencia resulta más estable usar un arreglo de caracteres tomado de ArrayPool<char>.6

El parámetro [Out] string es otra especificación que se debe evitar. Si la cadena está internada (interned), puede desestabilizar el runtime.6

5. Gestión del ciclo de vida de los handles — por qué usar SafeHandle

Mantener recursos nativos como handles de archivo, claves de registro o handles de dispositivo en un IntPtr crudo es un diseño que debe evitarse en la interoperabilidad nativa de .NET. Hay tres razones.4

  • Liberación prematura del handle por el GC. Si una clase que implementa un finalizador guarda el handle en un campo IntPtr, puede producirse una carrera en la que el GC recolecta ese objeto y cierra el handle mientras una llamada P/Invoke sigue en curso.
  • Ataque de reciclaje de handles. Windows reutiliza activamente los valores de handle. Si se sigue usando un IntPtr antiguo cuando ese valor de handle, que se creía cerrado, ya fue reasignado a otro recurso, se produce un accidente grave: terminar operando sobre un recurso completamente distinto.
  • Fugas por excepciones asíncronas. Si una interrupción asíncrona, como la abortación de un hilo, ocurre entre la obtención del handle y su almacenamiento en el campo, puede producirse una fuga del handle.

SafeHandle es una clase abstracta diseñada para resolver estos tres problemas. Hereda de CriticalFinalizerObject, lo que garantiza que el proceso de liberación se ejecute de forma confiable incluso ante un cierre anómalo del AppDomain. Como las llamadas P/Invoke incrementan y decrementan automáticamente el conteo de referencias del handle, tampoco puede reciclarse mientras la llamada está en curso.4

Para una implementación propia, se hereda de una clase como SafeHandleZeroOrMinusOneIsInvalid del espacio de nombres Microsoft.Win32.SafeHandles y se sobrescribe ReleaseHandle(). Como ReleaseHandle() se ejecuta en una región de ejecución restringida que asume que «no debe fallar», la práctica establecida es no escribir lógica compleja ahí y limitarse a una simple llamada a la API de liberación. No hace falta escribir un finalizador propio (de hecho, debe evitarse).5

6. Manejo de errores — SetLastError y GetLastPInvokeError

Muchas APIs de Win32, cuando fallan, establecen un código de error específico del hilo mediante SetLastError, y quien las llama lo lee con GetLastError. Para manejar esto en P/Invoke se pone en true DllImportAttribute.SetLastError (la misma propiedad, con el mismo nombre, también existe en LibraryImport).15

[LibraryImport("kernel32", EntryPoint = "SetCurrentDirectoryW", StringMarshalling = StringMarshalling.Utf16, SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool SetCurrentDirectoryW(string path);

Aquí hay dos puntos a los que hay que prestar atención.

  • Lea el código de error inmediatamente después de la llamada. En .NET (excepto .NET Framework), cada vez que se llama a un P/Invoke con SetLastError = true, la información de error se limpia primero, y solo se conserva el resultado de esa llamada concreta. Si se intercala una salida de log u otra llamada a una API, el valor se sobrescribe y se pierde, así que obtenga el valor justo en el momento en que detecte el fallo.15
  • Use Marshal.GetLastPInvokeError() en lugar de Marshal.GetLastWin32Error(). A partir de .NET 6 ambos son funcionalmente idénticos, pero el segundo se recomienda como el nombre nuevo que refleja la intención multiplataforma.7
if (!SetCurrentDirectoryW(path))
{
    int error = Marshal.GetLastPInvokeError();
    throw new Win32Exception(error);
}

7. Marshaling de structs — tipos blittable y StructLayout

Un tipo cuya representación en bits es idéntica en .NET y en código nativo se llama «blittable», y como se puede pasar tal cual sin ninguna conversión, es rápido. Entran en esta categoría los tipos básicos como byte, int o long, y las structs de layout fijo compuestas únicamente por tipos por valor blittable. Para una struct blittable, usar sizeof() de C# en lugar de Marshal.SizeOf<T>() es más rápido. Por el contrario, bool no es blittable (el BOOL nativo mide 4 bytes, mientras que el bool de C/C++ mide 1 byte), y usarlo sin tenerlo presente introduce el error de descartar la mitad del valor de retorno.2

El layout de una struct se controla con StructLayoutAttribute. Por defecto se usa LayoutKind.Sequential (coloca los campos en el orden en que se declaran), y solo se usa LayoutKind.Explicit cuando se quiere especificar la posición de cada campo, como en una unión (union).9

Un detalle fácil de pasar por alto es el campo Pack. Según la documentación oficial, la regla de colocación tiene dos niveles.9

  • La alineación de todo el tipo = el menor entre «el tamaño del campo más grande» y «el valor de Pack especificado»
  • El límite de colocación de cada campo = el menor entre «su propio tamaño» y «la alineación del tipo»

En otras palabras, si se especifica explícitamente un valor pequeño para Pack (como 2 o 4), este actúa como un límite superior de alineación, de forma similar a #pragma pack(N) en C++. El problema está en el valor predeterminado, Pack = 0.

7.1 Correspondencia entre «arquitectura x valor predeterminado»

Pack = 0 no significa «sin límite superior». Según la redacción de la documentación oficial, 0 indica «el tamaño de empaquetado predeterminado de la plataforma actual».9 Es decir, el límite superior existe; simplemente no lo ha decidido usted. Tampoco es «lo mismo que el valor predeterminado de /Zp en C++». Ambos se refieren a un límite superior del tamaño de empaquetado, pero el valor se determina de forma distinta en cada caso, así que trasladar la cifra de uno al otro descuadra los cálculos.

Compilador/runtime Contenido del valor predeterminado x86 x64 ARM / ARM64 ARM64EC
Pack = 0 de C# (predeterminado) Alineación de todo el tipo = el menor entre «el tamaño del campo más grande» y «el tamaño de empaquetado predeterminado de la plataforma»9 ídem ídem ídem ídem
/Zp de C++ (límite superior de la alineación de los miembros de una struct)16 Cada miembro se coloca en el menor entre «su propio tamaño» y «un límite de N bytes» 8 bytes 16 bytes 8 bytes 16 bytes

En la práctica, hay que evitar leer este valor predeterminado como «sin límite superior» y calcular los offsets a mano. En particular, en una struct con campos cuya alineación natural es grande, el límite superior actúa incluso con el valor predeterminado, y el cálculo manual deja de cuadrar. Si entonces, para hacerlo cuadrar, se especifica Pack a ciegas, el resultado queda fijado en un desajuste con el lado nativo, que es la situación más peligrosa que puede darse en P/Invoke. Cuando no tenga certeza sobre los offsets, mídalos de verdad con Marshal.SizeOf y Marshal.OffsetOf, como se muestra en la sección 7.2, y contrástelos con la cabecera del lado nativo.

Además, el layout predeterminado del lado de C# también puede cambiar según la versión del runtime. La documentación oficial menciona el ejemplo de una struct que contiene un decimal, cuyo tamaño con el empaquetado predeterminado es de 28 bytes en .NET Framework y de 32 bytes en .NET 5+, debido a diferencias en la composición interna de los campos.9

Eje de comparación Qué cambia
Proceso de 32 bits / proceso de 64 bits El ancho de los campos de tipo puntero (capítulo 9). Arrastrado por eso, también cambia el tamaño total de la struct
.NET Framework / .NET 5+ El tamaño predeterminado de una struct que contiene ciertos tipos (el ejemplo de decimal anterior)9
Lado C# / lado C++ La propia regla de alineación predeterminada (tabla anterior)

La conclusión aquí es que no debe darse por sentado que «como es el valor predeterminado, tiene que estar bien». Si la cabecera del lado nativo cambia explícitamente el tamaño de empaquetado con #pragma pack, o si la DLL con la que trabaja contiene campos que exigen una alineación mayor de 8 bytes, especifique explícitamente Pack en el lado de C#, o verifique los offsets reales de los campos con el método de la siguiente sección antes de usarlos. Si se descuida este paso, el offset de un campo se desplaza y se produce un accidente en el que los datos se corrompen en silencio.

A la inversa, en una API directa que usa tal cual las cabeceras del Windows SDK, si todos los campos son tipos básicos de 8 bytes o menos, en la práctica casi nunca hay problema en dejar la alineación predeterminada sin tocar Pack.

// Ejemplo para cuando la cabecera del lado nativo especifica explícitamente pack(4)
[StructLayout(LayoutKind.Sequential, Pack = 4)]
internal struct DeviceInfo
{
    public int DeviceId;
    public uint Flags;
    public long Timestamp;
}

7.2 Cómo comprobar el layout en la práctica — Marshal.OffsetOf

Es más fiable verificar que el layout coincide ejecutando el código que contándolo sobre el papel. La siguiente aplicación de consola imprime directamente el tamaño de la struct y el offset de cada campo. Úsela como herramienta: pegue la struct que quiera verificar, ejecútela y contraste el resultado con la cabecera del lado nativo.

// Reemplace el Program.cs del proyecto creado con "dotnet new console" por esto (.NET 8 / C# 12)
using System.Runtime.InteropServices;

Console.WriteLine($"Arquitectura del proceso : {RuntimeInformation.ProcessArchitecture}");
Console.WriteLine($"IntPtr.Size              : {IntPtr.Size} bytes");
Console.WriteLine($"Marshal.SizeOf           : {Marshal.SizeOf<DeviceInfo>()} bytes");
Console.WriteLine("--- Offset de los campos ---");

foreach (var field in typeof(DeviceInfo).GetFields())
{
    IntPtr offset = Marshal.OffsetOf<DeviceInfo>(field.Name);
    Console.WriteLine($"{field.Name,-12} : {offset}");
}

// Pegue aquí la struct que quiera verificar.
// Tenga en cuenta que debe colocarse después de las instrucciones de nivel superior
[StructLayout(LayoutKind.Sequential, Pack = 4)]
internal struct DeviceInfo
{
    public int DeviceId;
    public uint Flags;
    public long Timestamp;
}

Marshal.OffsetOf<T>(string fieldName) devuelve el offset en bytes de un campo desde el inicio de la struct tal como queda una vez hecho el marshaling a no administrado. En el lado nativo, se obtienen los mismos valores con la macro offsetof de C/C++ y con sizeof, y se comparan.

// Para comparar. Incluya la cabecera del lado nativo (la que contiene la definición de DeviceInfo)
#include <stdio.h>
#include <stddef.h>
#include "device.h"

int main(void)
{
    printf("sizeof(DeviceInfo)  = %zu\n", sizeof(DeviceInfo));
    printf("offsetof(DeviceId)  = %zu\n", offsetof(DeviceInfo, DeviceId));
    printf("offsetof(Flags)     = %zu\n", offsetof(DeviceInfo, Flags));
    printf("offsetof(Timestamp) = %zu\n", offsetof(DeviceInfo, Timestamp));
    return 0;
}

Si los valores de ambos lados coinciden en todos los campos, el layout es correcto en esa plataforma. Si aunque sea uno solo se desvía, hay un error en la especificación de Pack, o en el tipo o el orden de algún campo. Si va a distribuir tanto en 32 bits como en 64 bits, haga esta verificación en ambas arquitecturas. Que coincida solo en una de las dos es la forma típica en que se manifiesta este tipo de defecto.

8. Gestión del ciclo de vida de los callbacks (delegados)

No es raro tener que pasarle a una API nativa un callback del tipo «llame a esta función cuando termine». En código administrado, esa función la cumple un delegate, pero aquí hay una trampa propia del GC. Aunque se obtenga un puntero a función a partir de un delegado con Marshal.GetFunctionPointerForDelegate, el GC no rastrea la relación entre ese puntero a función y el delegado. Si el delegado se recolecta mientras el lado nativo todavía está usando ese puntero a función, se produce un cierre inesperado.10

Otro detalle fácil de pasar por alto es la convención de llamada. Cuando P/Invoke pasa un delegado a código nativo como puntero a función, por defecto se usa «la convención de llamada predeterminada de la plataforma», pero si se quiere hacerla coincidir explícitamente, se agrega UnmanagedFunctionPointerAttribute al tipo delegado.17 En x64/ARM/ARM64 en la práctica solo existe una convención de llamada, así que rara vez causa problemas reales aunque no se le preste atención, pero en Windows x86 (32 bits), Stdcall (el valor predeterminado de la API de Win32) y Cdecl (frecuente en bibliotecas en C de origen Unix) son distintos, así que si la cabecera del otro lado usa Cdecl y se deja el valor predeterminado, el resultado es la corrupción de la pila.17

// Especifica explícitamente la convención de llamada. Es obligatorio si el otro lado usa Cdecl en un build x86
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
private delegate void MyCallback(int code);

private static readonly MyCallback s_callback = OnNativeEvent;  // se conserva en un campo static para fijar su ciclo de vida

// [UnmanagedFunctionPointer] es la convención con la que el callback "es llamado",
// que es algo distinto de la convención de esta llamada en sí (el P/Invoke RegisterCallback).
// El valor predeterminado de LibraryImport es el de la plataforma (en Windows, equivalente a stdcall),
// así que si el otro lado es una DLL en C con Cdecl, aquí también hace falta especificarlo
[LibraryImport("nativelib")]
[UnmanagedCallConv(CallConvs = new[] { typeof(CallConvCdecl) })]
internal static partial void RegisterCallback(MyCallback callback);

private static void OnNativeEvent(int code)
{
    // ...
}

// Lado que realiza la llamada
RegisterCallback(s_callback);
GC.KeepAlive(s_callback);  // mantiene con vida explícitamente una variable que podría salir de alcance justo después

Si se conserva en un campo static, el GC no lo recolecta durante toda la vida de la aplicación. Solo cuando se tiene la certeza de que el lado nativo usará el callback durante una única llamada (y descartará el puntero a función en cuanto el callback regrese), es posible escribir una versión más ligera: extender el ciclo de vida con una variable local más GC.KeepAlive.

Las mejores prácticas oficiales recomiendan que, siempre que sea posible, se prefiera un método estático marcado con UnmanagedCallersOnlyAttribute junto con un puntero a función (delegate*<...>) en lugar de un tipo Delegate. Es una forma de escribir código con menos overhead que el marshaling de un delegado, y que además combina bien con Native AOT.10

9. Diferencias entre 32 bits y 64 bits

Basta con escribir una única firma P/Invoke para que, en tiempo de ejecución, se use la misma ruta de código tanto desde un proceso de 32 bits como desde uno de 64 bits. Aquí es fácil tropezar con el hecho de que el ancho de los tipos del lado nativo sigue a la arquitectura del proceso.

  • Los tipos de tipo puntero como HANDLE, HWND o LPARAM miden 4 bytes en un proceso de 32 bits y 8 bytes en uno de 64 bits. En el lado de .NET, lo correcto es recibirlos con IntPtr/UIntPtr (o nint/nuint); recibirlos con int/long de tamaño fijo produce código que solo funciona en 32 bits o solo en 64 bits.6
  • Si una struct contiene alguno de estos campos de tipo puntero, el tamaño total de la struct también cambia según la arquitectura. Junto con el hecho de que el valor predeterminado de Pack del capítulo 7 difiere entre arquitecturas, pruebe siempre partiendo de la premisa de que el layout binario puede diferir entre un build de 32 bits y uno de 64 bits, aunque la definición de la struct sea la misma.
  • El requisito de querer usar, desde una aplicación de 32 bits ya existente, una función de una DLL que solo funciona en 64 bits no se puede resolver con P/Invoke en sí (en un mismo proceso no pueden coexistir DLL de distinta arquitectura). En este caso, el diseño consiste en separar los procesos y tender un puente con un puente COM o con named pipes. Para un ejemplo real, consulte «Ejemplo real de un puente COM para llamar a una DLL de 64 bits desde una aplicación de 32 bits».
  • El problema de que la DLL simplemente no se encuentre, o de que se cargue una versión distinta de la esperada, no es un asunto de P/Invoke, sino del cargador de Windows. En «Cómo funciona la resolución de nombres de DLL en Windows» se explica el orden de búsqueda y el comportamiento de SxS, así que consúltelo también al investigar la causa de una DllNotFoundException.

10. Tabla de decisión — P/Invoke frente a wrapper de C++/CLI frente a interoperabilidad COM

P/Invoke no es la única forma de llamar a código nativo desde C#. Si la DLL con la que trabaja es compleja, con clases de C++, propiedad de recursos y excepciones, un wrapper de C++/CLI resulta eficaz, y si hay que cruzar el límite de proceso (un puente de 32/64 bits, el uso desde otro lenguaje como VBA), COM se convierte en una opción.

Criterio P/Invoke (LibraryImport) Wrapper de C++/CLI Interoperabilidad COM
Contraparte adecuada Interfaz en C directa (centrada en structs y tipos primitivos) DLL en la que intervienen clases de C++, propiedad de recursos, excepciones o tipos std:: Contraparte que cruza procesos, otros lenguajes como VBA
Costo de implementación Bajo a medio (solo definir la firma) Medio (hay que escribir una capa más de wrapper) Alto (diseño de interfaces, registro en el registro de Windows)
Seguridad de tipos Media (a mano, un error en la firma puede no verse hasta tiempo de ejecución; CsWin32 lo mejora) Alta (se manejan los tipos de C++ tal cual) Media (garantizada por el IDL/la biblioteca de tipos)
Compatibilidad con AOT/trimming ◎ (con LibraryImport) △ (C++/CLI no admite Native AOT)
Manejo de excepciones ✕ (hay que juzgarlo uno mismo por el valor de retorno o el HRESULT) ◎ (las excepciones de C++ se pueden convertir a excepciones de .NET) ○ (el HRESULT se convierte en una excepción COM)
Cruce del límite de proceso ✕ (solo dentro del mismo proceso) ✕ (solo dentro del mismo proceso) ◎ (permite un servidor fuera de proceso)
Facilidad de depuración ○ (con LibraryImport se puede depurar paso a paso el código generado) ○ (se puede depurar en VS tanto el código nativo como el administrado) △ (los problemas de conteo de referencias o de registro son difíciles de rastrear)
Costo de aprendizaje Bajo Medio a alto (sintaxis de C++/CLI) Alto (todo el conjunto de convenciones de COM)

Si «la contraparte es una API de Win32 basada en funciones de C, o una DLL en C directa propia de la empresa», use P/Invoke (CsWin32 si es posible); si «la contraparte es una clase de C++ y se quiere interactuar de forma natural incluyendo propiedad de recursos y excepciones», use un wrapper de C++/CLI (para más detalle, «Cómo llamar a una DLL nativa desde C#: wrapper de C++/CLI frente a P/Invoke»); y si «hay que cruzar procesos de entrada, o se quiere que se use desde VBA», use COM. Pensarlo en ese orden evita dudas. Si se convierte este orden en un diagrama, se ve que la decisión solo se bifurca en las dos primeras preguntas.

quiero llamar a código C# desde C/C++quiero que llame de vueltaa un .NET ya en ejecuciónquiero exportarlocomo una DLL nativaquiero llamar a código nativo desde C#sí lo expone (in-proc / out-of-proc)no lo exponecruza procesossí lo exigeno lo exigebasta con el mismo procesocentrada en funcionesy structs en Cclases de C++,propiedad de recursos, excepciones¿En qué dirección se llama?¿Qué forma tieneel .NET al que se llama?Pasar un delegado o un puntero a función(capítulo 8); basta con el runtime normalNative AOT + UnmanagedCallersOnly(esto no es P/Invoke)¿La contraparte yaexpone COM?Interoperabilidad COM¿Se resuelve dentrodel mismo proceso?¿La contraparte exige COM?(por ejemplo, usarlo desde VBA)Preparar uno mismoun servidor COM fuera de procesoTender un puente con un IPC existente,como named pipes, sockets o RPC (capítulo 9)Forma de la interfazde la contraparteP/Invoke (LibraryImport;CsWin32 si es la API de Win32)Wrapper de C++/CLI(no se puede usar Native AOT)

Figura 2: las tres opciones no compiten entre sí, sino que se reparten los casos de uso. Si la contraparte ya está expuesta por COM, COM es la puerta de entrada natural incluso dentro del mismo proceso; a la inversa, si solo se trata de separar procesos, un IPC existente puede bastar sin necesidad de COM

La dirección inversa (llamar a código C# desde C/C++) se divide, a su vez, en dos casos. Si solo se quiere que el lado nativo llame de vuelta (callback) a un proceso .NET que ya está en ejecución, basta con pasar un delegado o un puntero a función UnmanagedCallersOnly, como en el capítulo 8, y funciona con el runtime normal. Si, en cambio, se quiere exportar el código C# como una DLL nativa independiente para que la cargue un lado que no conoce .NET, la configuración pasa a ser publicar con Native AOT. Para este último caso, consulte «Cómo llamar a una DLL de C# Native AOT desde C/C++».

11. Ejemplo de implementación — manejo de handles y errores con LibraryImport

Este es un ejemplo de implementación que combina todo lo visto hasta aquí. Envolvemos OpenDevice / CloseDevice / ReadDeviceData, expuestas por un SDK ficticio de un dispositivo sensor, device.dll, incluyendo la gestión de handles con SafeHandle, el marshaling en tiempo de compilación con LibraryImport y el manejo de errores con SetLastError + GetLastPInvokeError.

Primero, la clase derivada de SafeHandle que retiene el handle nativo.

using Microsoft.Win32.SafeHandles;

// Envuelve el handle de device.dll. Independientemente del ciclo de vida del GC,
// evita la doble liberación, el ataque de reciclaje y la liberación prematura del handle
internal sealed class DeviceSafeHandle : SafeHandleZeroOrMinusOneIsInvalid
{
    // Se necesita un constructor sin parámetros porque se usa como valor de retorno de OpenDevice
    public DeviceSafeHandle() : base(ownsHandle: true)
    {
    }

    protected override bool ReleaseHandle()
        // Dentro de ReleaseHandle se está en una región de ejecución restringida que asume "no fallar".
        // Limitarse a una única llamada simple de liberación nativa
        => DeviceNativeMethods.CloseDevice(handle);
}

A continuación, las declaraciones P/Invoke. Se especifica explícitamente StringMarshalling.Utf16 para las cadenas, y se agrega SetLastError = true a todas las llamadas que puedan fallar.

using System.Runtime.InteropServices;

internal static partial class DeviceNativeMethods
{
    private const string DeviceDll = "device.dll";

    // Al devolver un handle como valor de retorno, SafeHandle empieza a
    // rastrear su ciclo de vida en cuanto la llamada tiene éxito. Si falla, se
    // devuelve un handle con IsInvalid en true
    [LibraryImport(DeviceDll, EntryPoint = "OpenDevice",
        StringMarshalling = StringMarshalling.Utf16, SetLastError = true)]
    internal static partial DeviceSafeHandle OpenDevice(string devicePath);

    // API interna pensada para llamarse directamente desde el ReleaseHandle de SafeHandle.
    // handle es exclusivo para liberación, así que se recibe como IntPtr crudo
    [LibraryImport(DeviceDll, EntryPoint = "CloseDevice", SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    internal static partial bool CloseDevice(IntPtr handle);

    // buffer es un arreglo ya reservado por quien llama. Como byte[] es blittable,
    // se fija (pin) y el lado nativo escribe directamente sobre esa misma memoria.
    // Especificar [Out] no es estrictamente obligatorio, pero se agrega para
    // autodocumentar la intención
    [LibraryImport(DeviceDll, EntryPoint = "ReadDeviceData", SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    internal static partial bool ReadDeviceData(
        DeviceSafeHandle handle,
        [Out] byte[] buffer,
        int bufferLength,
        out int bytesRead);
}

Por último, un wrapper delgado que utiliza todo lo anterior. El código de error se obtiene justo en el momento en que se detecta el fallo, y se envuelve en un Win32Exception para propagarlo a quien hizo la llamada.

using System.ComponentModel;
using System.Runtime.InteropServices;

public sealed class DeviceConnection : IDisposable
{
    private readonly DeviceSafeHandle _handle;

    private DeviceConnection(DeviceSafeHandle handle) => _handle = handle;

    public static DeviceConnection Open(string devicePath)
    {
        DeviceSafeHandle handle = DeviceNativeMethods.OpenDevice(devicePath);
        if (handle.IsInvalid)
        {
            // Se obtiene justo tras el fallo, antes de que otra llamada a la API lo sobrescriba
            int error = Marshal.GetLastPInvokeError();
            handle.Dispose();
            throw new IOException(
                $"No se pudo abrir el dispositivo: {devicePath} (Win32 error {error})",
                new Win32Exception(error));
        }
        return new DeviceConnection(handle);
    }

    public byte[] Read(int maxBytes)
    {
        var buffer = new byte[maxBytes];
        if (!DeviceNativeMethods.ReadDeviceData(_handle, buffer, buffer.Length, out int bytesRead))
        {
            int error = Marshal.GetLastPInvokeError();
            throw new IOException($"Fallo al leer del dispositivo (Win32 error {error})",
                new Win32Exception(error));
        }
        return bytesRead == buffer.Length ? buffer : buffer[..bytesRead];
    }

    // Basta con llamar a SafeHandle.Dispose; no se escribe un finalizador
    public void Dispose() => _handle.Dispose();
}

Quien use DeviceConnection solo necesita envolverlo en un using, sin preocuparse por olvidar liberar el handle. El principio de dónde se detecta qué y cómo se convierte en esta estructura es exactamente el mismo reparto de responsabilidades por capas descrito en «Dónde deben ir el catch y el log en el manejo de excepciones». El punto clave es trazar aquí la línea: traducir el código de error de la capa nativa a una excepción en el límite de P/Invoke, y a partir de ahí, en las capas superiores, tratarlo como una excepción normal de .NET.

12. Resumen

Detrás de la comodidad de que «basta con declarar la función de una DLL para poder llamarla», P/Invoke es una técnica en la que, tarde o temprano, se termina tropezando en algún punto: el marshaling de cadenas, el ciclo de vida de los handles, el momento en que se obtiene el código de error o el layout de las structs. Si trabaja con .NET 7 o posterior, adopte LibraryImport como opción predeterminada y, cuando sea posible, deje que CsWin32 genere la propia firma. Especifique explícitamente StringMarshalling para las cadenas y evite StringBuilder. Mantenga los handles en SafeHandle. Cuando use SetLastError, obtenga el código de error inmediatamente después de la llamada. Tenga presente que el valor predeterminado de Pack de una struct difiere según la arquitectura. Gestione explícitamente el ciclo de vida de los callbacks. Cada uno de los puntos expuestos en este artículo es del tipo «si lo sabe, se resuelve en pocas líneas, pero si no lo sabe, se convierte en un defecto que solo se reproduce en producción».

Y la decisión de si conviene insistir con P/Invoke o cambiar a un wrapper de C++/CLI o a COM depende de cuánto de «tipo C» sea la DLL con la que se trabaja, y de si hay que cruzar el límite de proceso. En las consultas sobre cómo llamar desde C# a recursos nativos ya existentes, o al revés, cómo llamar desde código nativo a recursos de C#, en muchos casos la configuración óptima solo se puede ver examinando la cabecera real o la estructura de la DLL, así que si tiene dudas, no dude en consultarnos.

Artículos relacionados

Áreas de consultoría relacionadas

KomuraSoft LLC ofrece consultoría técnica sobre el diseño del límite entre C# y las DLL nativas o la API de Win32, el desarrollo e investigación de componentes COM, y proyectos de migración que conectan recursos nativos existentes con .NET.

Referencias

  1. Microsoft Learn, Source generation for platform invokes. Sobre la generación de marshaling en tiempo de compilación mediante LibraryImportAttribute, su diferencia con la generación de un stub de IL en tiempo de ejecución de DllImport, y la afinidad con Native AOT/trimming.  2 3 4 5 6

  2. Microsoft Learn, Native interoperability best practices - Blittable types. Sobre la definición de tipo blittable, la trampa que supone que bool no sea blittable, y la ventaja de usar sizeof() en una struct blittable.  2

  3. Microsoft Learn, SYSLIB diagnostics for p/invoke source generation. Sobre la lista de IDs de diagnóstico, empezando por el analizador SYSLIB1054, que sugiere reescribir DllImport como LibraryImport. 

  4. Microsoft Learn, SafeHandle Class. Sobre el mecanismo por el cual SafeHandle evita la liberación prematura de un handle y el ataque de reciclaje, y la garantía de liberación confiable mediante CriticalFinalizerObject.  2 3

  5. Microsoft Learn, Native interoperability best practices - General guidance. Sobre el lineamiento de usar SafeHandle para gestionar el ciclo de vida de los recursos no administrados y evitar el uso de finalizadores.  2

  6. Microsoft Learn, Native interoperability best practices. Sobre el hecho de que el marshaling de StringBuilder siempre implica una copia hacia un búfer nativo y resulta ineficiente, que debe evitarse el argumento [Out] string, y que conviene usar SafeHandle y evitar los finalizadores.  2 3 4

  7. Microsoft Learn, Marshal.GetLastPInvokeError Method. Sobre cómo obtener el código de error inmediatamente después de una llamada P/Invoke con SetLastError=true, y sobre su recomendación por encima de GetLastWin32Error a partir de .NET 6.  2

  8. Microsoft Learn, Build a C# .NET app with WinUI 3 and Win32 interop. Sobre cómo introducir el C#/Win32 P/Invoke Source Generator (Microsoft.Windows.CsWin32) y el procedimiento para generar firmas enumerando nombres de funciones en NativeMethods.txt.  2

  9. Microsoft Learn, StructLayoutAttribute.Pack Field. Sobre el significado de “el tamaño de empaquetado predeterminado de la plataforma actual” que indica el valor predeterminado 0 de Pack, y sobre la regla de cálculo de la alineación de los campos.  2 3 4 5 6 7

  10. Microsoft Learn, Native interoperability best practices - Prevent delegate collection with GC.KeepAlive. Sobre el hecho de que el GC no rastrea la relación entre un delegado y el puntero a función obtenido con GetFunctionPointerForDelegate, la extensión del ciclo de vida mediante GC.KeepAlive, y la recomendación de usar UnmanagedCallersOnly.  2 3

  11. Microsoft Learn, Default Marshalling Behavior - Memory management with the interop marshaller. Sobre el hecho de que el marshaler siempre intenta liberar la memoria reservada por código no administrado, que en Windows se usa CoTaskMemFree, y que para la memoria reservada con algo distinto de CoTaskMemAlloc hay que recibirla como IntPtr y liberarla manualmente. 

  12. Microsoft Learn, Source generation for platform invokes - Differences from DllImport. Sobre el reemplazo de CharSet por StringMarshalling, el uso de UnmanagedCallConvAttribute en lugar de CallingConvention, y la ausencia de un equivalente para ExactSpelling/PreserveSig.  2 3

  13. Documentación oficial de CsWin32, Getting Started. Sobre el hecho de que cada línea de NativeMethods.txt puede contener un nombre de método, de tipo, de constante, de espacio de nombres o de módulo (con - al inicio como exclusión), que la configuración se puede cambiar con un NativeMethods.json colocado en la raíz del proyecto, que deshabilitar allowMarshaling produce una generación de código que no depende del marshaler del runtime, y que especificar https://aka.ms/CsWin32.schema.json en $schema habilita autocompletado, descripciones y validación en el editor de JSON, además de dar acceso a la lista de opciones de configuración.  2 3

  14. Microsoft Learn, Charsets and marshalling. Sobre el hecho de que, si no se especifica CharSet, los compiladores de C#, Visual Basic y F# asignan CharSet.None por defecto, y que CharSet.None se comporta igual que CharSet.Ansi (marshaling como no Unicode). 

  15. Microsoft Learn, DllImportAttribute.SetLastError Field. Sobre el comportamiento en .NET cuando SetLastError se pone en true (que la información de error se limpia en cada llamada).  2

  16. Microsoft Learn, /Zp (Struct Member Alignment). Sobre el hecho de que el valor predeterminado de la alineación de los miembros de una struct en el compilador de C++ es un límite de 8 bytes en x86/ARM/ARM64 y de 16 bytes en x64/ARM64EC. 

  17. Microsoft Learn, Unmanaged calling conventions. Sobre el hecho de que en Windows x86 Stdcall y Cdecl son convenciones de llamada predeterminadas distintas, que en x64/ARM/ARM64 en la práctica solo existe una convención de llamada, y que UnmanagedFunctionPointerAttribute permite especificarla explícitamente.  2

Artículos recientes con las mismas etiquetas para profundizar en temas cercanos.

Estas páginas sitúan el tema en un contexto más amplio de servicios y decisiones.

El artículo está directamente relacionado con los siguientes servicios.

Preguntas frecuentes

Preguntas habituales en las consultas sobre el tema del artículo.

¿Debo usar DllImport o LibraryImport?
A partir de .NET 7, LibraryImport debe ser la opción predeterminada. Mientras que DllImport genera en tiempo de ejecución un stub de IL para el marshaling, LibraryImport usa un generador de código fuente que produce ese código de marshaling en tiempo de compilación, por lo que es compatible con Native AOT y con el recorte (trimming), y el código generado puede ejecutarse paso a paso en el depurador. El analizador SYSLIB1054 indica dónde reescribir el código que usa DllImport. Solo conviene volver a DllImport cuando se necesita una configuración que LibraryImport todavía no admite (por ejemplo, ciertas especificaciones de MarshalAs).
¿Por qué es peligroso mantener un handle en P/Invoke como IntPtr?
Porque existen tres problemas. Primero, puede producirse una carrera de liberación prematura en la que el GC recolecta el objeto y cierra el handle mientras una llamada P/Invoke todavía está en curso. Segundo, Windows reutiliza activamente los valores de handle, lo que da lugar a un ataque de reciclaje en el que se termina operando sobre un recurso completamente distinto usando un valor de handle que se creía cerrado. Tercero, una excepción asíncrona puede provocar una fuga del handle. Usar una clase derivada de SafeHandle evita estos tres problemas gracias al conteo de referencias automático y a la garantía de liberación confiable.
¿Existe alguna forma de evitar escribir a mano las firmas de la API de Win32?
Sí, se puede usar el generador de código fuente CsWin32 (Microsoft.Windows.CsWin32). Basta con agregar el paquete NuGet y enumerar los nombres de las funciones que se quieren llamar en un archivo de texto llamado NativeMethods.txt: las firmas, constantes y estructuras se generan automáticamente a partir de los metadatos oficiales de Win32. Como HANDLE se genera como el tipo derivado de SafeHandle adecuado, se evitan de raíz errores habituales al escribir a mano, como confundir el CharSet o el orden de los campos. Por defecto la generación se basa en DllImport, así que si se apunta a Native AOT hay que indicar allowMarshaling: false en NativeMethods.json.
¿Cómo se elige entre P/Invoke, un wrapper de C++/CLI y COM?
La elección depende de la naturaleza de la DLL con la que se interactúa y de si hay que cruzar un límite de proceso. Para una interfaz en C directa (centrada en structs y tipos primitivos), P/Invoke es la opción de menor costo, y si se trata de la API de Win32 conviene combinarlo con CsWin32. Cuando la DLL es compleja e involucra clases de C++, propiedad de recursos, excepciones o tipos std::, conviene interponer un wrapper de C++/CLI. Cuando hay que cruzar el límite de proceso, como en un puente de 32/64 bits, o cuando otro lenguaje como VBA necesita usarlo, COM es la opción. Dado que un mismo proceso no puede alojar DLL de distinta arquitectura (bitness) a la vez, ese requisito no se puede resolver con P/Invoke.

Perfil del autor

Página de presentación del autor del artículo.

Go Komura

Representante de KomuraSoft LLC

Especializado en desarrollo de software para Windows, consultoría técnica e investigación de fallos, sobre todo en proyectos con sistemas existentes y errores difíciles de reproducir.

Volver al blog