Chiamando un C# Native AOT DLL da C / C++

· Aggiornato il: · · C#, .NET, Native AOT, C++, Sviluppo Windows, Interop nativo

Nel post precedente, Perché un wrapper C++/CLI è una scelta efficace per l’utilizzo di DLLs nativo da C#, abbiamo esaminato il limite durante la chiamata C++ da C#. Questa volta invertiamo la direzione: chiamando C# da C / C++.

A volte vuoi chiamare la logica scritta in C# da un’applicazione C / C++ esistente, ma P/Invoke va nella direzione sbagliata e introdurre C++/CLI o COM sembra eccessivo. Ciò si verifica soprattutto quando si desidera mantenere intatta l’applicazione nativa stessa e spostare in C# solo elementi come la logica decisionale, l’elaborazione delle stringhe, l’interpretazione della configurazione o le regole di calcolo.

COM può colmare anche questo, ma qui adottiamo un approccio più in-process, più simile a DLL. Con .NET Native AOT è possibile pubblicare una libreria di classi come libreria condivisa nativa ed esporre metodi contrassegnati con UnmanagedCallersOnly come punti di ingresso C. In altre parole, puoi utilizzare C# come “il DLL nativo chiamato”.

Detto questo, non tutto può semplicemente oltrepassare il confine così com’è. Perdite string, List<T>, eccezioni o proprietà oltre il confine e l’umore si inasprisce rapidamente. In questo articolo, utilizzando un esempio minimo di Windows + C++, esamineremo quando questa configurazione funziona davvero e quali forme API reggono bene. Il ragionamento è quasi lo stesso su Linux / macOS, ma gli esempi di codice presuppongono un Windows DLL.

Tutto il codice in questo articolo è pubblicato in GitHub come set di esempi compilabile ed eseguibile (la libreria C# pubblicata con Native AOT, un esempio di chiamante C++ e unit test).

csharp-native-aot-native-dll-from-c-cpp - komurasoft-blog-samples (GitHub)

Sommario

  1. Prima la conclusione (in una riga)
  2. Scegliere il ponte giusto
  3. Diagramma dell’architettura
  4. Configurazione minima
    • 4.1. Il progetto C#
    • 4.2. Il codice C# esportato
    • 4.3. Il comando Pubblica
    • 4.4. Chiamandolo da C++
    • 4.5. Verificare cosa è esportato
    • 4.6. Collegamento statico con un’import library
  5. Forme API che non si rompono
    • 5.1. Protendersi verso il C ABI
    • 5.2. Gestisci le stringhe come puntatore + lunghezza + capacità buffer
    • 5.3. Non lasciare mai che le eccezioni oltrepassino il confine
    • 5.4. Stabilire la convenzione di chiamata
    • 5.5. Mantieni i metodi di esportazione sottili e inserisci la logica altrove
  6. Casi in cui si adatta
  7. Casi in cui ancora non si adatta
  8. Insidie
  9. Riepilogo
  10. Riferimenti

1. Prima la conclusione (in una riga)

  • Se vuoi chiamare la logica C# da C / C++ in-process, Native AOT + UnmanagedCallersOnly è un’opzione molto efficace.
  • Tuttavia, ciò che viene esportato è strettamente un punto di ingresso della funzione C. Questo non è un mondo in cui esponi direttamente string o List<T>.
  • In pratica, appiattire le cose in un C API come create / destroy / operate, con gestione esplicita della durata e codici di errore, è molto più stabile.
  • Se vuoi lavorare con le classi C++ e STL naturalmente, C++/CLI è la soluzione migliore; se hai bisogno di chiamate di registrazione, automazione o tra processi, COM è la scelta migliore.

In breve: puoi utilizzare C# come interno di un DLL nativo, ma il confine deve essere progettato come C ABI, non come .NET. Se riesci ad accettare questo scambio, questo diventa uno strumento davvero interessante.

2. Scegliere il ponte giusto

Cosa vuoi fare Candidato forte Perché
Chiama un insieme di funzioni C da C# P/Invoke La direzione è semplice e la più naturale
Lavora con una libreria C++ naturalmente da C# C++/CLI C++ tipi, proprietà, eccezioni, std::wstring e simili sono facili da assorbire dal lato C++
Attraversare 32-bit / 64-bit o confini del processo COM / IPC Un DLL in-process da solo non può attraversarli
Richiama la logica C# da C / C++ come DLL nativo Native AOT + UnmanagedCallersOnly Puoi esportare i tuoi punti di ingresso C

Questa configurazione brilla quando il lato nativo è il protagonista e C# viene chiamato come componente. Questa direzione è esattamente l’opposto di P/Invoke e C++/CLI.

3. Diagramma dell’architettura

cdecl function callsApplicazione C / C++C# DLL pubblicato con Native AOTEsportazioni contrassegnate con UnmanagedCallersOnlyC# logica aziendaleGestire la gestione della tabella / stato

L’immagine è semplice. Ciò che conta è allineare il confine con le funzioni C. Gli elementi interni C# possono essere classi, raccolte o LINQ, non importa, ma la superficie che esponi all’esterno rimane piatta.

4. Configurazione minima

Qui costruiamo un esempio minimo in cui il lato C++ crea un “accumulatore”, vi aggiunge valori e infine recupera il totale. Nel lavoro reale potrebbe essere un motore decisionale, un interprete di configurazione o un semplice parser. Consideralo come il modello in cui il lato nativo contiene un handle e chiama le funzioni operative in sequenza.

4.1. Il progetto C#

Innanzitutto, configura una libreria di classi.

<!-- 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>

Ci sono due punti chiave.

  • Abilita la pubblicazione Native AOT
  • Consenti unsafe, poiché utilizziamo argomenti puntatore

Gli esempi in questo articolo hanno come target net8.0, ma lo stesso ragionamento si applica a .NET 9 / 10.

4.2. Il codice C# esportato

I metodi contrassegnati con UnmanagedCallersOnly diventano i punti di ingresso visibili dal lato nativo. Qui distribuiamo gli handle come numeri interi e gestiamo lo stato interno in un dizionario sul lato 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);
    }
}

Quello che fa è abbastanza chiaro.

  • L’unica cosa mostrata al lato nativo è un handle intptr_t
  • Lo stato attuale abita sul lato C#
  • crea / aggiungi / ottieni / distruggi sono suddivisi in funzioni semplici
  • I valori restituiti sono codici di errore; i valori di output ritornano tramite argomenti del puntatore

Con questa forma, puoi sostituire le parti interne C# in un secondo momento e il lato C ABI rimane notevolmente stabile.

Una nota sull’assegnazione degli handle. Nel campione usiamo un contatore long e il risultato di Interlocked.Increment viene castato a nint. Ci sono due proprietà da tenere presenti.

  • 0 non viene emesso. Il contatore parte da 0 e Increment restituisce il valore dopo l’incremento, quindi il primo handle è 1. Per questo il lato C++ può usare intptr_t handle = 0; come segno “non ancora posseduto”.
  • Su 32 bit avviene un troncamento. nint è largo quanto il puntatore: su 64 bit è a 64 bit, su 32 bit è a 32 bit. Il cast da long a nint scarta silenziosamente i bit superiori, quindi quando il conteggio supera 2^32 i valori tornano a zero. In un uso che crea e distrugge handle per 24 ore consecutive, teoricamente si può arrivare a questo punto.

Cosa succede quando avviene il wrap-around? Bisogna essere precisi. L’eccezione per chiave duplicata di s_instances.Add(handle, instance) aiuta solo quando lo stesso valore di handle è “ancora vivo”. L’uso normale di questa API è una sequenza di create e destroy, e i handle distrutti vengono rimossi dal dizionario. Quindi, quando il contatore torna a uno stesso valore, la chiave non c’è più e Add ha successo. Il risultato è che un vecchio handle tenuto dal lato C finisce per puntare a una nuova istanza senza alcuna relazione. Non viene sollevata eccezione né restituito codice di errore: i valori si rompono silenziosamente.

Un altro punto: il valore esatto 2^32 ha tutti i 32 bit inferiori a 0, quindi lo 0 che il lato C usava come “non ancora posseduto” verrebbe emesso come handle.

Quindi non fare affidamento sul controllo della chiave duplicata come dispositivo di sicurezza. Se devi supportare il 32 bit, usa una delle due strategie:

  • Incorpora un numero di generazione nell’handle. Usa i bit inferiori per il contatore e quelli superiori per la generazione; ogni destroy fa avanzare la generazione. Lo stesso contatore torna, ma il valore composto sarà diverso.
  • Quando il contatore è esaurito, fallisci in modo permanente. Quando la numerazione raggiunge il limite, i create successivi restituiscono errore. Nei dispositivi che devono girare a lungo questo richiederà un riavvio, ma è meglio che rompersi silenziosamente.

In entrambi i casi, mantieni il contatore in nint (non in long) così da non superare la larghezza dell’handle, e assicurati che 0 non venga mai emesso.

4.3. Il comando Pubblica

Pubblicarlo come libreria condivisa.

dotnet publish -r win-x64 -c Release /p:NativeLib=Shared

Questo produce un DLL nativo sotto bin/Release/net8.0/win-x64/publish/. Per Windows è un .dll, per Linux un .so e per macOS un .dylib.

L’importante è pubblicare per RID. Un binario creato per win-x64 non può essere utilizzato come se fosse win-arm64 e il numero di bit del chiamante e di DLL deve corrispondere.

4.4. Chiamandolo da C++

Per ora mettiamo da parte le librerie di importazione e le chiamiamo direttamente con LoadLibrary / GetProcAddress. Questo modulo semplifica la visualizzazione di cosa viene esportato e con quali firme dovresti riceverlo.

/* 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;

    // Do not use a Native AOT shared library with unloading in mind.
    // FreeLibrary(module);

    return EXIT_SUCCESS;
}

In questo esempio, tutto ciò che vede il lato C++ è “un C API richiamabile tramite puntatori a funzione”. Il fatto che l’interno sia scritto in C# non ha quasi bisogno di essere registrato.

4.5. Verificare cosa è esportato

Quando “non viene chiamato”, controllare innanzitutto se i nomi compaiono effettivamente nel DLL. Il modo più rapido è utilizzare dumpbin dal Prompt dei comandi per gli sviluppatori di Visual Studio.

dumpbin /exports NativeAotSample.dll

Se i quattro nomi km_accumulator_create / km_accumulator_add / km_accumulator_get_total / km_accumulator_destroy sono elencati, la pubblicazione lato C# è riuscita. Per filtrare per nome:

dumpbin /exports NativeAotSample.dll | findstr km_

Se i nomi non compaiono qui, il problema è lato C#; se compaiono ma GetProcAddress fallisce, il problema è lato chiamante.

Quando GetProcAddress restituisce NULL, controllare più o meno in questo ordine.

  1. dumpbin /exports elenca il nome? (se non lo elenca, è un problema lato C#)
  2. La stringa scritta in EntryPoint e quella passata a GetProcAddress corrispondono esattamente? (la distinzione tra maiuscole e minuscole è significativa)
  3. La bittness dell’EXE chiamante e del DLL corrispondono
  4. Il metodo con UnmanagedCallersOnly è static e non si trova all’interno di un tipo generico
  5. Quell’attributo è scritto nell’assembly oggetto della pubblicazione (anche se scritto in un assembly di riferimento, non verrà mostrato)

Inoltre, se LoadLibraryW stessa fallisce, non si tratta di un problema di export. Prima di tutto sospettare il percorso del DLL, la bittness o l’assenza di DLL dipendenti.

4.6. Collegamento statico con un’import library

Finora abbiamo scritto in modalità LoadLibrary / GetProcAddress. Lo abbiamo fatto perché è facile vedere cosa viene esportato e con quale firma riceverlo.

D’altra parte, in pratica spesso si desidera “includere un header e chiamare semplicemente le funzioni”. In tal caso si tratta di un caricamento statico con import library. La procedura è la seguente.

  1. Se l’output della pubblicazione include un’import library (.lib), collegala
  2. Se non è presente, prepara un file .def con l’elenco dei nomi di export e crea l’import library con lib.exe /def:NativeAotSample.def /out:NativeAotSample.lib /machine:x64
  3. Lato header, usa dichiarazioni di funzione normali anziché tipi puntatore a funzione
/* 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 questa forma, il codice chiamante diventa molto più semplice. Tuttavia, se il DLL non viene trovato, il processo fallisce all’avvio, quindi diventa difficile gestire un’operazione del tipo “il corpo principale funziona, ma questa funzione non è disponibile”. Se vuoi inserirlo come plugin, resta con la modalità LoadLibrary.

Nota: la pubblicazione come libreria statica (NativeLib=Static) non è ufficialmente supportata, quindi non fare affidamento su quella.

5. Forme API che non si rompono

Essere in grado di esportare con Native AOT è divertente, ma in pratica ciò che scegli di non esportare conta di più.

5.1. Protendersi verso il C ABI

È più tranquillo limitare fin dall’inizio i tipi che esponi al confine a quelli più o meno indicati.

  • Tipi primitivi come int32_t / int64_t / double
  • Strutture con layout fisso
  • Gestisce equivalenti a intptr_t / void*
  • uint8_t* più una lunghezza

Al contrario, queste sono le cose che non dovresti mai lasciar trapelare in primo luogo.

  • string
  • object
  • List<T>
  • Task
  • Span<T>
  • classi C++, std::vector, std::wstring

Prova a spingerli oltre il confine così come sono e la superficie del confine si appanna rapidamente. La chiave è impedire che le preoccupazioni interne di C# trapelino nel C++, e non lasciare che troppe preoccupazioni di C++ trapelino nemmeno nel C#.

5.2. Gestisci le stringhe come puntatore + lunghezza + capacità buffer

Nel momento in cui vuoi passare dei fili in giro, la tentazione è di esporre direttamente string: resisti. Al confine di una libreria, qualcosa di simile alla forma seguente è molto più chiaro.

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);

Il punto è decidere in anticipo la codifica, la lunghezza e chi alloca il buffer. Dato che si tratta di Windows, propendere per UTF-16 è un’opzione, ma se hai in vista altre lingue, UTF-8 di solito è più facile da lavorare.

5.3. Non lasciare mai che le eccezioni oltrepassino il confine

Un confine di funzione nativa non è un mezzo amichevole per esprimere eccezioni. Per lo meno, è più sicuro non progettare le cose in modo che le eccezioni gestite arrivino direttamente al chiamante.

In pratica:

  • Il valore restituito è un codice di stato
  • I dati effettivi ritornano attraverso i buffer o gli argomenti del puntatore
  • Se necessario, esponi informazioni aggiuntive tramite una funzione in stile get_last_error

Ciò mantiene le cose gestibili.

Non è appariscente, ma questo tipo di design poco affascinante ripaga in seguito. In altre parole, non iniziare improvvisamente un incontro di wrestling al confine.

5.4. Stabilire la convenzione di chiamata

L’esempio specifica in modo esplicito CallConvCdecl. Se lo ometti, ottieni la convenzione di chiamata predefinita della piattaforma, ma se vuoi definire intestazioni e tipi di puntatori a funzione, dichiararlo esplicitamente tu stesso è meno soggetto a incidenti.

Soprattutto se c’è qualche possibilità di affrontare x86, lasciare questo ambiguo farà male in seguito. Anche se emerge raramente su x64, imposta la regola all’inizio.

5.5. Mantieni i metodi di esportazione sottili e inserisci la logica altrove

I metodi contrassegnati con UnmanagedCallersOnly non devono essere chiamati direttamente dal codice gestito ordinario. Quindi, se inizi a scrivere tutta la logica del tuo business al loro interno, anche i test diventano dolorosi.

Anche nel campione l’attuale direzione statale risiede nel AccumulatorStore, mentre il NativeExports esportato non è altro che un’entrata sottile. Questo conta moltissimo.

  • Metodi di esportazione: la reception ABI
  • Classi interne: logica C# ordinaria

Con questa divisione del lavoro, puoi pensare al confine con C++ e al codice principale C# separatamente.

6. Casi in cui si adatta

Questa configurazione si adatta perfettamente a scenari come questi.

  • Si desidera mantenere l’applicazione C / C++ esistente così com’è e spostare solo parte della logica aziendale in C#
  • Non si desidera che la preinstallazione del runtime .NET sia un prerequisito di distribuzione
  • È possibile mantenere piccola la superficie funzionale esportata
  • Eventualmente potresti voler chiamare lo stesso C API da altri linguaggi come Rust o Go

Si abbina particolarmente bene con la struttura di mantenere intatta l’app nativa e scrivere solo il livello logico facilmente scambiabile in C#: l’UI e il controllo del dispositivo rimangono in C++; le decisioni, i calcoli e le regole di configurazione vanno in C#.

7. Casi in cui continua a non adattarsi

Naturalmente, questa non è una panacea. Ci sono casi evidenti in cui non si adatta.

  • Vuoi gestire direttamente le classi C++, std::vector o le eccezioni
    • In tal caso C++/CLI o un wrapper sul lato nativo è più naturale.
  • Vuoi entrare nel mondo della registrazione COM, VBA / Office automation o estensioni Explorer
    • Pensa invece a questo in termini di COM.
  • Vuoi superare 32-bit / 64-bit o superare il confine di un processo
    • La soluzione migliore non è un progetto in corso DLL — COM / IPC / un progetto a processo separato.
  • Vuoi scaricare i plugin più tardi
    • Le librerie condivise Native AOT non devono essere utilizzate tenendo presente lo scaricamento.
  • Le tue dipendenze fanno molto affidamento sulla riflessione o sulla generazione di codice dinamico
    • Se vengono visualizzati avvisi di pubblicazione AOT, è più sicuro non ignorarli.

Alla fine, la linea di demarcazione è se puoi vivere in un C ABI. Se non puoi, un bridge diverso è più pulito.

8. Insidie

Infine, ecco gli ostacoli più facilmente riscontrabili con le esportazioni Native AOT.

  • I metodi contrassegnati con UnmanagedCallersOnly devono essere static.
  • Non possono essere metodi generici o vivere all’interno di classi generiche.
  • Se desideri un’esportazione con nome, aggiungi EntryPoint.
  • Evita ref / in / out; restituiscono invece valori tramite argomenti del puntatore.
  • Vengono esportati solo i metodi nell’assembly pubblicato. Mettere l’attributo sui metodi in una libreria di riferimento non li fa emergere da solo.
  • Il bit del chiamante e del DLL devono corrispondere.
  • Pubblicare avvisi è molto importante. Se vengono visualizzati gli avvisi AOT / ritaglio, cancellarli prima.

Ognuno di questi è un “beh, ovviamente” una volta che lo sai. Ma colpiscine uno senza saperlo e ti attendono ore tristi.

Quando vuoi chiamare C# da C / C++, le prime cose che ti vengono in mente sono COM, C++/CLI o un processo separato. Tutte queste sono opzioni valide.Ma se vuoi inserire la logica C# come DLL nativo in-process, Native AOT + UnmanagedCallersOnly è una scelta davvero interessante.

Elenchiamo ancora una volta i punti chiave.

  • Non esporre C# così com’è: appiattiscilo in un C ABI
  • Rendi esplicita la gestione della durata con una progettazione basata su handle
  • Oltrepassa i limiti con i codici di errore, non con le eccezioni
  • Stabilire la convenzione di chiamata
  • Mantenere i metodi di esportazione sottili e separati dalla logica interna

Niente di tutto questo è appariscente. Ma il modo in cui tagli il confine ha un impatto reale sulla manutenibilità successiva. Quando vuoi mantenere in vita le risorse native portando la produttività di C# solo al livello logico, vale la pena ricordare questa configurazione.

10. Riferimenti

Articoli recenti con gli stessi tag per approfondire argomenti vicini.

Queste pagine collocano l’argomento in un contesto più ampio di servizi e decisioni.

L’articolo è direttamente collegato ai servizi seguenti.

Domande frequenti

Domande che ricorrono nelle consulenze sull’argomento dell’articolo.

Posso chiamare il codice C# da un'applicazione C o C++?
SÌ. Con .NET Native AOT è possibile pubblicare una libreria di classi C# come libreria condivisa nativa e i metodi contrassegnati con UnmanagedCallersOnly diventano punti di ingresso della funzione C richiamabili da C / C++. Si pubblica con dotnet publish e la proprietà NativeLib=Shared, che produce un file .dll su Windows, un file .so su Linux o un file .dylib su macOS. Il chiamante vede un C API ordinario raggiungibile tramite LoadLibrary e GetProcAddress; il fatto che l'implementazione sia C# ha poca importanza per il lato nativo.
Quali tipi possono attraversare in sicurezza il confine tra C# e C / C++?
Limitare il limite a C ABI: tipi primitivi come int32_t, int64_t e double, strutture con layout fisso, handle equivalenti a intptr_t e puntatori a byte abbinati a una lunghezza. Non esporre mai tipi .NET come string, object, List<T>, Task o Span<T>, né classi C++, std::vector o std::wstring. Un modello stabile è un'API di creazione / utilizzo / distruzione piatta in cui il lato nativo contiene solo un handle di numero intero mentre lo stato reale risiede sul lato C#, con valori restituiti come codici di stato e output tramite argomenti del puntatore.
Come dovrebbero essere gestiti le stringhe e gli errori attraverso un limite di esportazione Native AOT?
Passa le stringhe come puntatore più una lunghezza più una capacità del buffer e decidi la codifica, la semantica della lunghezza e chi alloca il buffer in anticipo; UTF-8 è solitamente più semplice se sono visualizzate piattaforme non Windows. Non lasciare mai che le eccezioni gestite oltrepassino i limiti della funzione: restituiscono un codice di stato, forniscono dati tramite buffer o argomenti del puntatore e, se sono necessari maggiori dettagli, esponili tramite una funzione in stile get_last_error. Stabilire inoltre esplicitamente la convenzione di chiamata (ad esempio CallConvCdecl), soprattutto se x86 potrebbe essere coinvolto.
Quando Native AOT è il ponte sbagliato tra C# e il codice nativo?
Se vuoi lavorare direttamente con le classi C++, STL o con le eccezioni, C++/CLI è la soluzione più naturale. Se è necessaria la registrazione COM, VBA o Office Automation oppure per oltrepassare i limiti di 32 bit / 64 bit o di processo, COM o IPC è il percorso più efficace, poiché il chiamante e DLL devono corrispondere in bit. Anche le librerie condivise Native AOT non dovrebbero essere utilizzate pensando allo scaricamento e le dipendenze che fanno molto affidamento sulla riflessione o sulla generazione di codice dinamico non sono adatte: gli avvisi di pubblicazione AOT non dovrebbero essere ignorati.

Profilo dell’autore

Pagina di presentazione dell’autore dell’articolo.

Go Komura

Rappresentante di KomuraSoft LLC

Specializzato nello sviluppo di software Windows, nella consulenza tecnica e nell’analisi dei malfunzionamenti, soprattutto nei progetti con sistemi esistenti e guasti difficili da riprodurre.

Torna al blog