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

在本教程中,你将学习如何实现封送程序,并将它用于源生成的 P/Invoke 中的自定义封送

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

本教程中使用的所有源代码都可以在 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。 因此,你将在托管代码中将本机 UTF-32 编码字符串封送至 string 类型或从该类型封送。 对于封送为 UTF-8、UTF-16、ANSI 甚至 Windows BSTR 类型的 string 类型,已经有多个内置封送程序。 但是,它们都不用于封送为 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 方法,该方法采用 unmanaged 类型的 SpanBufferSize 属性指示封送程序希望在封送调用期间获得的堆栈空间量(要传递到 FromManagedSpan 的长度)。

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 特性来告知源生成器在调用本机函数时要使用哪个封送程序。 无需明确应使用无状态还是有状态封送程序。 这由在封送程序的 CustomMarshaller 特性上定义 MarshalMode 的实现者来处理。 源生成器将根据应用了 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 中具有相同的大小。 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 模拟非托管类型的形状。 现在,使用 Utf32StringMarshaller 时,可轻松地从 ErrorData 转换为 ErrorDataUnmanaged

不需要封送 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);

让我们简要了解一下“out”场景。 请考虑返回 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”模式都需要处理“in”和“out”模式。

对于从托管到非托管的“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 { ... }

另请参阅