Compartilhar via


Tutorial: Usar marshallers personalizados em P/Invokes gerados pela origem

Neste tutorial, você aprenderá a implementar um marshaller e usá-lo para marshalling personalizado em P/Invokes gerados pela origem.

Você implementará marshallers para um tipo interno, personalizará o marshalling para um parâmetro específico e um tipo definido pelo usuário e especificará o marshalling padrão para um tipo definido pelo usuário.

Todo o código-fonte usado neste tutorial está disponível no repositório dotnet/samples.

Visão geral do gerador de origem LibraryImport

O tipo System.Runtime.InteropServices.LibraryImportAttribute é o ponto de entrada do usuário para um gerador de origem introduzido no .NET 7. Esse gerador de origem foi projetado para gerar todo o código de marshalling em tempo de compilação em vez de em tempo de execução. Pontos de entrada têm sido especificados historicamente usando DllImport, mas essa abordagem vem com custos que podem nem sempre ser aceitáveis. Confira Geração de origem P/Invoke para obter mais informações. O gerador de origem LibraryImport pode gerar todo o código de marshalling e remover o requisito de geração de tempo de execução intrínseco a DllImport.

Para expressar os detalhes necessários para o código de marshalling gerado para o runtime e para que os usuários personalizem para seus próprios tipos, vários tipos são necessários. Os seguintes tipos são usados ao longo deste tutorial:

  • MarshalUsingAttribute – Atributo que é procurado pelo gerador de origem em sites de uso e usado para determinar o tipo de marshaller para realizar marshalling da variável atribuída.

  • CustomMarshallerAttribute – Atributo usado para indicar um marshaller para um tipo e o modo no qual as operações de marshalling devem ser executadas (por exemplo, by-ref de gerenciado para não gerenciado).

  • NativeMarshallingAttribute – Atributo usado para indicar qual marshaller usar para o tipo atribuído. Isso é útil para autores de biblioteca que fornecem tipos e marshallers que acompanham esses tipos.

No entanto, esses atributos não são os únicos mecanismos disponíveis para um autor de marshaller personalizado. O gerador de origem inspeciona o próprio marshaller em busca de várias outras indicações que informam como o marshalling deve ocorrer.

Detalhes completos sobre o design podem ser encontrados no repositório dotnet/runtime.

Analisador e reparador do gerador de origem

Juntamente com o gerador de origem em si, um analisador e um reparador são fornecidos. O analisador e o reparador estão habilitados e disponíveis por padrão desde o .NET 7 RC1. O analisador foi projetado para ajudar a orientar os desenvolvedores a usar o gerador de origem corretamente. O reparador fornece conversões automatizadas de muitos padrões DllImport na assinatura LibraryImport apropriada.

Introdução à biblioteca nativa

Usar o gerador de origem LibraryImport significaria consumir uma biblioteca nativa ou não gerenciada. Uma biblioteca nativa pode ser uma biblioteca compartilhada (ou seja, .dll, .so ou dylib) que chama diretamente uma API do sistema operacional que não é exposta por meio do .NET. A biblioteca também pode ser uma que é altamente otimizada em uma linguagem não gerenciada que um desenvolvedor do .NET deseja consumir. Para este tutorial, você criará sua biblioteca compartilhada que expõe uma superfície de API no estilo C. O código a seguir representa um tipo definido pelo usuário e duas APIs que você consumirá do C#. Essas duas APIs representam o modo "in", mas há modos adicionais a serem explorados no exemplo.

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

O código anterior contém os dois tipos de interesse, char32_t* e error_data. char32_t* representa uma cadeia de caracteres codificada em UTF-32, que não é uma codificação de cadeia de caracteres que o .NET historicamente realiza marshalling. error_data é um tipo definido pelo usuário que contém um campo inteiro de 32 bits, um campo booliano C++ e um campo de cadeia de caracteres codificado em UTF-32. Esses tipos exigem que você forneça uma maneira para o gerador de origem gerar código de marshalling.

Personalizar o marshalling para um tipo interno

Considere o tipo char32_t* primeiro, já que o marshalling desse tipo é exigido pelo tipo definido pelo usuário. char32_t* representa o lado nativo, mas você também precisa de representação no código gerenciado. No .NET, há apenas um tipo de "cadeia de caracteres", string. Portanto, você realizará marshalling de uma cadeia de caracteres codificada em UTF-32 nativa de e para o tipo string no código gerenciado. Já existem vários marshallers internos para o string tipo que fazem marshalling como UTF-8, UTF-16, ANSI e até mesmo como o tipo BSTR Windows. No entanto, não há um para marshalling como UTF-32. Isso é o que você precisa definir.

O tipo Utf32StringMarshaller é marcado com um atributo CustomMarshaller, que descreve o que ele faz com o gerador de origem. O primeiro argumento de tipo para o atributo é o tipo string, o tipo gerenciado para realizar marshalling, o segundo é o modo, que indica quando usar o marshaller e o terceiro tipo é Utf32StringMarshaller, o tipo a ser usado para marshalling. Você pode aplicar o CustomMarshaller várias vezes para especificar ainda mais o modo e qual tipo de marshaller usar para esse modo.

O exemplo atual mostra um marshaller "sem estado" que usa algumas entradas e retorna dados na forma marshalled. O método Free existe para simetria com o marshalling não gerenciado e o coletor de lixo é a operação "livre" para o marshaller gerenciado. O implementador é livre para executar todas as operações desejadas para realizar marshalling da entrada para a saída, mas lembre-se de que nenhum estado será explicitamente preservado pelo gerador de origem.

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

As especificações de como esse marshaller específico executa a conversão de string para char32_t* podem ser encontradas no exemplo. Observe que quaisquer APIs do .NET podem ser usadas (por exemplo, Encoding.UTF32).

Considere um caso em que o estado é desejável. Observe o adicional CustomMarshaller e observe o modo mais específico, MarshalMode.ManagedToUnmanagedIn. Esse marshaller especializado é implementado como "com estado" e pode armazenar o estado na chamada de interoperabilidade. Mais especialização e otimizações de permissão de estado e organização sob medida para um modo. Por exemplo, o gerador de origem pode ser instruído a fornecer um buffer alocado por pilha que poderia evitar uma alocação explícita durante o marshalling. Para indicar suporte para um buffer alocado por pilha, o marshaller implementa uma propriedade BufferSize e um método FromManaged que usa um Span de um tipo unmanaged. A propriedade BufferSize indica a quantidade de espaço de pilha — o comprimento do Span a ser passado para FromManaged — que o marshaller gostaria de obter durante a chamada de 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();
        }
    }
}

Agora você pode chamar a primeira das duas funções nativas usando seus marshallers de cadeia de caracteres UTF-32. A declaração a seguir usa o atributo LibraryImport, assim como DllImport, mas depende do atributo MarshalUsing para informar ao gerador de origem qual marshaller usar ao chamar a função nativa. Não há necessidade de esclarecer se o marshaller sem estado ou com estado deve ser usado. Isso é tratado pelo implementador que define o MarshalMode nos atributos do CustomMarshaller do marshaller. O gerador de origem selecionará o marshaller mais apropriado com base no contexto no qual o MarshalUsing é aplicado, sendo MarshalMode.Default o fallback.

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

Personalizar o marshalling para um tipo definido pelo usuário

Realizar marshalling de um tipo definido pelo usuário requer a definição não apenas da lógica de marshalling, mas também do tipo em C# para realizar marshalling de/para. Lembre-se do tipo nativo que estamos tentando realizar marshaling.

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

Agora, defina a aparência ideal em C#. Um int tem o mesmo tamanho no C++ moderno e no .NET. Um bool é o exemplo canônico para um valor booliano no .NET. Com base em Utf32StringMarshaller, você pode realizar marshaling char32_t* como um .NET string. Contabilizando o estilo do .NET, o resultado é a seguinte definição em C#:

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

Seguindo o padrão de nomenclatura, nomeie o marshaller ErrorDataMarshaller. Em vez de especificar um marshaller para MarshalMode.Default, você definirá apenas marshallers para alguns modos. Nesse caso, se o marshaller for usado para um modo que não é fornecido, o gerador de origem falhará. Comece com a definição de um marshaller para a direção "in". Esse é um marshaller "sem estado", pois o marshaller em si consiste apenas em funções 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 imita a forma do tipo não gerenciado. A conversão de um ErrorData para um ErrorDataUnmanaged agora é trivial com Utf32StringMarshaller.

O marshalling de um int é desnecessário, pois sua representação é idêntica em código não gerenciado e gerenciado. A representação binária de um valor bool não é definida no .NET, portanto, use seu valor atual para definir um valor zero e diferente de zero no tipo não gerenciado. Em seguida, reutilize o marshaller UTF-32 para converter o campo string em um uint*.

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

Lembre-se de que você está definindo esse marshaller como um "in", portanto, você deve limpar todas as alocações executadas durante o marshalling. Os campos int e bool não alocaram nenhuma memória, mas o campo Message sim. Reutilize Utf32StringMarshaller novamente para limpar a cadeia de caracteres com marshalling.

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

Vamos considerar brevemente o cenário "out". Considere o caso em que uma ou várias instâncias de error_data são retornadas.

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

Um P/Invoke que retorna um só tipo de instância, não coleção, é categorizado como um MarshalMode.ManagedToUnmanagedOut. Normalmente, você usa uma coleção para retornar vários elementos e, nesse caso, um Array é usado. O marshaller de um cenário de coleção, correspondente ao modo MarshalMode.ElementOut, retornará vários elementos e é descrito posteriormente.

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

A conversão de ErrorDataUnmanaged para ErrorData é o inverso do que você fez para o modo "in". Lembre-se de que você também precisa limpar todas as alocações que o ambiente não gerenciado esperava que você executasse. Também é importante observar que as funções aqui são marcadas com static e, portanto, são "sem estado". Ser sem estado é um requisito para todos os modos "Elemento". Você também notará que há um método ConvertToUnmanaged como no modo "in". Todos os modos "Element" requerem manipulação para os modos "in" e "out".

Para o marshaller "out" não gerenciado, você vai fazer algo especial. O nome do tipo de dados que você está realizando marshalling é chamado error_data e o .NET normalmente expressa erros como exceções. Alguns erros são mais impactantes do que outros e erros identificados como "fatais" geralmente indicam um erro catastrófico ou irrecuperável. Observe que o error_data tem um campo para indicar se o erro é fatal. Você realizará marshaling de um error_data em código gerenciado e, se for fatal, lançará uma exceção em vez de apenas convertê-la em um ErrorData e retorná-la.

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

Um parâmetro "out" é convertido de um contexto não gerenciado em um contexto gerenciado, para que você implemente o método ConvertToManaged. Quando o receptor não gerenciado retorna e fornece um objeto ErrorDataUnmanaged, você pode inspecioná-lo usando o marshaller de modo ElementOut e verificar se ele está marcado como um erro fatal. Nesse caso, essa é a sua indicação para gerar em vez de apenas retornar o ErrorData.

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

    return data;
}

Talvez você não só consuma a biblioteca nativa, mas também queira compartilhar seu trabalho com a comunidade e fornecer uma biblioteca de interoperabilidade. Você pode fornecer ErrorData com um marshaller implícito sempre que for usado em um P/Invoke adicionando [NativeMarshalling(typeof(ErrorDataMarshaller))] à definição de ErrorData. Agora, qualquer pessoa que usar sua definição desse tipo em uma chamada LibraryImport obterá o benefício de seus marshallers. Eles sempre podem substituir seus marshallers usando MarshalUsing no site de uso.

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

Confira também