Tutorial: Verwenden von benutzerdefinierten Marshallern in quellgenerierten P/Invokes

In diesem Tutorial erfahren Sie, wie Sie einen Marshaller implementieren und ihn für benutzerdefiniertes Marshalling in quellgenerierten P/Invokes verwenden.

Sie implementieren Marshaller für einen integrierten Typ, passen das Marshalling für einen bestimmten Parameter und einen benutzerdefinierten Typ an und geben das Standardmarshalling für einen benutzerdefinierten Typ an.

Der gesamte Quellcode, der in diesem Tutorial verwendet wird, ist im dotnet/samples-Repository verfügbar.

Übersicht zum LibraryImport-Quellgenerator

Der Typ System.Runtime.InteropServices.LibraryImportAttribute ist der Benutzereinstiegspunkt für einen in .NET 7 eingeführten Quellgenerator. Dieser Quellgenerator ist so konzipiert, dass der gesamte Marshallcode zur Kompilierzeit statt zur Laufzeit generiert wird. Einstiegspunkte wurden in der Vergangenheit mit DllImport angegeben, aber dieser Ansatz bringt Kosten mit sich, die möglicherweise nicht immer akzeptabel sind. Weitere Informationen finden Sie unter P/Invoke-Quellgenerierung. Der LibraryImport-Quellgenerator kann den gesamten Marshallcode generieren und die bei DllImport intrinsische Anforderung zur Laufzeitgenerierung vermeiden.

Zum Ausdrücken der Details, die zum Generieren von Marshallcode zur Anpassung für eigene Typen sowohl für die Runtime als auch für Benutzer erforderlich sind, werden mehrere Typen benötigt. In diesem Tutorial werden durchgängig die folgenden Typen verwendet:

  • MarshalUsingAttribute: Attribut, das vom Quellgenerator an Einsatzorten gesucht und verwendet wird, um den Marshallertyp zum Marshallen der attribuierten Variable zu bestimmen.

  • CustomMarshallerAttribute: Attribut, das verwendet wird, um einen Marshaller für einen Typ und den Modus anzugeben, in dem die Marshallvorgänge ausgeführt werden sollen (z. B. durch Verweis von verwaltet in nicht verwaltet).

  • NativeMarshallingAttribute: Attribut, das verwendet wird, um anzugeben, welcher Marshaller für den attribuierten Typ verwendet werden soll. Dies ist für Bibliotheksautoren nützlich, die Typen und begleitende Marshaller für diese Typen bereitstellen.

Diese Attribute sind jedoch nicht die einzigen Mechanismen, die einem Autor von benutzerdefinierten Marshallern zur Verfügung stehen. Der Quellgenerator prüft den Marshaller selbst auf verschiedene andere Hinweise, die darüber informieren, wie das Marshallen erfolgen soll.

Vollständige Details zum Entwurf finden Sie im dotnet/runtime-Repository.

Analyse und Korrekturregel für den Quellgenerator

Zusammen mit dem eigentlichen Quellgenerator werden ein Analysetool und eine Korrekturregel zur Verfügung gestellt. Das Analysetool und die Korrekturregel sind seit .NET 7 RC1 standardmäßig aktiviert und verfügbar. Das Analysetool soll Entwicklern helfen, den Quellgenerator ordnungsgemäß zu verwenden. Die Korrekturregel bietet automatisierte Konvertierungen aus vielen DllImport-Mustern in die entsprechende LibraryImport-Signatur.

Einführung der nativen Bibliothek

Die Verwendung des LibraryImport-Quellgenerators bedeutet, eine native oder nicht verwaltete Bibliothek zu nutzen. Bei einer nativen Bibliothek kann es sich um eine freigegebene Bibliothek handeln (d. h. .dll, .so oder dylib), die direkt eine Betriebssystem-API aufruft, die nicht über .NET verfügbar gemacht wird. Die Bibliothek kann aber auch eine Bibliothek sein, die stark in einer nicht verwalteten Sprache optimiert ist und die ein .NET-Entwickler nutzen möchte. In diesem Tutorial erstellen Sie Ihre eigene freigegebene Bibliothek, die eine API-Oberfläche im C-Stil verfügbar macht. Der folgende Code stellt einen benutzerdefinierten Typ und zwei APIs dar, die Sie von C# nutzen. Diese beiden APIs stellen den „Ein“-Modus dar, aber es gibt zusätzliche Modi, die im Beispiel untersucht werden können.

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

Der vorstehende Code enthält die beiden interessierenden Typen char32_t* und error_data. char32_t* stellt eine Zeichenfolge dar, die in UTF-32 codiert ist. Diese Zeichenfolgencodierung wurde historisch nicht von .NET gemarshallt. error_data ist ein benutzerdefinierter Typ, der ein ganzzahliges 32-Bit-Feld, ein boolesches C++-Feld und ein UTF-32-codiertes Zeichenfolgenfeld enthält. Beide Typen erfordern, dass Sie dem Quellgenerator eine Möglichkeit bieten, Marshallcode zu generieren.

Anpassen des Marshallings für einen integrierten Typ

Berücksichtigen Sie zuerst den char32_t*-Typ, da das Marshallen dieses Typs für den benutzerdefinierten Typ erforderlich ist. char32_t* stellt die native Seite dar, aber Sie benötigen außerdem eine Darstellung in verwaltetem Code. In .NET gibt es nur einen Zeichenfolgentyp, string. Aus diesem Grund marshallen Sie eine native UTF-32-codierte Zeichenfolge in den und aus dem Typ string in verwaltetem Code. Es gibt bereits mehrere integrierte Marshaller für den string-Typ, die als UTF-8, UTF-16, ANSI und sogar als Windows-Typ BSTR marshallen. Es gibt jedoch keinen für das Marshallen als UTF-32. Das müssen Sie definieren.

Der Utf32StringMarshaller-Typ ist mit einem CustomMarshaller-Attribut gekennzeichnet, das beschreibt, was er mit dem Quellgenerator macht. Das erste Typargument für das Attribut ist der string-Typ, der zu marshallende verwaltete Typ, das zweite ist der Modus, der angibt, wann der Marshaller verwendet werden soll, und der dritte Typ ist Utf32StringMarshaller, der für das Marshallen zu verwendende Typ. Sie können den CustomMarshaller mehrmals anwenden, um weitere Angaben zum Modus und dem für diesen Modus zu verwendenden Marshallertyp zu machen.

Das aktuelle Beispiel zeigt einen „zustandslosen“ Marshaller, der eine Eingabe annimmt und Daten in gemarshallter Form zurückgibt. Die Free-Methode ist aus Gründen der Symmetrie mit dem nicht verwalteten Marshalling vorhanden, und der Garbage Collector ist der „freie“ Vorgang für den verwalteten Marshaller. Dem Implementierer steht es frei, alle gewünschten Vorgänge auszuführen, um die Eingabe in die Ausgabe zu marshallen, bedenken Sie jedoch, dass vom Quellgenerator kein Zustand explizit beibehalten wird.

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

Die Besonderheiten, wie dieser bestimmte Marshaller die Konvertierung von string in char32_t* ausführt, finden Sie im Beispiel. Beachten Sie, dass beliebige .NET-APIs verwendet werden können (z. B. Encoding.UTF32).

Betrachten Sie einen Fall, in dem ein Zustand wünschenswert ist. Richten Sie Ihr Augenmerk auf den zusätzlichen CustomMarshaller, und beachten Sie den spezifischeren Modus MarshalMode.ManagedToUnmanagedIn. Dieser spezialisierte Marshaller wird als „zustandsbehaftet“ implementiert und kann den Zustand über den Interop-Aufruf hinweg speichern. Weitere Spezialisierung und der Zustand ermöglichen Optimierungen und maßgeschneidertes Marshalling für einen Modus. Beispielsweise kann der Quellgenerator angewiesen werden, einen stapelseitig zugewiesenen Puffer bereitzustellen, durch den eine explizite Zuordnung während des Marshallings vermieden werden könnte. Zum Angeben der Unterstützung für einen stapelseitig zugewiesenen Puffer implementiert der Marshaller eine Eigenschaft BufferSize und eine Methode FromManaged, die einen Span vom Typ unmanaged annimmt. Die Eigenschaft BufferSize gibt die Menge des Stapelraums an – die Länge des Span, der an FromManaged übergeben wird – die der Marshaller während des Marshallaufrufs abrufen möchte.

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

Sie können jetzt die erste der beiden nativen Funktionen mithilfe Ihrer UTF-32-Zeichenfolgenmarshaller aufrufen. In der folgenden Deklaration wird das LibraryImport-Attribut ebenso wie DllImport verwendet, sie stützt sich aber auf das MarshalUsing-Attribut, um dem Quellgenerator mitzuteilen, welcher Marshaller beim Aufrufen der nativen Funktion verwendet werden soll. Es ist nicht erforderlich, zu klären, ob der zustandslose oder zustandsbehaftete Marshaller verwendet werden soll. Dies wird vom Implementierer übernommen, der den MarshalMode für die CustomMarshaller-Attribute des Marshallers definiert. Der Quellgenerator wählt den am besten geeigneten Marshaller ausgehend von dem Kontext aus, in dem der MarshalUsing angewendet wird, wobei MarshalMode.Default die Fallbackoption bildet.

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

Anpassen des Marshallens für einen benutzerdefinierten Typ

Für das Marshallen eines benutzerdefinierten Typs ist nicht nur die Marshalllogik erforderlich, sondern außerdem der Typ in C#, in den bzw. aus dem das Marshallen erfolgen soll. Erinnern Sie sich an den nativen Typ, den wir marshallen möchten.

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

Definieren Sie nun, wie dies im Idealfall in C# aussehen würde. Ein int hat sowohl in modernem C++ als auch in .NET die gleiche Größe. Ein bool ist das kanonische Beispiel für einen booleschen Wert in .NET. Auf der Grundlage von Utf32StringMarshaller können Sie char32_t* als .NET-string marshallen. Unter Berücksichtigung des .NET-Stils ist das Ergebnis die folgende Definition in C#:

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

Benennen Sie nach dem Benennungsmuster den Marshaller ErrorDataMarshaller. Anstatt einen Marshaller für MarshalMode.Defaultanzugeben, definieren Sie lediglich Marshaller für einige Modi. Wenn der Marshaller in diesem Fall für einen Modus verwendet wird, der nicht angegeben wurde, schlägt der Quellgenerator fehl. Beginnen Sie mit dem Definieren eines Marshallers für die „Ein“-Richtung. Dies ist ein „zustandsloser“ Marshaller, da der eigentliche Marshaller nur aus static-Funktionen besteht.

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 imitiert die Form des nicht verwalteten Typs. Die Konvertierung von einem ErrorData in einen ErrorDataUnmanaged ist mit Utf32StringMarshaller jetzt trivial.

Das Marshallen eines int ist nicht erforderlich, da seine Darstellung in nicht verwaltetem und verwaltetem Code identisch ist. Die binäre Darstellung eines bool-Werts ist in .NET nicht definiert. Verwenden Sie daher seinen aktuellen Wert, um einen Nullwert und einen Wert ungleich 0 im nicht verwalteten Typ zu definieren. Verwenden Sie dann Ihren UTF-32-Marshaller wieder, um das string-Feld in einen uint* zu konvertieren.

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

Denken Sie daran, dass Sie diesen Marshaller als „ein“ definieren, sodass Sie alle Zuordnungen bereinigen müssen, die während des Marshallings ausgeführt werden. Für die Felder int und bool wurde kein Arbeitsspeicher zugewiesen, wohl aber für das Feld Message. Verwenden Sie Utf32StringMarshaller erneut, um die gemarshallte Zeichenfolge zu bereinigen.

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

Betrachten wir kurz das „Aus“-Szenario. Sehen wir uns den Fall an, dass eine oder mehrere Instanzen von error_data zurückgegeben werden.

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

Ein P/Invoke, der einen einzelnen Instanztyp (keine Auflistung) zurückgibt, wird als MarshalMode.ManagedToUnmanagedOut kategorisiert. In der Regel verwenden Sie eine Sammlung, um mehrere Elemente zurückzugeben, und in diesem Fall wird ein Array verwendet. Der Marshaller für ein Auflistungsszenario, das dem MarshalMode.ElementOut-Modus entspricht, gibt mehrere Elemente zurück und wird später beschrieben.

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

Die Konvertierung von ErrorDataUnmanaged in ErrorData ist die Umkehrung des Vorgehens für den „Ein“-Modus. Denken Sie daran, dass Sie auch alle Zuordnungen bereinigen müssen, die von der nicht verwalteten Umgebung erwartet wurden. Es ist auch wichtig zu beachten, dass die Funktionen hier als static gekennzeichnet und daher „zustandslos“ sind. Zustandslosigkeit ist eine Voraussetzung für alle „Element“-Modi. Sie werden auch feststellen, dass es eine ConvertToUnmanaged-Methode wie im eingehenden Modus gibt. Alle Element-Modi erfordern die Behandlung sowohl für den eingehenden als auch für den ausgehenden Modus".

Für den „Aus“-Marshaller von verwaltet zu nicht verwaltet müssen Sie etwas Besonderes erledigen. Der Name des Datentyps, den Sie marshallen, lautet error_data, und .NET drückt Fehler in der Regel als Ausnahmen aus. Einige Fehler sind folgenreicher als andere, und Fehler, die als „schwerwiegend“ bezeichnet werden, deuten normalerweise auf einen katastrophalen oder nicht behebbaren Fehler hin. Beachten Sie, dass error_data über ein Feld verfügt, um zu überprüfen, ob der Fehler schwerwiegend ist. Sie marshallen einen error_data in verwalteten Code, und wenn er schwerwiegend ist, lösen Sie eine Ausnahme aus, anstatt ihn nur in ein ErrorData zu konvertieren und zurückzugeben.

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

Ein „Aus“-Parameter konvertiert aus einem nicht verwalteten Kontext in einen verwalteten Kontext, daher implementieren Sie die ConvertToManaged-Methode. Wenn der nicht verwaltete Aufgerufene bei der Rückgabe ein ErrorDataUnmanaged-Objekt angibt, können Sie es mit Ihrem Marshaller für den ElementOut-Modus untersuchen und überprüfen, ob es als schwerwiegender Fehler markiert ist. Wenn dies der Fall ist, ist dies Ihr Hinweis, eine Ausnahme auszulösen, anstatt ErrorData einfach zurückzugeben.

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

    return data;
}

Vielleicht möchten Sie nicht nur die native Bibliothek nutzen, sondern Ihre Arbeit darüber hinaus mit der Community teilen und eine Interop-Bibliothek bereitstellen. Sie können ErrorData bei jeder Verwendung in einem P/Invoke mit einem impliziten Marshaller angeben, indem Sie der ErrorData-Definition [NativeMarshalling(typeof(ErrorDataMarshaller))] hinzufügen. Alle Benutzer, die Ihre Definition dieses Typs in einem LibraryImport-Aufruf verwenden, profitieren nun von Ihren Marshallern. Sie können Ihre Marshaller jederzeit außer Kraft setzen, indem sie am Einsatzort MarshalUsing verwenden.

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

Siehe auch