Cómo invocar una DLL nativa de C# Native AOT desde C/C++
· Actualizado el: · Go Komura · C#, .NET, Native AOT, C++, Desarrollo en Windows, Integración nativa
En el artículo anterior, Por qué un wrapper de C++/CLI es la opción más sólida para usar una DLL nativa desde C#, organizamos el límite que aparece al llamar a C++ desde C#. Esta vez invertimos el sentido: hablamos de llamar a C# desde C/C++.
Hay escenarios en los que se quiere invocar, desde una aplicación C/C++ existente, un procesamiento escrito en C#, pero P/Invoke tiene el sentido inverso y tampoco vale la pena llegar hasta C++/CLI o COM. Es el caso, en particular, de cuando se quiere mantener intacto el cuerpo de la aplicación nativa y desplazar a C# solo partes como la lógica de decisión, el procesamiento de cadenas, la interpretación de configuración o las reglas de cálculo.
También se puede tender un puente con COM, pero en este caso buscamos algo más in-process, más parecido a una DLL propiamente dicha. Con Native AOT de .NET, una biblioteca de clases se puede publicar como biblioteca compartida nativa, y los métodos marcados con UnmanagedCallersOnly se pueden exponer como puntos de entrada de C. Es decir, C# puede usarse como la «DLL nativa a la que se llama».
Sin embargo, no todo puede cruzar el límite tal cual. Si se deja escapar al límite tipos como string, List<T>, excepciones o la propiedad de los objetos, el ambiente se enrarece de golpe. En este artículo, con un ejemplo mínimo de Windows + C++, organizamos en qué situaciones encaja bien esta configuración y qué forma de API resulta más resistente a romperse. El enfoque es prácticamente el mismo en Linux/macOS, pero los ejemplos de código dan por supuesto una DLL de Windows.
El código que aparece en este artículo se publica en GitHub como un conjunto de ejemplos que se pueden compilar y ejecutar (la biblioteca de C# publicada con Native AOT, ejemplos de invocación en C++ y pruebas unitarias).
csharp-native-aot-native-dll-from-c-cpp - komurasoft-blog-samples (GitHub)
Índice
- Conclusión, en una frase
- Cuándo usar cada opción, de un vistazo
- Diagrama de arquitectura
- Configuración mínima
- 4.1. Proyecto de C#
- 4.2. Código de C# que se exporta
- 4.3. Comando de publicación
- 4.4. Ejemplo de invocación desde C++
- 4.5. Verificar que la exportación existe
- 4.6. Enlace estático con una biblioteca de importación
- Forma de API resistente a roturas
- 5.1. Ajustarse al ABI de C
- 5.2. Tratar las cadenas como puntero + longitud + capacidad del búfer
- 5.3. No dejar que las excepciones crucen el límite
- 5.4. Fijar la convención de llamada
- 5.5. Mantener los métodos export delgados y el cuerpo aparte
- Casos en los que encaja bien
- Casos en los que, aun así, no encaja
- Dónde suele atascarse la gente
- Resumen
- Referencias
1. Conclusión, en una frase
- Si se quiere invocar procesamiento de C# desde C/C++ en el mismo proceso, Native AOT +
UnmanagedCallersOnlyes una opción bastante sólida. - Sin embargo, lo que se exporta es, en definitiva, un punto de entrada de función de C. No es un mundo en el que se puedan mostrar directamente
stringoList<T>. - En la práctica, resulta más estable reducirlo a una API de C plana del estilo
create/destroy/operate, dejando explícita la gestión del ciclo de vida y los códigos de error. - Si se quiere tratar de forma natural las clases de C++ o la STL, C++/CLI es más adecuado; si se necesita registro, automatización o cruzar procesos, COM es la mejor opción.
En resumen: se puede usar C# como el contenido de una DLL nativa, pero hay que diseñar la superficie del límite como un ABI de C, no como .NET. Si se asume esto con claridad, se convierte en una herramienta bastante interesante.
2. Cuándo usar cada opción, de un vistazo
| Qué se quiere hacer | Opción más sólida | Motivo |
|---|---|---|
| Llamar a un conjunto de funciones de C desde C# | P/Invoke | El sentido es directo; es la opción más natural |
| Tratar una biblioteca de C++ de forma natural desde C# | C++/CLI | Es fácil absorber en el lado de C++ los tipos de C++, la propiedad, las excepciones y cosas como std::wstring |
| Cruzar 32 bits/64 bits o límites de proceso | COM / IPC | Una DLL in-process por sí sola no puede cruzarlos |
| Llamar a lógica de C# como DLL nativa desde C/C++ | Native AOT + UnmanagedCallersOnly |
Se puede exportar el propio entry point de C |
Esta configuración encaja especialmente bien en los escenarios en los que «el lado nativo es el protagonista y C# se invoca como una pieza». Aquí el sentido es justo el opuesto al de P/Invoke o C++/CLI.
3. Diagrama de arquitectura
flowchart LR
accTitle: Arquitectura de la llamada desde C/C++ hasta la lógica de C# a través de una DLL publicada con Native AOT
accDescr: Diagrama que muestra cómo una aplicación C/C++ llama mediante cdecl a una DLL de C# publicada con Native AOT, que expone puntos export marcados con UnmanagedCallersOnly, los cuales se conectan a la lógica de negocio de C# y a la tabla de handles y gestión de estado
Cpp["Aplicación C / C++"] -->|"llamada de función cdecl"| Dll["DLL de C# publicada con Native AOT"]
Dll --> Exports["export con UnmanagedCallersOnly"]
Exports --> Core["Lógica de negocio de C#"]
Exports --> Store["Tabla de handles / gestión de estado"]
El aspecto es sencillo. Lo importante es alinear la superficie del límite con funciones de C. La implementación interna del lado de C# puede ser una clase, una colección o usar LINQ, pero la cara que se muestra hacia afuera se mantiene plana.
4. Configuración mínima
Aquí usamos un ejemplo mínimo en el que, desde el lado de C++, se crea un «acumulador», se le van sumando valores y, al final, se obtiene el total. En la práctica podría ser un motor de decisión, la interpretación de una configuración o un analizador sencillo. Piense en ello como una forma en la que el lado nativo mantiene el handle y va llamando en orden a las funciones de operación.
4.1. Proyecto de C#
Primero preparamos la biblioteca de clases.
<!-- NativeAotSample.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<PublishAot>true</PublishAot>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
</Project>
Hay dos puntos clave:
- Habilitar la publicación (publish) con Native AOT
- Permitir
unsafe, ya que se usan argumentos de puntero
El ejemplo de este artículo da por supuesto net8.0, pero el enfoque en sí es el mismo en .NET 9/10.
4.2. Código de C# que se exporta
Los métodos marcados con UnmanagedCallersOnly son los puntos de entrada visibles desde el lado nativo. Aquí el handle se entrega como un entero, y el estado interno se gestiona con un dictionary en el lado de C#.
// NativeExports.cs
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
namespace KomuraSoft.NativeAotSample;
internal static class NativeStatus
{
public const int Ok = 0;
public const int InvalidArgument = -1;
public const int InvalidHandle = -2;
public const int UnexpectedError = -3;
}
internal sealed class Accumulator
{
public long Total { get; private set; }
public void Add(int value)
{
Total += value;
}
}
internal static class AccumulatorStore
{
private static readonly object s_gate = new();
private static readonly Dictionary<nint, Accumulator> s_instances = new();
private static long s_nextHandle = 0;
public static int Create(out nint handle)
{
try
{
var instance = new Accumulator();
handle = (nint)System.Threading.Interlocked.Increment(ref s_nextHandle);
lock (s_gate)
{
s_instances.Add(handle, instance);
}
return NativeStatus.Ok;
}
catch
{
handle = 0;
return NativeStatus.UnexpectedError;
}
}
public static int Add(nint handle, int value)
{
try
{
lock (s_gate)
{
if (!s_instances.TryGetValue(handle, out var instance))
{
return NativeStatus.InvalidHandle;
}
instance.Add(value);
return NativeStatus.Ok;
}
}
catch
{
return NativeStatus.UnexpectedError;
}
}
public static int GetTotal(nint handle, out long total)
{
try
{
lock (s_gate)
{
if (!s_instances.TryGetValue(handle, out var instance))
{
total = 0;
return NativeStatus.InvalidHandle;
}
total = instance.Total;
return NativeStatus.Ok;
}
}
catch
{
total = 0;
return NativeStatus.UnexpectedError;
}
}
public static int Destroy(nint handle)
{
try
{
lock (s_gate)
{
return s_instances.Remove(handle)
? NativeStatus.Ok
: NativeStatus.InvalidHandle;
}
}
catch
{
return NativeStatus.UnexpectedError;
}
}
}
public static unsafe class NativeExports
{
[UnmanagedCallersOnly(
EntryPoint = "km_accumulator_create",
CallConvs = new[] { typeof(CallConvCdecl) })]
public static int AccumulatorCreate(nint* outHandle)
{
if (outHandle == null)
{
return NativeStatus.InvalidArgument;
}
var status = AccumulatorStore.Create(out var handle);
*outHandle = handle;
return status;
}
[UnmanagedCallersOnly(
EntryPoint = "km_accumulator_add",
CallConvs = new[] { typeof(CallConvCdecl) })]
public static int AccumulatorAdd(nint handle, int value)
{
return AccumulatorStore.Add(handle, value);
}
[UnmanagedCallersOnly(
EntryPoint = "km_accumulator_get_total",
CallConvs = new[] { typeof(CallConvCdecl) })]
public static int AccumulatorGetTotal(nint handle, long* outTotal)
{
if (outTotal == null)
{
return NativeStatus.InvalidArgument;
}
var status = AccumulatorStore.GetTotal(handle, out var total);
*outTotal = total;
return status;
}
[UnmanagedCallersOnly(
EntryPoint = "km_accumulator_destroy",
CallConvs = new[] { typeof(CallConvCdecl) })]
public static int AccumulatorDestroy(nint handle)
{
return AccumulatorStore.Destroy(handle);
}
}
Lo que se hace aquí es bastante sencillo:
- Al lado nativo solo se le muestra el handle, de tipo
intptr_t - El estado en sí se mantiene en el lado de C#
- create / add / get / destroy se descomponen en funciones planas
- El valor de retorno es un código de error, y los valores de salida se devuelven como argumentos de puntero
Con esta forma, aunque más adelante se reemplace la implementación interna del lado de C#, el ABI del lado de C se mantiene bastante estable.
Sobre la numeración de handles, agregamos una sola aclaración. En el ejemplo, el contador de numeración se mantiene como long, y el resultado de Interlocked.Increment se convierte (cast) a nint. Aquí hay dos propiedades que conviene conocer.
- Nunca se entrega el 0. El contador empieza en 0 y
Incrementdevuelve el valor después de sumar, por lo que el primer handle es 1. Por eso el lado de C++ puede usarintptr_t handle = 0;como marca de «todavía no se tiene». - En 32 bits se produce un truncamiento.
ninttiene el ancho de un puntero, así que en 64 bits ocupa 64 bits, pero en una compilación de 32 bits ocupa 32 bits. Al convertir delonganintse descartan silenciosamente los bits superiores, de modo que, si la numeración supera 2^32, el valor da una vuelta completa (wraparound). En un uso que repite create/destroy de forma continua durante 24 horas, en teoría se puede llegar a ese punto.
Conviene entender con precisión qué ocurre cuando se da la vuelta. La excepción de clave duplicada de s_instances.Add(handle, instance) solo ayuda cuando un handle con el mismo valor está «vivo en este momento». El uso habitual de esta API es repetir create y destroy, y un handle ya destruido desaparece del diccionario. Es decir, cuando la numeración da la vuelta y regresa al mismo valor, no hay ninguna clave en el diccionario, así que Add tiene éxito. Como resultado, un handle antiguo que el lado de C todavía conserva pasa a apuntar a una instancia nueva que no tiene relación con él. No se produce ninguna excepción ni se devuelve ningún código de error, así que el valor simplemente se corrompe en silencio.
Además, como el valor exactamente igual a 2^32 tiene los 32 bits inferiores en 0, se termina entregando el 0 que se suponía que era la marca de «todavía no se tiene».
Por lo tanto, no confíe en la verificación de claves duplicadas como mecanismo de seguridad. Si existe la posibilidad de trabajar con 32 bits, aplique una de estas dos opciones:
- Incrustar un número de generación en el handle. Usar los bits inferiores como número consecutivo y los superiores como generación, avanzando la generación en cada destroy. Aunque vuelva el mismo número consecutivo, el valor completo no coincidirá
- Hacer que falle de forma permanente una vez agotado. Cuando la numeración llegue al límite, convertir en error todos los create posteriores. En un dispositivo que sigue funcionando de forma continua esto exige un reinicio, pero es más manejable que una corrupción silenciosa
En cualquiera de los dos casos, conviene además mantener el propio contador de numeración como nint, para no superar su ancho, y asegurarse de no entregar nunca el 0.
4.3. Comando de publicación
Primero, un requisito previo. La publicación (publish) con Native AOT necesita, además, una cadena de herramientas nativa aparte.
Si simplemente se agrega PublishAot y se ejecuta dotnet publish, el fallo no ocurre en la compilación de C#, sino en la etapa final del enlazado nativo. Este es el primer obstáculo.
| Entorno | Qué se necesita |
|---|---|
| Windows | Visual Studio 2022 o posterior. Instalar la carga de trabajo «Desarrollo de escritorio con C++» con todos sus componentes predeterminados |
| Ubuntu 18.04 o posterior | sudo apt-get install clang zlib1g-dev |
| Alpine 3.15 o posterior | sudo apk add clang build-base zlib-dev |
| Fedora 39 o posterior / RHEL 8 o posterior | sudo dnf install clang zlib-ng-devel zlib-ng-compat-devel zlib-devel |
| macOS | Command Line Tools de Xcode (compatible desde .NET 8) |
Como este artículo da por supuesto Windows + C++, en la práctica se trata de verificar primero si está instalada la carga de trabajo de C++ de Visual Studio.
Errores como que no se encuentre el enlazador o que falle algo relacionado con link.exe suelen originarse aquí.
Hecho esto, se publica como biblioteca compartida.
dotnet publish -r win-x64 -c Release /p:NativeLib=Shared
Con esto, la DLL nativa aparece bajo bin/Release/net8.0/win-x64/publish/. En Windows es .dll, en Linux es .so y en macOS es .dylib.
Lo importante es publicar para cada RID por separado. Lo que se genera para win-x64 no se puede usar dando por supuesto win-arm64, y además hay que igualar la arquitectura (bitness) entre el lado que invoca y la DLL.
4.4. Ejemplo de invocación desde C++
Por ahora dejamos de lado el tema de la biblioteca de importación (import lib) y hacemos la llamada de forma directa con LoadLibrary / GetProcAddress. Con esta forma resulta fácil ver qué se está exportando y con qué firma hay que recibirlo.
/* native_api.h */
#pragma once
#include <stdint.h>
enum km_status
{
KM_STATUS_OK = 0,
KM_STATUS_INVALID_ARGUMENT = -1,
KM_STATUS_INVALID_HANDLE = -2,
KM_STATUS_UNEXPECTED_ERROR = -3
};
typedef int (__cdecl *km_accumulator_create_fn)(intptr_t* out_handle);
typedef int (__cdecl *km_accumulator_add_fn)(intptr_t handle, int value);
typedef int (__cdecl *km_accumulator_get_total_fn)(intptr_t handle, int64_t* out_total);
typedef int (__cdecl *km_accumulator_destroy_fn)(intptr_t handle);
// main.cpp
#include <cstdint>
#include <cstdlib>
#include <iostream>
#include <windows.h>
#include "native_api.h"
template <typename T>
T LoadSymbol(HMODULE module, const char* name)
{
FARPROC proc = ::GetProcAddress(module, name);
if (proc == nullptr)
{
std::cerr << "GetProcAddress failed: " << name << '\n';
std::exit(EXIT_FAILURE);
}
return reinterpret_cast<T>(proc);
}
int main()
{
HMODULE module = ::LoadLibraryW(L"NativeAotSample.dll");
if (module == nullptr)
{
std::cerr << "LoadLibraryW failed" << '\n';
return EXIT_FAILURE;
}
auto create = LoadSymbol<km_accumulator_create_fn>(module, "km_accumulator_create");
auto add = LoadSymbol<km_accumulator_add_fn>(module, "km_accumulator_add");
auto getTotal = LoadSymbol<km_accumulator_get_total_fn>(module, "km_accumulator_get_total");
auto destroy = LoadSymbol<km_accumulator_destroy_fn>(module, "km_accumulator_destroy");
intptr_t handle = 0;
if (create(&handle) != KM_STATUS_OK)
{
std::cerr << "create failed" << '\n';
return EXIT_FAILURE;
}
if (add(handle, 10) != KM_STATUS_OK)
{
std::cerr << "add(10) failed" << '\n';
return EXIT_FAILURE;
}
if (add(handle, 20) != KM_STATUS_OK)
{
std::cerr << "add(20) failed" << '\n';
return EXIT_FAILURE;
}
std::int64_t total = 0;
if (getTotal(handle, &total) != KM_STATUS_OK)
{
std::cerr << "get_total failed" << '\n';
return EXIT_FAILURE;
}
std::cout << "total = " << total << '\n';
if (destroy(handle) != KM_STATUS_OK)
{
std::cerr << "destroy failed" << '\n';
return EXIT_FAILURE;
}
handle = 0;
// Las bibliotecas compartidas publicadas con Native AOT no están pensadas para descargarse.
// FreeLibrary(module);
return EXIT_SUCCESS;
}
En este ejemplo, lo único que ve el lado de C++ es «una API de C que se puede invocar mediante punteros a función». Apenas hace falta tener presente que por dentro está escrito en C#.
Si se coloca la DLL publicada en la misma carpeta que main.exe y se ejecuta, como se están sumando 10 y 20, la salida estándar es solo esta:
total = 30
Si algo falla en el camino, std::cerr muestra en qué etapa se produjo el fallo. LoadLibraryW failed indica que la DLL no se encuentra en absoluto; GetProcAddress failed: km_accumulator_add indica que la DLL sí se pudo leer, pero no se encontró la exportación. Así se puede distinguir el origen del problema.
4.5. Verificar que la exportación existe
Cuando «no se puede invocar», lo primero es comprobar si el nombre realmente aparece en el lado de la DLL. Lo más rápido es usar dumpbin desde el Developer Command Prompt de Visual Studio.
dumpbin /exports NativeAotSample.dll
Si en la lista de nombres aparecen los cuatro: km_accumulator_create / km_accumulator_add / km_accumulator_get_total / km_accumulator_destroy, la publicación del lado de C# se realizó correctamente. Para filtrar por nombre, se hace así:
dumpbin /exports NativeAotSample.dll | findstr km_
Si el nombre no aparece aquí, el problema está en el lado de C#; si aparece pero GetProcAddress falla, el problema está en el lado que hace la llamada. Así se puede aislar la causa.
Cuando GetProcAddress devuelve NULL, los puntos que conviene revisar son, en general, en este orden:
- Si el nombre aparece en
dumpbin /exports(si no aparece, es un problema del lado de C#) - Si la cadena escrita en
EntryPointcoincide exactamente con la cadena pasada aGetProcAddress(también se distingue entre mayúsculas y minúsculas) - Si la arquitectura (bitness) del EXE que llama y de la DLL coinciden
- Si el método marcado con
UnmanagedCallersOnlyesstaticy no está dentro de un generic - Si ese atributo está escrito en el ensamblado que es objeto de la publicación (si se escribe en una biblioteca referenciada, no aparecerá en la tabla de exportación)
Cabe señalar que, si el propio LoadLibraryW falla, no se trata de un problema de exportación. Sospeche primero de la ruta de la DLL, de la arquitectura (bitness) o de la falta de alguna DLL de la que dependa.
4.6. Enlace estático con una biblioteca de importación
Hasta aquí escribimos siguiendo el método LoadLibrary / GetProcAddress, porque facilita ver qué se exporta y con qué firma hay que recibirlo.
Por otro lado, en la práctica es habitual querer «incluir un header e invocar la función directamente». En ese caso se recurre a la carga estática mediante una biblioteca de importación. El procedimiento es el siguiente:
- Si la salida de la publicación incluye una biblioteca de importación (
.lib), se enlaza directamente - Si no aparece, se prepara un archivo
.defcon los nombres exportados y se genera la biblioteca de importación conlib.exe /def:NativeAotSample.def /out:NativeAotSample.lib /machine:x64 - En el header, en lugar de tipos de puntero a función, se usan declaraciones de función normales
/* native_api_static.h */
#pragma once
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
int __cdecl km_accumulator_create(intptr_t* out_handle);
int __cdecl km_accumulator_add(intptr_t handle, int value);
int __cdecl km_accumulator_get_total(intptr_t handle, int64_t* out_total);
int __cdecl km_accumulator_destroy(intptr_t handle);
#ifdef __cplusplus
}
#endif
Con esta forma, el código del lado que llama se vuelve bastante directo. A cambio, si no se encuentra la DLL, el fallo ocurre en el momento de iniciar el proceso, por lo que resulta difícil operar bajo el esquema de «el cuerpo principal funciona, pero solo esta función queda inutilizable». Si se quiere insertar algo al estilo de un plugin, sigue siendo más manejable el método LoadLibrary.
Cabe señalar que la publicación como biblioteca estática (NativeLib=Static) no está oficialmente soportada, así que es más seguro no contar con ella.
5. Forma de API resistente a roturas
Poder exportar con Native AOT resulta interesante, pero en la práctica lo más importante es qué es lo que no se exporta.
5.1. Ajustarse al ABI de C
Primero, aclaremos la terminología. El núcleo de este artículo es «diseñar la superficie del límite como un ABI de C, no como .NET», y ese ABI es la sigla de Application Binary Interface (interfaz binaria de aplicación): es el conjunto de acuerdos sobre cómo encajan entre sí, en tiempo de ejecución, binarios ya compilados. Piense en ello no como un acuerdo a nivel de código fuente, sino a nivel de lenguaje máquina. Se compone principalmente de tres partes.
| Acuerdo | Qué define | Qué ocurre si no se respeta |
|---|---|---|
| Convención de llamada (calling convention) | Si los argumentos se pasan por registros o por la pila y de qué forma, dónde se coloca el valor de retorno, y si quien restaura la pila tras la llamada es el que llama o el que es llamado | Los argumentos quedan desalineados, o la pila se corrompe justo después de retornar |
| Diseño (layout) de los tipos | Cuántos bytes ocupa cada tipo, en qué posición quedan los miembros de un struct (relleno y alineación) | Deja de poder leerse el valor a partir de cierto punto de la estructura |
| Nombre y enlace (link) | La ortografía del nombre de la función exportada, si existe decoración (name mangling) o no | GetProcAddress no logra encontrar el nombre |
cdecl y stdcall son nombres del primero de estos tres acuerdos, la «convención de llamada». Las clases y las excepciones de C++ no encajan si se exponen tal cual en el límite, porque estos tres acuerdos varían según el compilador. Dicho de otro modo, si se reduce todo a funciones de C y tipos básicos, los acuerdos son simples y por tanto encajan con facilidad. «Ajustarse al ABI de C» significa reducir la superficie del límite hasta ese conjunto simple de acuerdos.
Dicho esto, conviene, desde el principio, limitar los tipos que se exponen en el límite a algo parecido a lo siguiente.
- Tipos básicos como
int32_t/int64_t/double - Struct de diseño (layout) fijo
- Handle equivalente a
intptr_t/void* uint8_t*junto con una longitud
En cambio, esto es lo que conviene evitar exponer desde el principio:
stringobjectList<T>TaskSpan<T>- Clases de C++, o
std::vectorystd::wstring
Si se intenta hacer cruzar estos elementos tal cual, la superficie del límite se enturbia enseguida. Lo importante es no filtrar las particularidades de C# hacia C++, ni tampoco filtrar en exceso las particularidades de C++ hacia C#.
Para reducir errores al transcribir, incluimos también una tabla de correspondencias. En la firma de un método marcado con UnmanagedCallersOnly solo se pueden usar tipos blittable, así que en la práctica todo queda dentro de este rango.
| Lado de C# | Lado de C / C++ | Nota |
|---|---|---|
byte / sbyte |
uint8_t / int8_t |
|
short / ushort |
int16_t / uint16_t |
|
int / uint |
int32_t / uint32_t |
|
long / ulong |
int64_t / uint64_t |
El long de C++ tiene 32 bits en Windows y 64 bits en el LP64 de Linux, así que es más seguro usar int64_t en lugar de escribir long |
nint / nuint |
intptr_t / uintptr_t |
Ancho de puntero. En una compilación de 32 bits pasa a ser de 32 bits |
float / double |
float / double |
|
bool |
No se usa | No es blittable. Se pasa como int32_t con 0/1 |
char / string |
No se usa | Las cadenas se tratan, como en 5.2, mediante puntero + longitud |
T* (puntero unsafe) |
T* |
Los valores de salida se devuelven así |
| Struct de diseño fijo | Struct con el mismo diseño | Hay que hacer coincidir siempre, en ambos lados, el orden de los miembros, el tipo y el relleno (padding) |
5.2. Tratar las cadenas como puntero + longitud + capacidad del búfer
Cuando surge la necesidad de intercambiar cadenas, dan ganas de exponer directamente string, pero conviene contenerse aquí. En el límite de la biblioteca resulta claro reducirlo, por ejemplo, a una forma como la siguiente.
int km_parse_utf8(const uint8_t* text, int32_t text_len, int32_t* out_value);
int km_format_utf8(int32_t value, uint8_t* buffer, int32_t buffer_len, int32_t* out_written);
Es decir, hay que decidir de antemano la codificación de caracteres, la longitud y quién reserva el búfer. Como se trata de Windows, también existe la opción de inclinarse por UTF-16, pero si se piensa también en otros lenguajes, UTF-8 suele ser más manejable.
5.3. No dejar que las excepciones crucen el límite
El límite de las funciones nativas no es especialmente amigable como forma de representar excepciones. Como mínimo, es más seguro no diseñar el sistema de forma que una excepción managed se filtre tal cual hacia quien hace la llamada.
En la práctica resulta manejable dejarlo así:
- El valor de retorno es un código de estado (status code)
- Los datos reales se pasan mediante un búfer de salida o argumentos de puntero
- Si hace falta, se obtiene información adicional con un formato del estilo
get_last_error
No es algo vistoso, pero este tipo de diseño discreto rinde frutos más adelante. Se trata de no ponerse, de repente, a hacer artes marciales en la superficie del límite.
5.4. Fijar la convención de llamada
En el ejemplo especificamos explícitamente CallConvCdecl. Si se omite, se usa la convención de llamada predeterminada de la plataforma, pero si se quiere fijar el header o el tipo de puntero a función, es menos propenso a accidentes especificarlo explícitamente de este lado.
En particular, si existe la posibilidad de tratar con x86, dejar esto ambiguo se vuelve doloroso más adelante. Aunque en x64 no suele salir a la superficie, es mejor fijar la regla desde el principio.
5.5. Mantener los métodos export delgados y el cuerpo aparte
Los métodos marcados con UnmanagedCallersOnly no están pensados para invocarse tal cual desde código managed normal. Por eso, si se empieza a escribir ahí toda la lógica de negocio, también se vuelve difícil probarla.
También en el ejemplo, la gestión de las instancias se coloca en AccumulatorStore, y el NativeExports que se exporta se limita a ser solo un punto de entrada delgado. Esto es bastante importante.
- Método export: la ventanilla del ABI
- Clase interna: lógica de C# normal
Con esta división del trabajo, se puede pensar por separado el límite con C++ y el código principal de C#.
6. Casos en los que encaja bien
Esta configuración encaja de forma bastante natural en escenarios como los siguientes.
- Se quiere mantener la aplicación C/C++ existente y desplazar a C# solo una parte de la lógica de negocio
- No se quiere dar por supuesto, como requisito de distribución, la instalación previa del runtime de .NET
- Se puede mantener pequeña la superficie de funciones exportadas
- Existe la posibilidad de que, en el futuro, se quiera invocar la misma API de C también desde otros lenguajes, como Rust o Go
En particular, encaja muy bien con la configuración de dejar la aplicación nativa tal cual y escribir en C# solo la capa de lógica que resulta fácil de reemplazar. Es una división en la que la interfaz de usuario y el control de dispositivos siguen en C++, mientras que la decisión, el cálculo y las reglas de configuración quedan en C#.
7. Casos en los que, aun así, no encaja
Por supuesto, esto no es una solución universal. Hay escenarios en los que claramente no encaja.
- Se quiere tratar directamente clases de C++,
std::vectoro excepciones- En estos casos resulta más natural C++/CLI o un wrapper del lado nativo.
- Se quiere entrar en el mundo del registro COM, la automatización de VBA/Office o las extensiones de Explorer
- Aquí conviene pensarlo en el contexto de COM.
- Se quiere tender un puente entre 32 bits y 64 bits, o cruzar límites de proceso
- Es más razonable una configuración de COM/IPC/proceso separado que una DLL in-process.
- Se quiere descargar un plugin más adelante
- Es mejor no usar las bibliotecas compartidas de Native AOT bajo el supuesto de que se pueden descargar.
- La biblioteca de la que se depende se apoya fuertemente en reflection o en generación dinámica de código
- Si aparecen warnings en la publicación con AOT, es más seguro no ignorarlas a la ligera.
En definitiva, el punto de quiebre es si se puede asumir con claridad trabajar dentro del ABI de C, o no. Si no se puede asumir, es más limpio recurrir a otro tipo de puente.
8. Dónde suele atascarse la gente
Por último, resumimos los puntos en los que suele atascarse la gente, de forma discreta, con la exportación de Native AOT.
- El método marcado con
UnmanagedCallersOnlydebe serstatic. - No se puede colocar dentro de un método generic ni de una clase generic.
- Si se quiere un named export, hay que agregar
EntryPoint. - Es mejor no usar
ref/in/out, sino devolver mediante argumentos de puntero. - Lo que se exporta es el método del lado del ensamblado objeto de la publicación. Si se pone el atributo en un método de una biblioteca referenciada, tal cual no aparece en la tabla de exportación.
- Hay que igualar la arquitectura (bitness) entre el lado que llama y la DLL.
- Las warnings de la publicación son bastante importantes. Si aparecen warnings de AOT o de trimming, es más seguro resolverlas primero.
Todos estos son puntos del tipo «una vez que se sabe, tiene sentido». Pero si se tropieza con ellos sin saberlo, se pasa un buen rato bastante amargo.
9. Resumen
Cuando se quiere invocar C# desde C/C++, lo primero que suele venir a la mente es COM, C++/CLI o un proceso separado. Todas son opciones correctas.
Sin embargo, si lo que se quiere es insertar el procesamiento de C# como una DLL nativa in-process, Native AOT + UnmanagedCallersOnly es una opción bastante interesante.
Repasemos los puntos clave una vez más.
- No exponer C# tal cual, sino aplanarlo (flatten) al ABI de C
- Dejar explícita la gestión del ciclo de vida basada en handles
- Cruzar el límite con códigos de error, en lugar de excepciones
- Fijar la convención de llamada
- Mantener los métodos export delgados y separados de la lógica interna
Lo que se hace no es nada vistoso. Pero decisiones como «dónde trazar el límite» influyen bastante en la mantenibilidad a futuro. Cuando se quiere aprovechar los activos nativos existentes y, al mismo tiempo, aportar la productividad de C# solo en la capa de lógica, vale la pena tener presente esta configuración.
10. Referencias
- Conjunto completo de código de ejemplo de este artículo (biblioteca de C#, ejemplo de invocación en C++, pruebas unitarias) - komurasoft-blog-samples (GitHub)
- Native code interop with Native AOT - Microsoft Learn
- Building native libraries - Microsoft Learn
- Native AOT deployment - Microsoft Learn
- UnmanagedCallersOnlyAttribute Class - Microsoft Learn
- UnmanagedCallersOnlyAttribute.CallConvs Field - Microsoft Learn
- C# compiler breaking changes: ref / ref readonly / in / out are not allowed on methods attributed with UnmanagedCallersOnly
- Building Native Libraries with NativeAOT - dotnet/samples
- DUMPBIN /EXPORTS - Microsoft Learn
- LIB Reference - Microsoft Learn
- Cómo llamar a una DLL nativa desde C#: wrapper de C++/CLI vs. P/Invoke - KomuraSoft Blog
- Ejemplo real de un puente COM para llamar a una DLL de 64 bits desde una aplicación de 32 bits - KomuraSoft Blog
Artículos relacionados
Artículos recientes con las mismas etiquetas para profundizar en temas cercanos.
Usar WMI/CIM desde C# y PowerShell ── Guía práctica de obtención de información de hardware, monitorización de procesos y consultas remotas
WMI/CIM es la solución estándar para leer el número de serie, monitorizar el disco y detectar procesos. Cmdlets CIM, migración desde Get-...
Diseño de códigos en sistemas empresariales ── Cómo definir códigos de producto y cliente, y el dígito de control
Guía práctica para diseñar códigos de producto y cliente en sistemas empresariales: código significativo frente a secuencial, fórmulas de...
Compatibilidad retroactiva de interfaces DLL y COM — Tabla de decisión sobre qué cambios rompen al lado que llama
Qué cambios de DLL o COM rompen al lado que llama: los tres niveles de compatibilidad, la tabla de decisión por cambio y la regla de inmu...
Cómo versionar el esquema de la base de datos de una aplicación empresarial — Migraciones que evitan que «cada cliente tenga una base de datos distinta»
Guía práctica para versionar el esquema de bases de datos de aplicaciones empresariales dispersas entre clientes: PRAGMA user_version, mi...
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...
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.
Interoperabilidad de 32 / 64 bits
Compatibilidad de 32/64 bits, límites nativos y decisiones de diseño en Windows.
Servicios relacionados con este tema
El artículo está directamente relacionado con los siguientes servicios.
Desarrollo de aplicaciones para Windows
Al tratar la implementación del límite entre C# y C/C++, este tema se vincula directamente con el asesoramiento de diseño e implementación de Desarrollo de aplicaciones Windows.
Reutilización y migración de activos existentes
En cuanto a cómo tender un puente entre los activos nativos existentes y .NET, también encaja bien con Aprovechamiento y migración de activos existentes.
Preguntas frecuentes
Preguntas habituales en las consultas sobre el tema del artículo.
- ¿Se puede invocar código de C# desde C++?
- Sí, es posible. Con Native AOT de .NET, una biblioteca de clases de C# se puede publicar como biblioteca compartida nativa, y los métodos marcados con UnmanagedCallersOnly pueden exponerse como puntos de entrada de C. Es decir, C# puede usarse como la «DLL nativa a la que se llama» desde C/C++, en el mismo proceso (in-process).
- ¿Para qué escenarios es adecuada esta configuración?
- Para los casos en los que se quiere mantener la aplicación nativa tal cual, pero desplazar a C# solo partes como la lógica de decisión, el procesamiento de cadenas, la interpretación de configuración o las reglas de cálculo. Su característica distintiva es que «el lado nativo es el protagonista y C# se invoca como una pieza». En cambio, si se quiere llamar a funciones de C desde C#, P/Invoke es más adecuado; si se quiere tratar de forma natural tipos y propiedad de C++, C++/CLI es mejor; y si hay que cruzar 32 bits/64 bits o límites de proceso, COM/IPC es la opción indicada.
- ¿Qué hay que tener en cuenta al diseñar la API?
- Lo que se exporta es, en definitiva, un punto de entrada de función de C, por lo que no se debe exponer directamente en el límite tipos como string, List<T> o excepciones. Hay que reducirlo a una API de C plana del estilo create / destroy / operate, dejar explícita la gestión del ciclo de vida y los códigos de error, tratar las cadenas como puntero + longitud + capacidad del búfer, no dejar que las excepciones crucen el límite, y fijar la convención de llamada. El punto clave es diseñar la superficie del límite como un ABI de C, no como .NET.
- ¿Hay código de ejemplo funcional disponible?
- Sí. En el repositorio komurasoft-blog-samples de GitHub se publica un conjunto completo de ejemplos que se pueden compilar y ejecutar, incluida la biblioteca de C# publicada con Native AOT, ejemplos de invocación en C++ y pruebas unitarias. Los ejemplos de código dan por supuesto una DLL de Windows, pero el enfoque es prácticamente el mismo en Linux/macOS.
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.