Cómo invocar una DLL nativa de C# Native AOT desde C/C++

· Actualizado el: · · 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

  1. Conclusión, en una frase
  2. Cuándo usar cada opción, de un vistazo
  3. Diagrama de arquitectura
  4. 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
  5. 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
  6. Casos en los que encaja bien
  7. Casos en los que, aun así, no encaja
  8. Dónde suele atascarse la gente
  9. Resumen
  10. Referencias

1. Conclusión, en una frase

  • Si se quiere invocar procesamiento de C# desde C/C++ en el mismo proceso, Native AOT + UnmanagedCallersOnly es 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 string o List<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

Arquitectura de la llamada desde C/C++ hasta la lógica de C# a través de una DLL publicada con Native AOTDiagrama 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 estadollamada de función cdeclAplicación C / C++DLL de C# publicada con Native AOTexport con UnmanagedCallersOnlyLógica de negocio de C#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 Increment devuelve el valor después de sumar, por lo que el primer handle es 1. Por eso el lado de C++ puede usar intptr_t handle = 0; como marca de «todavía no se tiene».
  • En 32 bits se produce un truncamiento. nint tiene 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 de long a nint se 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:

  1. Si el nombre aparece en dumpbin /exports (si no aparece, es un problema del lado de C#)
  2. Si la cadena escrita en EntryPoint coincide exactamente con la cadena pasada a GetProcAddress (también se distingue entre mayúsculas y minúsculas)
  3. Si la arquitectura (bitness) del EXE que llama y de la DLL coinciden
  4. Si el método marcado con UnmanagedCallersOnly es static y no está dentro de un generic
  5. 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:

  1. Si la salida de la publicación incluye una biblioteca de importación (.lib), se enlaza directamente
  2. Si no aparece, se prepara un archivo .def con los nombres exportados y se genera la biblioteca de importación con lib.exe /def:NativeAotSample.def /out:NativeAotSample.lib /machine:x64
  3. 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:

  • string
  • object
  • List<T>
  • Task
  • Span<T>
  • Clases de C++, o std::vector y std::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::vector o 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 UnmanagedCallersOnly debe ser static.
  • 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

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.

¿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.

Volver al blog