教程:在源生成的 P/Invokes 中使用自定义封送器

本教程介绍如何实现封送器并将其用于源生成的 P/Invokes 中的自定义封送

你将为内置类型实现封送器,为特定参数和用户定义的类型自定义封送,并为用户定义的类型指定默认封送。

本教程中使用的所有源代码均在 dotnet/samples 存储库中提供。

LibraryImport源生成器概述

System.Runtime.InteropServices.LibraryImportAttribute 类型是 .NET 7 中引入的源生成器的用户入口点。 此源生成器旨在在编译时而不是运行时生成所有封送代码。 入口点以前通常使用 DllImport 指定,但这种方法涉及的成本可能并不总是可以接受的,详见 P/Invoke 源生成LibraryImport 源生成器可生成所有封送代码,并移除 DllImport 固有的运行时生成要求。

若要表达为运行时和用户生成用来为其自己的类型进行自定义的封送代码所需的详细信息,需要使用几种类型。 本教程中使用以下类型:

  • MarshalUsingAttribute - 源生成器在使用站点中查找的特性,用来确定用于封送特性化变量的封送程序类型。

  • CustomMarshallerAttribute - 该特性用于指示类型的封送程序和执行封送操作的模式(例如,通过 by-ref 从“托管”封送到“非托管”)。

  • NativeMarshallingAttribute - 该特性用于指示要对特性化类型使用哪个封送程序。 对于提供类型和为这些类型提供随附封送程序的作者,这非常有用。

但是,这些属性并不是自定义封送器作者唯一可用的机制。 源生成器会检查封送程序本身,了解其他各种说明应如何进行封送的指示。

可以在 dotnet/runtime 存储库中找到有关设计的完整详细信息。

源生成器分析器和修复程序

除了源生成器本身,还提供了分析器和修复程序。 自 .NET 7 RC1 以来,分析器和修复程序默认处于启用状态并可用。 分析器旨在帮助开发人员正确使用源生成器。 修复程序提供从许多 DllImport 模式到相应 LibraryImport 签名的自动转换。

本机库简介

使用 LibraryImport 源生成器意味着使用本机库(也称为非托管库)。 本机库可能是共享库(即 .dll.sodylib),它直接调用未通过 .NET 公开的操作系统 API。 该库也可能是在 .NET 开发人员想要使用的非托管语言中进行了大量优化的库。 在本教程中,你将生成自己的共享库,用于公开 C 样式 API 图面。 以下代码表示一个用户定义类型和两个 API,你将通过 C# 使用这些 API。 这两个 API 表示“in”模式,但示例中还有其他模式要探索。

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

上述代码包含两种类型的兴趣, char32_t* 以及 error_datachar32_t* 表示在 UTF-32 中编码的字符串,该字符串不是 .NET 历史上封送的字符串编码。 error_data 是包含 32 位整数字段、C++布尔字段和 UTF-32 编码字符串字段的用户定义类型。 这两种类型都要求你提供一种方法来使源生成器生成封送代码。

自定义内置类型的封送

首先考虑 char32_t* 类型,因为用户定义类型需要封送此类型。 char32_t* 代表本机端,但你还需要在托管代码中进行表示。 在 .NET 中,只有一个“string”类型 string。 因此,你将在托管代码中将本机 UTF-32 编码字符串封送至 string 类型或从该类型封送。 对于封送为 UTF-8、UTF-16、ANSI 甚至 Windows string 类型的 BSTR 类型,已经有多个内置封送程序。 但是,它们都不用于封送为 UTF-32。 这就是你需要定义的内容。

Utf32StringMarshaller 类型被标记为具有 CustomMarshaller 属性,该属性描述了它对源生成器的作用。 属性的第一个类型参数是 string 类型,要封送的托管类型,第二个类型是模式,指示何时使用封送器,第三种类型是 Utf32StringMarshaller用于封送的类型。 可以多次应用 CustomMarshaller 以进一步指定模式以及要用于该模式的封送器类型。

当前示例展示了一个“无状态”封送程序,该封送程序接受一些输入,并返回封送形式的数据。 Free 方法的存在是为了与非托管封送保持对称,而垃圾回收器是托管封送程序的“自由”操作。 实现者可以自由执行任何操作来将输入整理为输出,但请记住源生成器不会显式保留任何状态。

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

此特定封送器如何执行从stringchar32_t*的转换的详细信息可以在示例中找到。 请注意,可以使用任何 .NET API(例如 Encoding.UTF32)。

请考虑需要状态的情况。 观察其他 CustomMarshaller 模式并记下更具体的模式 MarshalMode.ManagedToUnmanagedIn。 这个专用的封送程序实现为“有状态”,可跨互操作调用存储状态。 更多专用化和状态允许针对模式进行优化和定制封送。 例如,可以指示源生成器提供堆栈分配的缓冲区,以避免在封送期间出现显式分配。 为了指示对堆栈分配的缓冲区的支持,封送程序实现了 BufferSize 属性和 FromManaged 方法,该方法采用 Span 类型的 unmanagedBufferSize 属性指示封送程序希望在封送调用期间获得的堆栈空间量(要传递到 SpanFromManaged 的长度)。

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

现在可以使用 UTF-32 字符串封送器调用这两个本机函数中的第一个。 以下声明使用LibraryImport属性(如同DllImport),但依赖MarshalUsing属性来告知源生成器在调用本机函数时使用哪个封送器。 无需明确应使用无状态还是有状态封送程序。 这由在封送程序的 MarshalMode 特性上定义 CustomMarshaller 的实现者来处理。 源代码生成器将根据应用的 MarshalUsing 上下文选择最合适的封送器,并将 MarshalMode.Default 作为备选项。

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

自定义用户定义类型的封送

要封送用户定义类型,需要定义封送逻辑,还需要定义 C# 要封送到/从中封送的类型。 回想一下我们尝试封送的本机类型。

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

现在,定义它在 C# 中理想的代码结构。 int新式C++和 .NET 中的大小相同。 A bool 是 .NET 中布尔值的规范示例。 基于 Utf32StringMarshaller 构建,你可以将 char32_t* 封送为 .NET string。 考虑到 .NET 样式,结果为 C# 中的以下定义:

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

遵循命名模式,将封送器 ErrorDataMarshaller命名为 。 与其为 MarshalMode.Default 指定封送器,不如仅为某些模式定义封送器。 在这种情况下,如果封送器用于未提供的模式,则源生成器将失败。 首先为“in”方向定义封送程序。 这是一个“无状态”封送器,因为封送器本身只包含 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 模拟非托管类型的形状。 使用ErrorData,从ErrorDataUnmanaged转换为Utf32StringMarshaller现在非常简单。

不需要封送 int,因为它的表示形式在非托管代码和托管代码中是相同的。 在 .NET 中,bool 值的二进制表示形式未被定义,因此请使用其当前值来在非托管类型中定义零值和非零值。 然后,重复使用 UTF-32 封送器将 string 字段转换为一个 uint*

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

回想一下,你要将此封送程序定义为“in”,因此必须清理在封送期间执行的任何分配。 intbool字段未分配任何内存,但Message字段已经分配了内存。 再次重复使用 Utf32StringMarshaller 来清理已封送的字符串。

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

让我们简要考虑一下“退出”情况。 请考虑返回一个或多个实例 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 被划分为 MarshalMode.ManagedToUnmanagedOut。 通常,使用集合返回多个元素,在这种情况下,将使用一个 Array 元素。 集合方案的封送器(对应于 MarshalMode.ElementOut 模式)将返回多个元素,稍后将进行描述。

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

ErrorDataUnmanagedErrorData 的转换与你在“in”模式下所做的相反。 请记住,还需要清理非托管环境期望你执行的任何分配。 此外,请务必注意此处的函数被标记 static ,因此是“无状态”,是所有“元素”模式的无状态要求。 你还会注意到有一个 ConvertToUnmanaged 方法,就像在“in”模式下一样。 所有“Element”模式都需要处理“输入”和“输出”模式。

对于从托管到非托管的“out”封送程序,需要执行一些特定操作。 您正在封送的数据类型的名称是 error_data,而 .NET 通常将错误表示为异常。 某些错误比其他错误更具影响,标识为“致命”的错误通常表示灾难性或无法恢复的错误。 请注意, error_data 有一个用于检查错误是否致命的字段。 你将 error_data 封送为托管代码,如果是严重错误,你将引发异常,而不仅仅是将其转换为 ErrorData 并返回它。

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

“out”参数将非托管上下文转换为托管上下文,因此您需要实现 ConvertToManaged 方法。 当非托管被调用方返回并提供 ErrorDataUnmanaged 对象时,可使用 ElementOut 模式封送程序来检查它,并检查它是否被标记为严重错误。 如果是这样,则指示引发异常,而不只是返回 ErrorData

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

    return data;
}

也许你不仅要使用本地库,而且还希望与社区共享工作并提供互操作库。 每当在 P/Invoke 中使用时,你都可以通过向 ErrorData 定义添加 [NativeMarshalling(typeof(ErrorDataMarshaller))] 来向 ErrorData 提供隐式封送程序。 现在,在 LibraryImport 调用中使用此类型的定义的用户都将从你的封送程序中获益。 他们始终可通过在使用站点中使用 MarshalUsing 来重写你的封送程序。

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

另请参阅