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: · Go Komura · 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
LibraryImportcomo opción predeterminada en lugar deDllImport. 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 analizadorSYSLIB1054señala dónde reescribir el código que usaDllImport.13 - Mantenga los handles en una clase derivada de
SafeHandle, no en unIntPtrcrudo. 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
StringMarshallingpara las cadenas y eviteStringBuilder. El marshaling deStringBuildersiempre 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, leaMarshal.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.
flowchart TB
subgraph MG["Lado administrado (.NET)"]
CODE["Código C# que realiza la llamada"]
OBJ["Objetos que mueve el GC<br/>• Arreglo blittable → se fija (pin) y se pasa tal cual<br/>• No blittable (cadenas, etc.) → se convierte y se copia<br/>• Delegado → se mantiene vivo durante la llamada"]
SH["SafeHandle<br/>posee el ciclo de vida del handle nativo"]
end
subgraph BD["Límite (marshaler)"]
SIG["Declaración de DllImport / LibraryImport<br/>= el contrato del límite es exactamente lo que está escrito aquí"]
CONV["Conversión de tipos y ajuste de la convención de llamada"]
TMP["Búfer temporal de destino"]
end
subgraph NT["Lado nativo (API de Win32 / DLL en C propia)"]
FN["Función exportada"]
NRES["Memoria y handles nativos"]
end
CODE --> SIG --> CONV --> FN --> NRES
OBJ -.->|"la forma de pasarlo depende de la naturaleza del valor"| CONV
CONV --> TMP
TMP -.->|"pasa el valor copiado"| FN
NRES -.->|"la declaración no dice quién libera<br/>hay que decidirlo según la especificación de la API"| SH
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
CharSetdesaparece y se sustituye porStringMarshalling(Utf16/Utf8/ personalizado). ANSI queda eliminado y UTF-8 pasa a ser una opción de primera clase.CallingConventionse sustituye porUnmanagedCallConvAttribute.- No hay equivalente para
ExactSpellingniPreserveSig: 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 necesitaAllowUnsafeBlocks.
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
IntPtrantiguo 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 deMarshal.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
Packespecificado» - 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,HWNDoLPARAMmiden 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 conIntPtr/UIntPtr(onint/nuint); recibirlos conint/longde 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
Packdel 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.
flowchart TD
Q0{"¿En qué dirección se llama?"}
Q0 -->|"quiero llamar a código C# desde C/C++"| QR{"¿Qué forma tiene<br/>el .NET al que se llama?"}
QR -->|"quiero que llame de vuelta<br/>a un .NET ya en ejecución"| CB["Pasar un delegado o un puntero a función<br/>(capítulo 8); basta con el runtime normal"]
QR -->|"quiero exportarlo<br/>como una DLL nativa"| AOT["Native AOT + UnmanagedCallersOnly<br/>(esto no es P/Invoke)"]
Q0 -->|"quiero llamar a código nativo desde C#"| QC{"¿La contraparte ya<br/>expone COM?"}
QC -->|"sí lo expone (in-proc / out-of-proc)"| COM["Interoperabilidad COM"]
QC -->|"no lo expone"| Q1{"¿Se resuelve dentro<br/>del mismo proceso?"}
Q1 -->|"cruza procesos"| QI{"¿La contraparte exige COM?<br/>(por ejemplo, usarlo desde VBA)"}
QI -->|"sí lo exige"| COM2["Preparar uno mismo<br/>un servidor COM fuera de proceso"]
QI -->|"no lo exige"| IPC["Tender un puente con un IPC existente,<br/>como named pipes, sockets o RPC (capítulo 9)"]
Q1 -->|"basta con el mismo proceso"| Q2{"Forma de la interfaz<br/>de la contraparte"}
Q2 -->|"centrada en funciones<br/>y structs en C"| PI["P/Invoke (LibraryImport;<br/>CsWin32 si es la API de Win32)"]
Q2 -->|"clases de C++,<br/>propiedad de recursos, excepciones"| CLI["Wrapper de C++/CLI<br/>(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
- Cómo llamar a una DLL nativa desde C#: wrapper de C++/CLI frente a P/Invoke
- Cómo llamar a una DLL de C# Native AOT desde C/C++
- Ejemplo real de un puente COM para llamar a una DLL de 64 bits desde una aplicación de 32 bits
- Cómo funciona la resolución de nombres de DLL en Windows - orden de búsqueda y SxS
Á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.
- Consultoría técnica y revisión de diseño
- Desarrollo de componentes COM
- Desarrollo de aplicaciones Windows
- Contacto
Referencias
-
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
-
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
-
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. ↩
-
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
-
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
-
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
-
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
-
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
-
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
-
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
-
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. ↩
-
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
-
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
-
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). ↩
-
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
-
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. ↩
-
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 relacionados
Artículos recientes con las mismas etiquetas para profundizar en temas cercanos.
MAX_PATH y las trampas de rutas y nombres de archivo en Windows — el límite de 260 caracteres, nombres reservados, el punto final y las mayúsculas y minúsculas
Analizamos las limitaciones de rutas y nombres de archivo, causa habitual de «archivo no encontrado»: el desglose de MAX_PATH=260, la act...
Suspensión, hibernación y Modern Standby: evitar con diseño que las apps de larga duración "se detengan de noche"
Analiza por qué las apps Windows de larga duración se detienen de noche, según las diferencias entre S3, hibernación y Modern Standby. Ex...
¿Las aplicaciones empresariales funcionan en Windows para Arm? — La realidad de la emulación x64 (Prism), las DLL nativas y COM
Respondemos si las aplicaciones empresariales funcionan en Windows para Arm: la emulación x64 (Prism), las capas que no funcionan (contro...
Trampas de las unidades de red y las rutas UNC — la gestión práctica de servidores de archivos (carpetas compartidas) en aplicaciones empresariales
Analizamos los problemas típicos al escribir en carpetas compartidas o monitorizarlas desde una app empresarial: la letra de unidad (Z:),...
Prevención de la ejecución múltiple en aplicaciones de Windows — Mutex con nombre y activación en el segundo inicio
Analizamos cómo implementar la prevención de la ejecución múltiple en apps Windows mediante un Mutex con nombre. Cubrimos Global\ y Local...
Temas relacionados
Estas páginas sitúan el tema en un contexto más amplio de servicios y decisiones.
Temas técnicos de Windows
Portal sobre desarrollo de Windows, investigación de fallos y aprovechamiento de activos existentes.
Servicios relacionados con este tema
El artículo está directamente relacionado con los siguientes servicios.
Desarrollo de aplicaciones para Windows
Aplicaciones empresariales, integración de dispositivos y herramientas de comunicación, de los requisitos al desarrollo.
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.