Condividi tramite


Esercitazione: Usare marshaller personalizzati in operazioni PInvoke generate dall'origine

In questa esercitazione si apprenderà come implementare un marshaller e usarlo per il marshalling personalizzato in operazioni PInvoke generate dall'origine.

Si implementeranno marshaller per un tipo predefinito, si personalizzerà il marshalling per un parametro specifico e un tipo definito dall'utente e si specificherà il marshalling predefinito per un tipo definito dall'utente.

Tutto il codice sorgente usato in questa esercitazione è disponibile nel repository dotnet/samples.

Panoramica del generatore di origini LibraryImport

Il tipo System.Runtime.InteropServices.LibraryImportAttribute è il punto di ingresso utente per un generatore di origini introdotto in .NET 7. Questo generatore di origini è progettato per generare tutto il codice di marshalling in fase di compilazione anziché in fase di esecuzione. I punti di ingresso sono stati specificati cronologicamente usando DllImport, ma questo approccio comporta costi che potrebbero non essere sempre accettabili. Per altre informazioni, vedere Generazione dell'origine per le operazioni platform invoke. Il generatore di origini LibraryImport può generare tutto il codice di marshalling e rimuovere il requisito di generazione in fase di esecuzione intrinseco in DllImport.

Per esprimere i dettagli necessari per generare il codice di marshalling sia per il runtime che per consentire agli utenti di personalizzare per i propri tipi, sono necessari diversi tipi. In questa esercitazione vengono usati i tipi seguenti:

  • MarshalUsingAttribute: attributo cercato dal generatore di origini nei siti di utilizzo e usato per determinare il tipo di marshalling per il marshalling della variabile con attributi.

  • CustomMarshallerAttribute: attributo usato per indicare un marshaller per un tipo e la modalità in cui devono essere eseguite le operazioni di marshalling, ad esempio per riferimento da codice gestito a codice non gestito.

  • NativeMarshallingAttribute: attributo usato per indicare il marshaller da usare per il tipo con attributi. Questo attributo è utile per gli autori di librerie che forniscono tipi e marshaller di accompagnamento per tali tipi.

Questi attributi, tuttavia, non sono gli unici meccanismi disponibili per un autore di marshaller personalizzati. Il generatore di origini esamina il marshaller stesso per cercare altre indicazioni sulla modalità di esecuzione del marshalling.

I dettagli completi sulla progettazione sono disponibili nel repository dotnet/runtime.

Analizzatore e correzione del generatore di origini

Con il generatore di origini vengono forniti un analizzatore e un'utilità di correzione. L'analizzatore e l'utilità di correzione sono abilitati e disponibili per impostazione predefinita a partire da .NET 7 RC1. L'analizzatore è progettato per aiutare gli sviluppatori a usare correttamente il generatore di origini. L'utilità di correzione fornisce conversioni automatiche di numerosi schemi di DllImport nella firma di LibraryImport appropriata.

Introduzione alla libreria nativa

L'uso del generatore di origini LibraryImport implica l'utilizzo di una libreria nativa o non gestita. Una libreria nativa può essere una libreria condivisa (ovvero, .dll, .so o dylib) che chiama direttamente un'API del sistema operativo non esposta tramite .NET. Potrebbe anche trattarsi di una libreria estremamente ottimizzata in un linguaggio non gestito che uno sviluppatore .NET vuole utilizzare. Per questa esercitazione si creerà una libreria condivisa personalizzata che espone una superficie API di tipo C. Il codice seguente rappresenta un tipo definito dall'utente e due API che verranno utilizzate da C#. Queste due API rappresentano la modalità "in", ma sono disponibili modalità aggiuntive da esplorare nell'esempio.

struct error_data
{
    int code;
    bool is_fatal_error;
    char32_t* message;    /* UTF-32 encoded string */
};

extern "C" DLL_EXPORT void STDMETHODCALLTYPE PrintString(char32_t* chars);

extern "C" DLL_EXPORT void STDMETHODCALLTYPE PrintErrorData(error_data data);

Il codice precedente contiene i due tipi di interesse, char32_t* e error_data. char32_t* rappresenta una stringa con codifica UTF-32, che non è una codifica di tipo stringa di cui .NET esegue generalmente il marshalling. error_data è un tipo definito dall'utente che contiene un campo di interi a 32 bit, un campo booleano C++ e un campo stringa con codifica UTF-32. Con entrambi questi tipi il generatore di origini deve fornire una soluzione per generare il codice di marshalling.

Personalizzare il marshalling per un tipo predefinito

Considerare prima il tipo char32_t* perché il marshalling di questo tipo è richiesto dal tipo definito dall'utente. char32_t* rappresenta il lato nativo, ma è necessaria anche la rappresentazione nel codice gestito. In .NET è presente un solo tipo"string", ovvero string. Di conseguenza, si esegue il marshalling di una stringa con codifica UTF-32 nativa da e verso il tipo string nel codice gestito. Esistono già diversi marshaller predefiniti per il tipo string di cui viene eseguito il marshalling in formato UTF-8, UTF-16, ANSI e anche come tipo BSTR di Windows. Tuttavia, non ne esiste uno per il marshalling in formato UTF-32. È esattamente quello che è necessario definire.

Il tipo Utf32StringMarshaller è contrassegnato con un attributo CustomMarshaller, che descrive le operazioni eseguite per il generatore di origini. Il primo argomento tipo per l'attributo è il tipo string, il tipo gestito di cui effettuare il marshalling, il secondo è la modalità, che indica quando usare il marshaller e il terzo tipo è Utf32StringMarshaller, ovvero il tipo da usare per il marshalling. È possibile applicare CustomMarshaller più volte per specificare ulteriormente la modalità e il tipo di marshaller da usare per tale modalità.

Nell'esempio corrente viene illustrato un marshaller "senza stato" che accetta alcuni input e restituisce i dati nel modulo sottoposto a marshalling. Il metodo Free è presente per simmetria con il marshalling non gestito e il Garbage Collector rappresenta l'operazione "gratuita" per il marshaller gestito. L'implementatore è libero di eseguire le operazioni desiderate per effettuare il marshalling dell'input all'output, ma tenere presente che il generatore di origini non manterrà alcuno stato in modo esplicito.

namespace CustomMarshalling
{
    [CustomMarshaller(typeof(string), MarshalMode.Default, typeof(Utf32StringMarshaller))]
    internal static unsafe class Utf32StringMarshaller
    {
        public static uint* ConvertToUnmanaged(string? managed)
            => throw new NotImplementedException();

        public static string? ConvertToManaged(uint* unmanaged)
            => throw new NotImplementedException();

        public static void Free(uint* unmanaged)
            => throw new NotImplementedException();
    }
}

Le specifiche relative alla modalità in cui questo marshaller specifico esegue la conversione da string a char32_t* sono disponibili nell'esempio. Si noti che è possibile usare qualsiasi API .NET, ad esempio Encoding.UTF32.

Considerare un caso in cui lo stato è auspicabile. Osservare l'ulteriore oggetto CustomMarshaller e prendere nota della modalità più specifica, ovvero MarshalMode.ManagedToUnmanagedIn. Questo marshaller specializzato viene implementato "con stato" e può archiviare lo stato nella chiamata di interoperabilità. Una maggiore specializzazione e lo stato consentono ottimizzazioni e un marshalling su misura per una modalità. Ad esempio, è possibile indicare al generatore di origini di fornire un buffer allocato nello stack che potrebbe evitare un'allocazione esplicita durante il marshalling. Per indicare il supporto per un buffer allocato nello stack, il marshaller implementa una proprietà BufferSize e un metodo FromManaged che accetta un parametro Span di un tipo unmanaged. La proprietà BufferSize indica la quantità di spazio dello stack, ovvero la lunghezza di Span da passare a FromManaged, che il marshaller vuole ottenere durante la chiamata di marshalling.

namespace CustomMarshalling
{
    [CustomMarshaller(typeof(string), MarshalMode.Default, typeof(Utf32StringMarshaller))]
    [CustomMarshaller(typeof(string), MarshalMode.ManagedToUnmanagedIn, typeof(ManagedToUnmanagedIn))]
    internal static unsafe class Utf32StringMarshaller
    {
        //
        // Stateless functions removed
        //

        public ref struct ManagedToUnmanagedIn
        {
            public static int BufferSize => 0x100;

            private uint* _unmanagedValue;
            private bool _allocated; // Used stack alloc or allocated other memory

            public void FromManaged(string? managed, Span<byte> buffer)
                => throw new NotImplementedException();

            public uint* ToUnmanaged()
                => throw new NotImplementedException();

            public void Free()
                => throw new NotImplementedException();
        }
    }
}

È ora possibile chiamare la prima delle due funzioni native usando i marshaller in formato stringa UTF-32. La dichiarazione seguente usa l'attributo LibraryImport, proprio come DllImport, ma si basa sull'attributo MarshalUsing per indicare al generatore di origini quale marshaller usare quando si chiama la funzione nativa. Non è necessario chiarire se deve essere usato il marshaller senza stato o con stato. Questa operazione viene gestita dall'implementatore che definisce MarshalMode in base agli attributi CustomMarshaller del marshaller. Il generatore di origini selezionerà il marshaller più appropriato in base al contesto in cui viene applicato MarshalUsing, usando MarshalMode.Default come fallback.

// extern "C" DLL_EXPORT void STDMETHODCALLTYPE PrintString(char32_t* chars);
[LibraryImport(LibName)]
internal static partial void PrintString([MarshalUsing(typeof(Utf32StringMarshaller))] string s);

Personalizzare il marshalling per un tipo definito dall'utente

Il marshalling di un tipo definito dall'utente richiede la definizione non solo della logica di marshalling, ma anche del tipo in C# da cui e verso cui effettuare il marshalling. Ricordare il tipo nativo di cui si prova a effettuare il marshalling.

struct error_data
{
    int code;
    bool is_fatal_error;
    char32_t* message;    /* UTF-32 encoded string */
};

Definire ora l'aspetto ideale in C#. Un valore int presenta le stesse dimensioni sia in C++ moderno che in .NET. Un valore bool è l'esempio canonico per un valore booleano in .NET. Basandosi su Utf32StringMarshaller, è possibile effettuare il marshalling di char32_t* come string .NET. Tenendo conto dello stile di .NET, il risultato è la definizione seguente in C#:

struct ErrorData
{
    public int Code;
    public bool IsFatalError;
    public string? Message;
}

Seguendo il modello di denominazione, assegnare al marshaller il nome ErrorDataMarshaller. Invece di specificare un marshaller per MarshalMode.Default, si definiranno i marshaller solo per alcune modalità. In questo caso, se il marshaller viene usato per una modalità non fornita, il generatore di origini non funzionerà. Iniziare con la definizione di un marshaller per la direzione "in". Si tratta di un marshaller "senza stato" perché il marshaller stesso è costituito solo da funzioni static.

namespace CustomMarshalling
{
    [CustomMarshaller(typeof(ErrorData), MarshalMode.ManagedToUnmanagedIn, typeof(ErrorDataMarshaller))]
    internal static unsafe class ErrorDataMarshaller
    {
        // Unmanaged representation of ErrorData.
        // Should mimic the unmanaged error_data type at a binary level.
        internal struct ErrorDataUnmanaged
        {
            public int Code;        // .NET doesn't support less than 32-bit, so int is 32-bit.
            public byte IsFatal;    // The C++ bool is defined as a single byte.
            public uint* Message;   // This could be as simple as a void*, but uint* is closer.
        }

        public static ErrorDataUnmanaged ConvertToUnmanaged(ErrorData managed)
            => throw new NotImplementedException();

        public static void Free(ErrorDataUnmanaged unmanaged)
            => throw new NotImplementedException();
    }
}

ErrorDataUnmanaged simula la forma del tipo non gestito. La conversione da ErrorData a ErrorDataUnmanaged ora è semplice con Utf32StringMarshaller.

Il marshalling di un valore int non è necessario perché la relativa rappresentazione è identica sia nel codice non gestito che in quello gestito. La rappresentazione binaria di un valore bool non è definita in .NET, quindi usare il valore corrente per definire un valore zero e un valore diverso da zero nel tipo non gestito. Riutilizzare quindi il marshaller UTF-32 per convertire il campo string in un valore uint*.

public static ErrorDataUnmanaged ConvertToUnmanaged(ErrorData managed)
{
    return new ErrorDataUnmanaged
    {
        Code = managed.Code,
        IsFatal = (byte)(managed.IsFatalError ? 1 : 0),
        Message = Utf32StringMarshaller.ConvertToUnmanaged(managed.Message),
    };
}

Tenere presente che si sta definendo questo marshaller come "in", quindi è necessario pulire le allocazioni eseguite durante il marshalling. I campi int e bool non hanno allocato memoria, a differenza del campo Message. Riutilizzare Utf32StringMarshaller di nuovo per pulire la stringa sottoposta a marshalling.

public static void Free(ErrorDataUnmanaged unmanaged)
    => Utf32StringMarshaller.Free(unmanaged.Message);

Si consideri brevemente lo scenario "out". Si consideri il caso in cui vengono restituite una o più istanze di error_data.

extern "C" DLL_EXPORT error_data STDMETHODCALLTYPE GetFatalErrorIfNegative(int code)

extern "C" DLL_EXPORT error_data* STDMETHODCALLTYPE GetErrors(int* codes, int len)
[LibraryImport(LibName)]
internal static partial ErrorData GetFatalErrorIfNegative(int code);

[LibraryImport(LibName)]
[return: MarshalUsing(CountElementName = "len")]
internal static partial ErrorData[] GetErrors(int[] codes, int len);

Un'operazione PInvoke che restituisce un singolo tipo di istanza, non raccolta, viene classificata come MarshalMode.ManagedToUnmanagedOut. In genere, si usa una raccolta per restituire più elementi e in questo caso viene usato un elemento Array. Il marshaller per uno scenario di raccolta, corrispondente alla modalità MarshalMode.ElementOut, restituirà più elementi come descritto più avanti.

namespace CustomMarshalling
{
    [CustomMarshaller(typeof(ErrorData), MarshalMode.ManagedToUnmanagedIn, typeof(ErrorDataMarshaller))]
    [CustomMarshaller(typeof(ErrorData), MarshalMode.ElementOut, typeof(Out))]
    internal static unsafe class ErrorDataMarshaller
    {
        //
        // Other marshallers removed
        //

        public static class Out
        {
            public static ErrorData ConvertToManaged(ErrorDataUnmanaged unmanaged)
                => throw new NotImplementedException();

            public static ErrorDataUnmanaged ConvertToUnmanaged(ErrorData managed)
                => throw new NotImplementedException();

            public static void Free(ErrorDataUnmanaged unmanaged)
                => throw new NotImplementedException();
        }
    }
}

La conversione da ErrorDataUnmanaged a ErrorData è l'inverso dell'operazione eseguita per la modalità "in". Tenere presente che è anche necessario pulire le allocazioni che l'ambiente non gestito prevede che vengano eseguite. È anche importante notare che le funzioni qui sono contrassegnate come static e sono quindi "senza stato"; essere senza stato è un requisito per tutte le modalità "Element". Si noterà anche che è presente un metodo ConvertToUnmanaged come nella modalità "in". Tutte le modalità "Element" devono essere gestite per le modalità "in" e "out".

Per il marshaller "out" da codice gestito a codice non gestito, serve qualcosa di speciale. Il nome del tipo di dati di cui si effettua il marshalling viene chiamato error_data e .NET in genere esprime gli errori come eccezioni. Alcuni errori hanno un impatto maggiore rispetto ad altri e gli errori identificati come "irreversibili" indicano in genere un errore catastrofico o irreversibile. Si noti che error_data include un campo per verificare se l'errore è irreversibile. Si effettuerà il marshalling di error_data nel codice gestito e, se è irreversibile, si genererà un'eccezione invece di convertirlo in ErrorData e restituirlo.

namespace CustomMarshalling
{
    [CustomMarshaller(typeof(ErrorData), MarshalMode.ManagedToUnmanagedIn, typeof(ErrorDataMarshaller))]
    [CustomMarshaller(typeof(ErrorData), MarshalMode.ElementOut, typeof(Out))]
    [CustomMarshaller(typeof(ErrorData), MarshalMode.ManagedToUnmanagedOut, typeof(ThrowOnFatalErrorOut))]
    internal static unsafe class ErrorDataMarshaller
    {
        //
        // Other marshallers removed
        //

        public static class ThrowOnFatalErrorOut
        {
            public static ErrorData ConvertToManaged(ErrorDataUnmanaged unmanaged)
                => throw new NotImplementedException();

            public static void Free(ErrorDataUnmanaged unmanaged)
                => throw new NotImplementedException();
        }
    }
}

Un parametro "out" esegue la conversione da un contesto non gestito a un contesto gestito, quindi viene implementato il metodo ConvertToManaged. Quando il computer chiamato non gestito restituisce e fornisce un oggetto ErrorDataUnmanaged, è possibile esaminarlo usando il marshaller in modalità ElementOut e verificare se è contrassegnato come errore irreversibile. Se lo è, sarà necessario generare un'eccezione invece di restituire semplicemente ErrorData.

public static ErrorData ConvertToManaged(ErrorDataUnmanaged unmanaged)
{
    ErrorData data = Out.ConvertToManaged(unmanaged);
    if (data.IsFatalError)
        throw new ExternalException(data.Message, data.Code);

    return data;
}

Forse non si intende solo utilizzare la libreria nativa, ma anche condividere il proprio lavoro con la community e fornire una libreria di interoperabilità. È possibile fornire ErrorData con un marshaller implicito ogni volta che viene usato in un'operazione PInvoke aggiungendo [NativeMarshalling(typeof(ErrorDataMarshaller))] alla definizione di ErrorData. A questo punto, chiunque usi la definizione di questo tipo in una chiamata di LibraryImport potrà beneficiare dei marshaller e potrà comunque sempre eseguire l'override dei marshaller usando MarshalUsing nel sito di utilizzo.

[NativeMarshalling(typeof(ErrorDataMarshaller))]
struct ErrorData { ... }

Vedi anche