Share via


자습서: 원본에서 생성된 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 원본 생성기를 사용하는 것은 네이티브 또는 비관리형 라이브러리를 사용하는 것을 의미합니다. 네이티브 라이브러리는 .NET을 통해 노출되지 않는 운영 체제 API를 직접 호출하는 공유 라이브러리(즉, .dll, .so 또는 dylib)일 수 있습니다. 이 라이브러리는 .NET 개발자가 사용하려는 비관리형 언어로 과도하게 최적화된 라이브러리일 수도 있습니다. 이 자습서에서는 C 스타일 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_data의 두 가지 유용한 형식이 포함되어 있습니다. char32_t*는 .NET이 이전에 마샬링하던 문자열 인코딩이 아닌 UTF-32로 인코딩된 문자열을 나타냅니다. error_data는 32비트 정수 필드, C++ 부울 필드 및 UTF-32로 인코딩된 문자열 필드를 포함하는 사용자 정의 형식입니다. 이러한 두 형식 모두 원본 생성기가 마샬링 코드를 생성하는 방법을 제공해야 합니다.

기본 제공 형식에 대한 마샬링 사용자 지정

사용자 정의 형식에서 이 형식의 마샬링을 요구하므로 char32_t* 형식을 먼저 고려합니다. char32_t*는 네이티브 쪽을 나타내지만 관리 코드로도 표현해야 합니다. .NET에는 하나의 "string" 형식인 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();
    }
}

이 특정 마샬러가 string에서 char32_t*로의 변환을 수행하는 방법에 대한 자세한 내용은 샘플에서 찾을 수 있습니다. 어떤 .NET API도 사용 가능합니다(예: Encoding.UTF32).

상태가 바람직한 경우를 고려합니다. 추가 CustomMarshaller와 좀 더 구체적인 모드인 MarshalMode.ManagedToUnmanagedIn을 확인합니다. 이 특수 마샬러는 "상태 저장"으로 구현되며 interop 호출 전반에서 상태를 저장할 수 있습니다. 더 많은 특수화 및 상태가 최적화와 모드에 맞게 조정된 마샬링을 허용합니다. 예를 들어 마샬링하는 동안 명시적 할당을 방지할 수 있는 스택 할당 버퍼를 제공하도록 원본 생성기에 지시할 수 있습니다. 스택 할당 버퍼에 대한 지원을 나타내기 위해 마샬러는 BufferSize 속성과 unmanaged 형식의 Span을 사용하는 FromManaged 메서드를 구현합니다. BufferSize 속성은 마샬러가 마샬링 호출 중에 가져올 스택 공간의 크기(FromManaged에 전달될 Span의 길이)를 나타냅니다.

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는 비관리형 형식의 모양을 모방합니다. ErrorData에서 ErrorDataUnmanaged로 변환하는 작업은 이제 Utf32StringMarshaller를 사용하면 간단합니다.

int의 마샬링은 해당 표현이 비관리형 및 관리형 코드에서 동일하기 때문에 불필요합니다. bool 값의 이진 표현은 .NET에 정의되어 있지 않으므로 현재 값을 사용하여 비관리형 형식으로 0 값과 0이 아닌 값을 정의합니다. 그런 다음, 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();
        }
    }
}

ErrorDataUnmanaged에서 ErrorData로의 변환은 "in" 모드에 대해 수행한 작업과는 반대입니다. 또한 비관리형 환경에서 수행해야 하는 모든 할당을 정리해야 합니다. 또한 여기에 있는 함수는 static으로 표시되어 있으므로 “상태 비저장”이며, 모든 "Element" 모드에 대해 상태 비저장이어야 합니다. 또한 ConvertToUnmanaged 메서드가 있다는 것을 알 수 있습니다(예: "in" 모드). 모든 "Element" 모드는 "in" 및 "out" 모드 모두에 대한 처리가 필요합니다.

관리형에서 비관리형으로의 "out" 마샬러에 대해서는 좀 더 특별한 작업을 수행하게 됩니다. 마샬링하는 데이터 형식의 이름을 error_data라고 하며 .NET은 일반적으로 오류를 예외로 표현합니다. 일부 오류는 다른 오류보다 더 큰 영향을 미치며 "치명적"으로 식별되는 오류는 치명적이거나 복구할 수 없는 오류를 나타냅니다. error_data에는 오류가 심각한지 확인할 필드가 있습니다. error_data를 관리 코드로 마샬링하고, 치명적이면 ErrorData로 변환하고 반환하는 대신 예외를 throw합니다.

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를 반환하는 대신 throw하라는 것을 나타냅니다.

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

    return data;
}

네이티브 라이브러리를 사용할 뿐만 아니라 커뮤니티와 작업을 공유하고 interop 라이브러리를 제공하려고 할 수도 있습니다. ErrorData 정의에 [NativeMarshalling(typeof(ErrorDataMarshaller))]를 추가하여 P/Invoke에서 사용될 때마다 ErrorData에 암시적 마샬러를 제공할 수 있습니다. 이제 LibraryImport 호출에서 이 형식의 정의를 사용하는 모든 사용자는 마샬러의 이점을 얻을 수 있습니다. 사용 사이트에서 MarshalUsing을 사용하여 마샬러를 항상 재정의할 수 있습니다.

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

추가 정보