Udostępnij za pośrednictwem


Samouczek: używanie niestandardowych marshallerów w P/Invoke generowanych ze źródła

W tym samouczku dowiesz się, jak zaimplementować marshaler i używać go do niestandardowego marshalingu w źródłowo generowanych wywołaniach P/Invoke.

Zaimplementujesz mechanizmy serializacji dla wbudowanego typu, dostosujesz serializację dla określonego parametru oraz typu zdefiniowanego przez użytkownika, a także określisz domyślną serializację dla typu zdefiniowanego przez użytkownika.

Cały kod źródłowy używany w tym samouczku jest dostępny w repozytorium dotnet/samples.

Omówienie źródłowego generatora LibraryImport

Typ System.Runtime.InteropServices.LibraryImportAttribute to punkt wejścia użytkownika dla generatora źródłowego wprowadzonego na platformie .NET 7. Ten generator kodu źródłowego jest przeznaczony do generowania wszystkiego kodu marshallingowego w czasie kompilacji zamiast w czasie działania. Punkty wejścia zostały historycznie określone przy użyciu metody DllImport, ale takie podejście wiąże się z kosztami, które nie zawsze mogą być akceptowalne — aby uzyskać więcej informacji, zobacz Generowanie źródła P/Invoke. Generator LibraryImport źródła może wygenerować cały kod marshallingu i usunąć wymóg generowania w czasie wykonywania, który jest nieodłączną częścią DllImport.

Aby wyrazić szczegóły potrzebne do wygenerowania kodu marshalling zarówno dla środowiska uruchomieniowego, jak i dla użytkowników w celu dostosowania ich własnych typów, potrzebne są kilka typów. W tym samouczku są używane następujące typy:

  • MarshalUsingAttribute — atrybut poszukiwany przez generator kodu źródłowego w miejscach zastosowania i używany do określania rodzaju marshallera do obsługi zmiennej.

  • CustomMarshallerAttribute — Atrybut używany do wskazania marshallera dla typu i trybu, w którym mają być wykonywane operacje marshallingu (na przykład przez odwołanie z zarządzanego do niezarządzanego).

  • NativeMarshallingAttribute — atrybut używany do wskazywania, który marshaller ma być używany dla typu atrybutu. Jest to przydatne w przypadku autorów bibliotek, którzy udostępniają typy i towarzyszące im marshallers dla tych typów.

Te atrybuty nie są jednak jedynymi mechanizmami dostępnymi dla niestandardowego autora marshallera. Generator źródła sprawdza sam marshaller, szukając różnych wskazówek dotyczących sposobu przeprowadzania marshallingu.

Szczegółowe informacje na temat projektu można znaleźć w repozytorium dotnet/runtime .

Analizator i narzędzie do naprawy generatora kodu źródłowego

Wraz z samym generatorem źródłowym zapewniane są zarówno analizator, jak i korektor. Analizator i narzędzie do naprawiania są domyślnie włączone i dostępne od .NET 7 RC1. Analizator został zaprojektowany w celu ułatwienia deweloperom prawidłowego używania generatora źródłowego. Narzędzie Fixer automatycznie konwertuje wiele wzorców DllImport na odpowiednie sygnatury LibraryImport.

Wprowadzenie do biblioteki natywnej

Użycie generatora źródłowego LibraryImport oznaczałoby korzystanie z natywnej lub niezarządzanej biblioteki. Biblioteka natywna może być biblioteką udostępnioną (czyli , .dll.solub dylib), która bezpośrednio wywołuje interfejs API systemu operacyjnego, który nie jest udostępniany za pośrednictwem platformy .NET. Biblioteka może być również taka, która jest mocno zoptymalizowana w języku niezarządzanym, z którego chce korzystać deweloper platformy .NET. W tym samouczku utworzysz własną bibliotekę współdzieloną, która uwidacznia interfejs API w stylu C. Poniższy kod reprezentuje typ zdefiniowany przez użytkownika i dwa interfejsy API, które będziesz używać w C#. Te dwa interfejsy API reprezentują tryb "wejścia", ale istnieją dodatkowe tryby do eksplorowania w pokazowym przykładzie.

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

Powyższy kod zawiera dwa typy zainteresowań i char32_t*error_data. char32_t* reprezentuje ciąg zakodowany w formacie UTF-32, który nie jest kodowaniem ciągów historycznie obsługiwanym przez .NET. error_data jest typem zdefiniowanym przez użytkownika, który zawiera 32-bitowe pole całkowite, pole logiczne języka C++ i pole ciągów zakodowanych w formacie UTF-32. Oba te typy wymagają zapewnienia sposobu generowania kodu marshalling przez generator źródła.

Dostosowywanie marshalingu dla wbudowanego typu

Najpierw rozważ typ char32_t*, ponieważ jego marszaling jest wymagany przez typ zdefiniowany przez użytkownika. char32_t* reprezentuje stronę natywną, ale potrzebna jest również reprezentacja w kodzie zarządzanym. Na platformie .NET istnieje tylko jeden typ „string”. string W związku z tym będziesz przenosić natywny ciąg zakodowany w UTF-32 do i z typu w kodzie zarządzanym string. Istnieje już kilka wbudowanych marshalerów dla typu string, które przesyłają jako UTF-8, UTF-16, ANSI, a nawet jako typ Windows BSTR. Nie ma jednak jednego procesu konwersji do UTF-32. To właśnie musisz zdefiniować.

Typ Utf32StringMarshaller jest oznaczony atrybutem CustomMarshaller , który opisuje, co robi z generatorem źródłowym. Pierwszy argument typu dla atrybutu to string, typ zarządzany do marshalingu, drugi to tryb, który wskazuje, kiedy używać marshaler, a trzeci to Utf32StringMarshaller, typ wykorzystywany do marshalingu. Można zastosować CustomMarshaller wiele razy, aby dodatkowo określić tryb i typ marshallera, który ma być używany dla tego trybu.

W bieżącym przykładzie pokazano bezstanowy marshaller, który pobiera dane wejściowe i zwraca dane w postaci zserializowanej. Metoda Free istnieje dla symetrii z niezarządzanym marszallingiem, a zbieracz odpadów jest operacją bezpłatną dla zarządzanego marszallera. Osoba wdrażająca może wykonać dowolne operacje, aby przeprowadzić przekazywanie danych wejściowych do danych wyjściowych, ale pamiętaj, że żaden stan nie zostanie jawnie zachowany przez generator kodu źródłowego.

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

Specyfika działania tego konkretnego marshallera, który dokonuje konwersji z string na char32_t*, jest zawarta w przykładzie. Należy pamiętać, że można użyć dowolnych interfejsów API platformy .NET (na przykład Encoding.UTF32).

Rozważmy przypadek, w którym stan jest pożądany. Zwróć uwagę na dodatkowy CustomMarshaller i bardziej szczegółowy tryb MarshalMode.ManagedToUnmanagedIn. Ten wyspecjalizowany marshaller jest implementowany jako "zachowujący stan" i może przechowywać stan podczas wywołania międzyoperacyjnego. Większa specjalizacja oraz optymalizacje zezwalają na dostosowane przekazywanie danych i usprawnienia dla trybu. Na przykład generator źródła można poinstruować, aby zapewnić bufor przydzielony do stosu, który może uniknąć jawnej alokacji podczas marshalingu. Aby wskazać obsługę bufora przydzielonego na stosie, marshaller implementuje właściwość BufferSize i metodę FromManaged, która przyjmuje Span typu unmanaged. Właściwość BufferSize wskazuje ilość miejsca w stosie — długość Span do przekazania do FromManaged — które marshaller chce uzyskać podczas wywołania marshala.

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

Teraz można wywołać pierwszą z dwóch funkcji natywnych przy użyciu marshallerów ciągów UTF-32. Poniższa deklaracja używa atrybutu LibraryImport, podobnie jak DllImport, ale opiera się na atrybucie MarshalUsing, aby poinformować generatora źródeł, który marshaller ma być używany podczas wywoływania funkcji natywnej. Nie ma potrzeby doprecyzowywać, czy należy używać marshallera bezstanowego czy stanowego. Jest to obsługiwane przez implementatora definiującego MarshalMode na atrybucie CustomMarshaller marshallera. Generator źródła wybierze najbardziej odpowiedniego marszalera na podstawie kontekstu, w którym MarshalUsing jest stosowany, a MarshalMode.Default jako rezerwowy.

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

Dostosowywanie marshalingu dla typu zdefiniowanego przez użytkownika

Marshalling typu zdefiniowanego przez użytkownika wymaga zdefiniowania nie tylko logiki marshalingu, ale także typu w języku C# do marshalingu do/z. Przypomnij sobie rodzimy typ, który próbujemy poddać konwersji.

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

Teraz zdefiniuj, jak najlepiej będzie wyglądać w języku C#. Rozmiar elementu int jest taki sam zarówno w nowoczesnym języku C++, jak i na platformie .NET. A bool to kanoniczny przykład wartości boolowskiej w .NET. Opierając się na Utf32StringMarshaller, możesz zorganizować char32_t* jako .NET string. Biorąc pod uwagę styl platformy .NET, wynik to następująca definicja w języku C#:

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

Zgodnie ze wzorcem nazewnictwa nadaj nazwę marshaller ErrorDataMarshaller. Zamiast określać marshaller dla MarshalMode.Default, zdefiniujesz tylko marshallers dla niektórych trybów. W takim przypadku jeśli marshaller jest używany dla trybu, który nie jest udostępniony, generator kodu zakończy się niepowodzeniem. Zacznij od zdefiniowania marshaller'a dla kierunku "in". Jest to "bezstanowy" marshaller, ponieważ sam marshaller składa się tylko z static funkcji.

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 naśladuje kształt niezarządzanego typu. Konwersja z elementu ErrorData na element ErrorDataUnmanaged jest teraz trywialna dzięki Utf32StringMarshaller.

Marshalling elementu int jest niepotrzebny, ponieważ jego reprezentacja jest identyczna w niezarządzanym i zarządzanym kodzie. bool Reprezentacja binarna wartości nie jest zdefiniowana na platformie .NET, dlatego użyj jej bieżącej wartości, aby zdefiniować wartość zero i niezerową w typie niezarządzanym. Następnie ponownie użyj marshallera UTF-32, aby przekonwertować string pole na uint*.

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

Pamiętaj, że definiujesz ten marshaller jako "w", więc należy wyczyścić wszystkie alokacje wykonywane podczas marshallingu. Pola int i bool nie przydzieliły żadnej pamięci, ale pole Message tak. Ponownie użyj Utf32StringMarshaller, aby wyczyścić ciąg sformatowany.

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

Pokrótce rozważmy scenariusz „wyjścia”. Rozważmy przypadek, w którym zwracane jest jedno lub wiele wystąpień 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);

P/Invoke zwracający pojedynczy typ instancji, nie będący kolekcją, jest klasyfikowany jako MarshalMode.ManagedToUnmanagedOut. Zazwyczaj używa się kolekcji do zwracania wielu elementów, a w tym przypadku używany jest Array. Marshaller dla scenariusza kolekcji, odpowiadający trybowi MarshalMode.ElementOut, zwróci wiele elementów i zostanie opisany później.

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

Konwersja z ErrorDataUnmanaged na ErrorData jest odwrotnością tego, co zrobiłeś dla trybu "in". Należy pamiętać, że należy również wyczyścić wszystkie alokacje, których oczekuje się, że wykonasz w środowisku niezarządzanym. Ważne jest również pamiętać, że funkcje tutaj są oznaczone jako static, a zatem są "bezstanowe". Bycie bezstanowym jest wymogiem wszystkich trybów "Element". Zauważysz również, że istnieje metoda podobna do tej w trybie "in". Wszystkie tryby "Element" wymagają obsługi zarówno trybu "in", jak i "out".

Dla marshaler "out" z zarządzanego do niezarządzanego, będziesz musiał zrobić coś szczególnego. Nazwa typu danych, który jest zamieniany, to error_data, a platforma .NET zwykle wyraża błędy jako wyjątki. Niektóre błędy są bardziej wpływające niż inne, a błędy zidentyfikowane jako "krytyczne" zwykle wskazują na katastrofalny lub nieodwracalny błąd. Zwróć uwagę, że pole error_data zawiera pole, aby sprawdzić, czy błąd jest krytyczny. Przeprowadzisz element do kodu zarządzanego error_data, a jeśli jest śmiertelny, zgłosisz wyjątek, zamiast tylko przekonwertować go na element ErrorData i go zwrócić.

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

Parametr "out" konwertuje z kontekstu niezarządzanego na kontekst zarządzany, aby zaimplementować metodę ConvertToManaged . Kiedy niezarządzany odbierający zwraca i udostępnia obiekt ErrorDataUnmanaged, możesz go sprawdzić przy użyciu marshalerze trybu ElementOut i sprawdzić, czy jest oznaczony jako krytyczny błąd. Jeśli tak, oznacza to, że należy zgłosić zamiast po prostu zwrócić ErrorData.

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

    return data;
}

Być może nie tylko zamierzasz korzystać z biblioteki natywnej, ale także chcesz podzielić się swoją pracą ze społecznością i dostarczyć bibliotekę interoperacyjną. Możesz dostarczyć ErrorData implicitny marshaller przy jego użyciu w P/Invoke, dodając [NativeMarshalling(typeof(ErrorDataMarshaller))] do definicji ErrorData. Teraz każda osoba korzystająca z twojej definicji tego typu w wywołaniu LibraryImport zyska korzyści z twoich marshallerów. Mogą zawsze zastąpić twoje marshallery, używając MarshalUsing w miejscu zastosowania.

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

Zobacz także