プログラム系統備忘録ブログ

記事中のコードは自己責任の下でご自由にどうぞ。

LibraryImportAttributeが.NET 7で追加されたので触ってみました

.NET 7で追加されたLibraryImportAttributeや関連する機能の概要、生成されるコード、試行錯誤した点、サンプル等の備忘録記事です。

本記事の内容は、Microsoft Visual Studio Community 2022 (64-bit) - Current Version 17.4.3.NET SDK Version 7.0.101で動作確認しました。

概要

LibraryImportAttribute Classは、DllImportAttribute Classの代替となる属性です。どちらもPlatform Invoke (P/Invoke)用途で使用する属性で、人によっては全く縁がない分野かもしれません。しかし必要な人にとっては必須の分野だと思います。

LibraryImportAttribute使用時は、ソースジェネーレーターのruntime/LibraryImportGenerator.cs at v7.0.0 · dotnet/runtimeによってビルド時にP/Invoke用ソースが生成されます。LibraryImportGeneratorによるエラーや警告、メッセージ等は、SYSLIB1050等のエラー番号で表されます。SYSLIB関係のドキュメントはSYSLIB diagnostics for Microsoft.Interop.LibraryImportGeneratorにあります。

P/Invoke source generationや、提案時のLibraryImportAttribute for p/invoke source generation · Issue #46822 · dotnet/runtimeによると、DllImportAttributeLibraryImportAttributeの大きな違いは以下の点とのことです:

  • DllImportAttributeでは、マーシャリング用コードの動的生成が実行時に必要です。一方でLibraryImportAttributeではマーシャリング用コードをビルド時に生成できるため、実行時のコード生成が不要になります。また、マーシャリング箇所のインライン展開や、AOT(ahead of time)コンパイルも可能になります。
  • DllImportAttributeでは、マーシャリング指定に誤りがあった場合はデバッグが大変です。一方でLiburaryImportAttributeではC#ソースが生成されているため分かりやすく、また必要な場合はステップ実行できます。
  • DllImportAttributeという名前には、Windows環境固有のDLLという名称が含まれています。LibraryImportAttributeと言う名称は、クロスプラットフォーム向きの名称になっています。(DllImortAttributeでもLinuxやmacOSの動的ライブラリ中APIを呼び出すことはできます)

利用者にとっては以下の点が大きいと思います:

  • DllImportAttributeでは非対応でしたが、LibraryImportAttributeでは可能になったマーシャリングが存在します。詳細は後述します。
  • 一方で、DllImportAttributeでは対応していたマーシャリングが、LibraryImportAttributeでは非対応になっていたり、独自マーシャラーの実装が必要になる場合があります。こちらも詳細は後述します。
  • runtime/StructMarshalling.md at main · dotnet/runtimeLimited type information in ref assembliesとある通り、ref assemblyでは型情報が欠落しているため、ref assemblyを使用する場合のコード生成はうまくいかないとのことです。後述するGuidSystem.Runtime.InteropServices.ComTypes.FILETIPEの場合に該当するのだと思います。

なお、この後紹介するように、LibraryImportAttributeの生成結果ソースはDllImportAttributeを使用しています。そのためDllImportAttributeが完全に無くなっても良いわけではありません。内部的に依存しています。

Win32APIを使うならCsWin32も便利

microsoft/CsWin32というソースジェネレーターがあります。C#やRustからWin32 APIをもっと簡単に呼び出せるように ~Microsoftが「win32metadata」プロジェクトを発表 - 窓の杜によると、2021/01/21頃、つまりは.NET 5が最新バージョンだった時代に公開されました(tagsから追える最初のリリースは2021/01/12です)。当該ライブラリはnuget経由で導入できます。

CsWin32は、名前の通りWindows環境向けのソースジェネレーターで、P/Invoke用だけでなくCOM相互運用用途のコードも生成できます。使用したいAPI名や定数名等をNativeMethods.txtに書くと、それらの相互運用用C#コードを生成してくれます。なお、Linux環境等でもソースコードの生成やビルドはできるとのことです。とはいえ実行は流石にできません。(出典: Visual Studio 16.9 Preview 3 (C# Next チョットある) | ++C++; // 未確認飛行 C ブログ)

CsWin32の使用方法、使用感に言及している記事です:

私がCsWin32リリース当初に少し触ってみたときの感想です:

  • APIの引数や戻り値に使用する構造体やSafeHandle派生型の定義等もまとめて生成してくれるのが特に便利だと思いました。C++ヘッダーやMicrosoftドキュメントを見てAPIシグネチャを書き直す作業には慣れましたが、避けられるのはやはり良いものです。
  • CsWin32の生成結果ソースにはポインター型が登場しやすい印象です。適宜、配列型やSpan<T>を受け取り、fixedしつつCsWin32の生成結果を呼び出すラッパーが欲しくなりそうだと思いました。
  • CsWin32を導入しているソリューションを開いている間、VisualStudioの動作が遅く感じました(数値測定はしておらず)。ただハイスペックマシンを使っている方なら気にならないかもしれません。

C#コードからWin32APIを呼び出したい場合には選択肢の1つになると思います。

ただし、本記事執筆時点の最新バージョンでもCsWin32のソースジェネレーターはISourceGeneratorで実装されています(出典: CsWin32/SourceGenerator.cs at v0.2.162-beta · microsoft/CsWin32)。すなわちIIncrementalGenerator実装では無いため、VisualStudioのパフォーマンスに悪影響を与えやすいようです。なお、LibraryImportAttribute用のソースジェネレーターは、IIncrementalGeneratorで実装されています(出典: runtime/LibraryImportGenerator.cs at v7.0.0 · dotnet/runtime)。

IIncrementalGeneratorについては.NET 6からのC# Incremental Source Generatorの開発入門Roslyn とその活用法 - Speaker Deckで紹介されています。

CsWin32の紹介はここまでで、ここからはLibraryImportAttributeの紹介に入ります。

Quick ActionによるDllImportAttributeからLibraryImportAttributeへの変換

.NET 7のプロジェクトでDllImportAttributeを使っていれば、エラー一覧に以下のようなメッセージが自動的に出てくると思います:

Message SYSLIB1054 Mark the method 'GetTickCount' with 'LibraryImportAttribute' instead of 'DllImportAttribute' to generate P/Invoke marshalling code at compile time

メッセージをダブルクリックするとDllImportAttributeのついたメソッドへカーソルが移動します。そこでQuick Action候補を表示すると、提案内容にLibraryImportAttributeへの自動変換が存在します。このとき、元の名前のまま変換する(=LibraryImportAttribute.EntryPointの指定なし)か、AまたはW接尾辞をつけた名前で変換する(=LibraryImportAttribute.EntryPointを指定)かを適切に選びます。LibraryImportAttributeはEntryPoint指定があればその名前のみを、指定がなければメソッド名そのもののみをネイティブライブラリから探す仕様であるため、ネイティブ側の関数名をよく確認しましょう。

Quick Actionの実行例画像です:

CharSet.Unicodeを指定している場合はUnicode版としてW接尾辞のEntryPoint名にするかの選択肢が出てきます

文字列を使わないAPIの場合でもAnsi版のA接尾辞としてEntryPointを指定する選択肢があるので誤って選択しないよう注意

「最初はDllImportAttributeで書いて、Quick ActionでLibraryImportAttribtueに変換してもらう」という実装の流れも十分ありだと思います。

DllImportAttributeとLibraryImportAttributeで異なる点の詳細

P/Invoke source generationで説明されている違いとして、以下の点があります。

  • DllImportAttribute.CallingConventionに対応するメンバーはLibraryImportAttributeにはありません。代わりにUnmanagedCallConvAttribute Classを使用する必要があります。なお、Quick Actionで変換すると自動で変更してくれるので簡単です。
  • DllImportAttribute.CharSetは、LibraryImportAttribute.StringMarshallingに代わっています。フィールド名だけでなく列挙型も異なるため、適宜変換が必要です。なお、Quick Actionで変換すると自動で変更してくれるので簡単です。
  • DllImportAttribute.ExactSpellingfalseが設定されている場合は、「EntryPointでの指定名またはメソッド名を探して存在すればそれを使い、存在しなければCharSetに応じてAまたはWを末尾に追加した名前でも探す」という動作を行います。LibraryImportAttributeにはそのような動作はありません。「EntryPointでの指定名またはメソッド名を探して存在すればそれを使う」動作のみになります。理由は、DllImportAttribute.ExactSpellingtrueに設定したコードを生成するためです。
  • DllImportAttribute.PreserveSigfalseが設定されている場合は、ネイティブAPI戻り値のint型をHRESULTとして解釈し、失敗コードの場合は自動的に例外を送出してくれます。LibraryImportAttributeにはそのような機能はありません。なお、DllImportAttribute.PreserveSigfalseのメソッドをQuick Actionで変換すると、戻り値がvoidからintへ変換されました。呼び出しそのものは問題なくできますが、利用箇所で失敗判定を追加する必要があります。
  • DllImportAttribute.BestFitMappingtrue時の処理や、DllImportAttribute.ThrowOnUnmappableChartrue時の処理は、LibraryImportAttributeでは提供されていません。当該APIのUtf-16版やUtf-8版APIが存在するなら、そちらを使用するよう切り替えるのが解決策になります。GetProcAddressのように本当にAnsi版APIだけが存在する場合は……DllImportAttributeを使い続けるのも1つの手だと思います。なお、当該メンバーをtrueに設定しているDllImportAttributeを持つメソッドをQuick Actionで変更しようとすると、存在しないLibraryImportAttribute.BestFitMappingLibraryImportAttribute.ThrowOnUnmappableCharを設定しようとするコードが生成されてしまいます。当然そのコードはコンパイルエラーになります。本現象は報告済みですので今後修正されると思いますが、「Quick Actionによってエラーになるコードになってしまう」可能性がある点に注意が必要です。

その他、触ってみてから気付いた違いも多くあります。

  • DllImportAttributeの場合は、Span<T>のマーシャリングは非対応です。LibraryImportAttributeではSpan<T>等のマーシャリングに対応しています!
  • DllImportAttributeの場合は、ネイティブAPIからのcharバッファ読み書き用途でStringBuilder型を使えます。ただしパフォーマンス上の理由で避けるよう推奨されています(出典: Default Marshalling for Strings - .NET Framework)。LibraryImportAttributeの場合はStringBuilder型のサポートはありません。char[]Span<char>を使いましょう。
  • DllImportAttributeの場合は、bool型はデフォルトで、Win32APIで使用する4バイト値のBOOL型へマーシャリングされます(出典: Customizing structure marshalling - .NET)。その挙動はMarshalAsAttribute Classで変更できます。LibraryImportAttributeの場合は、bool型へのMarshalAsAttributeの指定が必須です。そのため分かりやすいコードになります。また、MarshalAsAttributeの記述を忘れている場合は以下のようなエラーを出してくれます:
Error SYSLIB1051 Marshalling bool without explicit marshalling information is not supported. Specify either 'MarshalUsingAttribute' or 'MarshalAsAttribute'. The generated source will not handle marshalling of the return value of method 'ReadFile'.
  • DllImportAttributeの場合は、unsafeコードを許可しないプロジェクトでも使用できます。一方でLibraryImportAttributeの場合は生成結果ソースにunsafeコードを含むため、プロジェクト設定でunsafeコードを許可する必要があります。とはいえunsafeコードを許可していない場合は以下のエラーを出してくれるので、すぐに分かることでしょう:
Error SYSLIB1062 LibraryImportAttribute requires unsafe code. Project must be updated with '<AllowUnsafeBlocks>true</AllowUnsafeBlocks>'
  • DllImportAttributeの場合は、C#のclass Fooを、ネイティブAPIのFoo*へマーシャリングできます(出典: Marshalling Classes, Structures, and Unions)。LibraryImportAttributeの場合はclassのマーシャリングをするためには独自マーシャラーの実装が必要です。詳細は後述します。
  • DllImportAttributeの場合は、ネイティブAPIがGUID*を引数に取る箇所で、[MarshalAs(UnmanagedType.LPStruct)] Guidを指定できます(出典: c# - How do I marshal a structure as a pointer to a structure? - Stack Overflow)。LibraryImportAttributeではUnmanagedType.LPStructをサポートしていません(とはいえソースジェネレーターを使う時点でpartialメソッドが拡張されたC# 9以降を使っているはずなので、C# 7.2で追加されたinパラメーターを使ってin Guidと指定すればいいでしょう)。また、Guid型のマーシャリングには独自マーシャラーの実装が必要であるようです。詳細は後述します。
  • DllImportAttributeの場合は、HandleRef Structをマーシャリングできます(出典: Type marshalling - .NET)。LibraryImportAttributeの場合はHandleRefのマーシャリングは出来ません。とはいえHandleRefのドキュメントにある通りSafeHandle型が後継ですし、そちらはLibraryImportAttributeも対応しているため、SafeHandle型を使えばいいでしょう(もしくはIntPtr型か)。もしどうしてもHandleRef型を使う必要がある場合は、HandleRefMarshaller.csを参考にして同様に独自マーシャラーを実装すると良さそうです。
  • DllImportAttributeの場合は__arglistを扱えます。__arglistの解説は可変引数をパラメータに持つC++の関数を、C#から利用する方法 | C# プログラミング解説等にあります。一方でLibraryImportAttribute__arglistを全く扱えないようです。また、エラー内容を見ても__arglistが原因とは全く分からない内容なので、ハマると困りそうです:
using System;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;

namespace Test;

internal static partial class NativeMethods
{
    [LibraryImport("msvcrt.dll")]
    [UnmanagedCallConv(CallConvs = new Type[] { typeof(CallConvCdecl) })]
    internal static partial int printf([MarshalAs(UnmanagedType.LPStr)] string s, __arglist);
}
Error CS8795 Partial method 'NativeMethods.printf(string, __arglist)' must have an implementation part because it has accessibility modifiers.

LibraryImportAttributeによる生成結果ソースの観察

本節では、LibraryImportAttributeによる生成結果ソースを見ていきます。

なお、LibraryImportAttribute関連箇所を編集していると、エラー一覧に以下の警告が出ていることがあります:

Warning CS8785 Generator 'LibraryImportGenerator' failed to generate source. It will not contribute to the output and compilation errors may occur as a result. Exception was of type 'ArgumentException' with message 'SyntaxTree is not part of the compilation

この警告が発生していると、partialメソッド定義箇所で「定義を移動」を選択しても生成結果にジャンプできなくなってしまい、不便です。その現象が発生した場合は、ソリューションを開き直すとまた通常通りの動作になります。

独自マーシャラーを実装せずにできる用途の例

LibraryImportAttribute関係で標準でマーシャリングできる型、出来ない型が存在します。標準でマーシャリングできない型は、独自マーシャラーを実装してマーシャリングする必要があります。本項では、独自マーシャラーが不要な範囲でのLibraryImportAttributeの使用例と生成結果ソースを解説します。

引数や戻り値がBlittable型のみの場合はそもそもマーシャリング不要

Blittable型とは、マネージドとネイティブ側で同一メモリ表現であるため、マーシャリングが不要な型です(出典: Blittable and Non-Blittable Types - .NET Framework)。

int等の整数型やenumIntPtrやポインター型等はBlittable型です。関数や引数がBlittable型のみである場合は、LibraryImportAttributeによる生成結果ソースは単にDllImportAttributeを付与した定義になります。:

using System;
using System.Runtime.InteropServices;

namespace Test;

internal enum WM : int
{
    WM_CREATE = 1,
    // 他メンバーは省略
}

internal static partial class NativeMethods
{
    // ポインター型の例
    [LibraryImport("Kernel32.dll")]
    internal static unsafe partial int DiscardVirtualMemory(
        void* VirtualAddress,
        nint Size);

    // enumや関数ポインター型の例
    [LibraryImport("User32.dll", EntryPoint = "CallWindowProcW")]
    internal static unsafe partial IntPtr CallWindowProc(
        delegate* unmanaged[Stdcall]<IntPtr, WM, IntPtr, IntPtr, IntPtr> lpPrevWndFunc,
        IntPtr hWnd,
        WM Msg,
        IntPtr wParam,
        IntPtr lParam);
}
// <auto-generated/>
namespace Test
{
    internal static unsafe partial class NativeMethods
    {
        [System.Runtime.InteropServices.DllImportAttribute("Kernel32.dll", EntryPoint = "DiscardVirtualMemory", ExactSpelling = true)]
        internal static unsafe extern partial int DiscardVirtualMemory(void* VirtualAddress, nint Size);
    }
}
namespace Test
{
    internal static unsafe partial class NativeMethods
    {
        [System.Runtime.InteropServices.DllImportAttribute("User32.dll", EntryPoint = "CallWindowProcW", ExactSpelling = true)]
        internal static unsafe extern partial nint CallWindowProc(delegate* unmanaged[Stdcall]<nint, global::Test.WM, nint, nint, nint> lpPrevWndFunc, nint hWnd, global::Test.WM Msg, nint wParam, nint lParam);
    }
}

なお、関数ポインター型へ変換可能なC#コードはUnmanagedCallersOnlyAttribute Classを使うと実現できました:

[UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvStdcall) })]
private static IntPtr TestWndProc(IntPtr hWnd, WM Msg, IntPtr wParam, IntPtr lParam)
{
    Console.WriteLine($"hWnd={hWnd}, Msg={Msg}, wParam={wParam}, lParam={lParam}");
    return IntPtr.Zero;
}

// &演算子で関数ポインター型へ変換出来ます。
NativeMethods.CallWindowProc(&TestWndProc, 12, (WM)34, 56, 78);

bool型やSafeHandle型、Delegate型の使用時や、SetLastError指定時は、必要な事前処理と事後処理が生成される

boolからの変換やboolへの変換、SafeHandleからのネイティブハンドルの取得、Delegate型からのポインター取得等、必要な処理が生成されます:

using System;
using System.Runtime.InteropServices;
using Microsoft.Win32.SafeHandles;

namespace Test;

internal static partial class NativeMethods
{
    // SetLastError指定、SafeHandle入力と出力、bool引数と戻り値の例
    [LibraryImport("Kernel32.dll", SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    internal static partial bool DuplicateHandle(
        SafeProcessHandle hSourceProcessHandle,
        SafeFileHandle hSourceHandle,
        SafeProcessHandle hTargetProcessHandle,
        out SafeFileHandle lpTargetHandle,
        int dwDesiredAccess,
        [MarshalAs(UnmanagedType.Bool)] bool bInheritHandle,
        int dwOptions);

    // SetLastError指定、SafeHandle入力、bool入力と出力と戻り値の例
    [LibraryImport("Kernel32.dll", SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    internal static partial bool GetProcessPriorityBoost(
        SafeProcessHandle hProcess,
        [MarshalAs(UnmanagedType.Bool)] out bool pDisablePriorityBoost);

    // SetLastError指定、Delegate入力、bool戻り値の例
    [return: MarshalAs(UnmanagedType.Bool)]
    internal delegate bool EnumWindowsProc(IntPtr hwnd, IntPtr lParam);

    [LibraryImport("User32.dll", SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    internal static partial bool EnumWindows(
        EnumWindowsProc lpEnumFunc,
        IntPtr lParam);
}
// <auto-generated/>
namespace Test
{
    internal static unsafe partial class NativeMethods
    {
        [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Interop.LibraryImportGenerator", "7.0.7.6804")]
        [System.Runtime.CompilerServices.SkipLocalsInitAttribute]
        internal static partial bool DuplicateHandle(global::Microsoft.Win32.SafeHandles.SafeProcessHandle hSourceProcessHandle, global::Microsoft.Win32.SafeHandles.SafeFileHandle hSourceHandle, global::Microsoft.Win32.SafeHandles.SafeProcessHandle hTargetProcessHandle, out global::Microsoft.Win32.SafeHandles.SafeFileHandle lpTargetHandle, int dwDesiredAccess, bool bInheritHandle, int dwOptions)
        {
            int __lastError;
            bool __invokeSucceeded = default;
            System.Runtime.CompilerServices.Unsafe.SkipInit(out lpTargetHandle);
            System.IntPtr __hSourceProcessHandle_native = default;
            System.IntPtr __hSourceHandle_native = default;
            System.IntPtr __hTargetProcessHandle_native = default;
            System.IntPtr __lpTargetHandle_native = default;
            int __bInheritHandle_native = default;
            bool __retVal;
            int __retVal_native = default;
            // Setup - Perform required setup.
            bool hSourceProcessHandle__addRefd = false;
            bool hSourceHandle__addRefd = false;
            bool hTargetProcessHandle__addRefd = false;
            global::Microsoft.Win32.SafeHandles.SafeFileHandle lpTargetHandle__newHandle = new global::Microsoft.Win32.SafeHandles.SafeFileHandle();
            try
            {
                // Marshal - Convert managed data to native data.
                hSourceProcessHandle.DangerousAddRef(ref hSourceProcessHandle__addRefd);
                __hSourceProcessHandle_native = hSourceProcessHandle.DangerousGetHandle();
                hSourceHandle.DangerousAddRef(ref hSourceHandle__addRefd);
                __hSourceHandle_native = hSourceHandle.DangerousGetHandle();
                hTargetProcessHandle.DangerousAddRef(ref hTargetProcessHandle__addRefd);
                __hTargetProcessHandle_native = hTargetProcessHandle.DangerousGetHandle();
                __bInheritHandle_native = (int)(bInheritHandle ? 1 : 0);
                {
                    System.Runtime.InteropServices.Marshal.SetLastSystemError(0);
                    __retVal_native = __PInvoke(__hSourceProcessHandle_native, __hSourceHandle_native, __hTargetProcessHandle_native, &__lpTargetHandle_native, dwDesiredAccess, __bInheritHandle_native, dwOptions);
                    __lastError = System.Runtime.InteropServices.Marshal.GetLastSystemError();
                }

                __invokeSucceeded = true;
                // Unmarshal - Convert native data to managed data.
                __retVal = __retVal_native != 0;
            }
            finally
            {
                if (__invokeSucceeded)
                {
                    // GuaranteedUnmarshal - Convert native data to managed data even in the case of an exception during the non-cleanup phases.
                    System.Runtime.InteropServices.Marshal.InitHandle(lpTargetHandle__newHandle, __lpTargetHandle_native);
                    lpTargetHandle = lpTargetHandle__newHandle;
                }

                // Cleanup - Perform required cleanup.
                if (hSourceProcessHandle__addRefd)
                    hSourceProcessHandle.DangerousRelease();
                if (hSourceHandle__addRefd)
                    hSourceHandle.DangerousRelease();
                if (hTargetProcessHandle__addRefd)
                    hTargetProcessHandle.DangerousRelease();
            }

            System.Runtime.InteropServices.Marshal.SetLastPInvokeError(__lastError);
            return __retVal;
            // Local P/Invoke
            [System.Runtime.InteropServices.DllImportAttribute("Kernel32.dll", EntryPoint = "DuplicateHandle", ExactSpelling = true)]
            static extern unsafe int __PInvoke(System.IntPtr hSourceProcessHandle, System.IntPtr hSourceHandle, System.IntPtr hTargetProcessHandle, System.IntPtr* lpTargetHandle, int dwDesiredAccess, int bInheritHandle, int dwOptions);
        }
    }
}
namespace Test
{
    internal static unsafe partial class NativeMethods
    {
        [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Interop.LibraryImportGenerator", "7.0.7.6804")]
        [System.Runtime.CompilerServices.SkipLocalsInitAttribute]
        internal static partial bool GetProcessPriorityBoost(global::Microsoft.Win32.SafeHandles.SafeProcessHandle hProcess, out bool pDisablePriorityBoost)
        {
            int __lastError;
            System.Runtime.CompilerServices.Unsafe.SkipInit(out pDisablePriorityBoost);
            System.IntPtr __hProcess_native = default;
            int __pDisablePriorityBoost_native = default;
            bool __retVal;
            int __retVal_native = default;
            // Setup - Perform required setup.
            bool hProcess__addRefd = false;
            try
            {
                // Marshal - Convert managed data to native data.
                hProcess.DangerousAddRef(ref hProcess__addRefd);
                __hProcess_native = hProcess.DangerousGetHandle();
                {
                    System.Runtime.InteropServices.Marshal.SetLastSystemError(0);
                    __retVal_native = __PInvoke(__hProcess_native, &__pDisablePriorityBoost_native);
                    __lastError = System.Runtime.InteropServices.Marshal.GetLastSystemError();
                }

                // Unmarshal - Convert native data to managed data.
                __retVal = __retVal_native != 0;
                pDisablePriorityBoost = __pDisablePriorityBoost_native != 0;
            }
            finally
            {
                // Cleanup - Perform required cleanup.
                if (hProcess__addRefd)
                    hProcess.DangerousRelease();
            }

            System.Runtime.InteropServices.Marshal.SetLastPInvokeError(__lastError);
            return __retVal;
            // Local P/Invoke
            [System.Runtime.InteropServices.DllImportAttribute("Kernel32.dll", EntryPoint = "GetProcessPriorityBoost", ExactSpelling = true)]
            static extern unsafe int __PInvoke(System.IntPtr hProcess, int* pDisablePriorityBoost);
        }
    }
}
namespace Test
{
    internal static unsafe partial class NativeMethods
    {
        [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Interop.LibraryImportGenerator", "7.0.7.6804")]
        [System.Runtime.CompilerServices.SkipLocalsInitAttribute]
        internal static partial bool EnumWindows(global::Test.NativeMethods.EnumWindowsProc lpEnumFunc, nint lParam)
        {
            int __lastError;
            System.IntPtr __lpEnumFunc_native;
            bool __retVal;
            int __retVal_native;
            // Marshal - Convert managed data to native data.
            __lpEnumFunc_native = lpEnumFunc != null ? System.Runtime.InteropServices.Marshal.GetFunctionPointerForDelegate(lpEnumFunc) : default;
            {
                System.Runtime.InteropServices.Marshal.SetLastSystemError(0);
                __retVal_native = __PInvoke(__lpEnumFunc_native, lParam);
                __lastError = System.Runtime.InteropServices.Marshal.GetLastSystemError();
            }

            // NotifyForSuccessfulInvoke - Keep alive any managed objects that need to stay alive across the call.
            global::System.GC.KeepAlive(lpEnumFunc);
            // Unmarshal - Convert native data to managed data.
            __retVal = __retVal_native != 0;
            System.Runtime.InteropServices.Marshal.SetLastPInvokeError(__lastError);
            return __retVal;
            // Local P/Invoke
            [System.Runtime.InteropServices.DllImportAttribute("User32.dll", EntryPoint = "EnumWindows", ExactSpelling = true)]
            static extern unsafe int __PInvoke(System.IntPtr lpEnumFunc, nint lParam);
        }
    }
}

ここで、DuplicateHandleの生成結果ソースを見れば分かるように、out引数のSafeFileHandleのデフォルトコンストラクタを呼び出しています。そのためSafeHandle派生型のデフォルトコンストラクタを定義し忘れていた場合は、コンパイルエラーとなって気付けます。DllImportAttributeによるSafeHandle派生型のout引数の場合は、デフォルトコンストラクタが無ければMissingMethodExceptionが実行時に送出されていました。このようにLibraryImportAttributeの場合はコンパイル時に気づけることが多くなる点も良い点です。

余談です。SafeHandle関係で思い出す話ですが、かつてはConstrained Execution Regions(略称CER)という機能がありました。CERについてはUsing the Reliability Features of the .NET Frameworkが詳しいです。このCERを使うと「絶対に例外を発生させない区間」を実現できるため、「ネイティブハンドル取得からSafeHandle格納までの間に例外が発生してしまってハンドルリークしてしまう」問題を回避できました。「ネイティブハンドル取得からSafeHandle格納までの間に例外が発生」という状況は、例えばThread.Abortによって発生する可能性がありました。

しかしCERは.NET Core以降ではサポートされておらず、またThread.Abortは.NET 5以降ではPlatformNotSupportedExceptionを送出するだけになっています。つまりは.NET 7の現状では、「ネイティブハンドル取得からSafeHandle格納までの間に例外が発生してしまってハンドルリークしてしまう問題」は考慮する必要がないようです(生成コードを見るに、OutOfMeoryExceptionStackOverflowExceptionが発生する余地もなさそうです)。そのためDuplicateHandleのコード生成で、P/Invokeで取得したネイティブハンドルをMarshal.InitHandleで設定するまでの間に例外が発生することは無いのでしょう。

ref引数やout引数、in引数はfixedステートメントでポインターに変換される

参照系統の引数はいずれもfixedステートメント経由でポインター型へ変換され、ポインター型を使ってP/Invokeする挙動になります:

using System.Runtime.InteropServices;

namespace Test;

internal struct FILETIME
{
    internal int dwLowDateTime;
    internal int dwHighDateTime;
}

internal struct SYSTEMTIME
{
    internal short wYear;
    internal short wMonth;
    internal short wDayOfWeek;
    internal short wDay;
    internal short wHour;
    internal short wMinute;
    internal short wSecond;
    internal short wMilliseconds;
}

internal partial class NativeMethods
{
    [LibraryImport("Kernel32.dll", EntryPoint = "FileTimeToSystemTime")]
    [return: MarshalAs(UnmanagedType.Bool)]
    internal static partial bool FileTimeToSystemTime_UsingInOut(in FILETIME lpFileTime, out SYSTEMTIME lpSystemTime);

    [LibraryImport("Kernel32.dll", EntryPoint = "FileTimeToSystemTime")]
    [return: MarshalAs(UnmanagedType.Bool)]
    internal static partial bool FileTimeToSystemTime_UsingRef(ref FILETIME lpFileTime, ref SYSTEMTIME lpSystemTime);
}
// <auto-generated/>
namespace Test
{
    internal unsafe partial class NativeMethods
    {
        [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Interop.LibraryImportGenerator", "7.0.7.6804")]
        [System.Runtime.CompilerServices.SkipLocalsInitAttribute]
        internal static partial bool FileTimeToSystemTime_UsingInOut(in global::Test.FILETIME lpFileTime, out global::Test.SYSTEMTIME lpSystemTime)
        {
            System.Runtime.CompilerServices.Unsafe.SkipInit(out lpSystemTime);
            bool __retVal;
            int __retVal_native;
            // Pin - Pin data in preparation for calling the P/Invoke.
            fixed (global::Test.FILETIME* __lpFileTime_native = &lpFileTime)
            fixed (global::Test.SYSTEMTIME* __lpSystemTime_native = &lpSystemTime)
            {
                __retVal_native = __PInvoke(__lpFileTime_native, __lpSystemTime_native);
            }

            // Unmarshal - Convert native data to managed data.
            __retVal = __retVal_native != 0;
            return __retVal;
            // Local P/Invoke
            [System.Runtime.InteropServices.DllImportAttribute("Kernel32.dll", EntryPoint = "FileTimeToSystemTime", ExactSpelling = true)]
            static extern unsafe int __PInvoke(global::Test.FILETIME* lpFileTime, global::Test.SYSTEMTIME* lpSystemTime);
        }
    }
}
namespace Test
{
    internal unsafe partial class NativeMethods
    {
        [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Interop.LibraryImportGenerator", "7.0.7.6804")]
        [System.Runtime.CompilerServices.SkipLocalsInitAttribute]
        internal static partial bool FileTimeToSystemTime_UsingRef(ref global::Test.FILETIME lpFileTime, ref global::Test.SYSTEMTIME lpSystemTime)
        {
            bool __retVal;
            int __retVal_native;
            // Pin - Pin data in preparation for calling the P/Invoke.
            fixed (global::Test.FILETIME* __lpFileTime_native = &lpFileTime)
            fixed (global::Test.SYSTEMTIME* __lpSystemTime_native = &lpSystemTime)
            {
                __retVal_native = __PInvoke(__lpFileTime_native, __lpSystemTime_native);
            }

            // Unmarshal - Convert native data to managed data.
            __retVal = __retVal_native != 0;
            return __retVal;
            // Local P/Invoke
            [System.Runtime.InteropServices.DllImportAttribute("Kernel32.dll", EntryPoint = "FileTimeToSystemTime", ExactSpelling = true)]
            static extern unsafe int __PInvoke(global::Test.FILETIME* lpFileTime, global::Test.SYSTEMTIME* lpSystemTime);
        }
    }
}

余談です。.NET標準でもSystem.Runtime.InteropServices.ComTypes.FILETIMEが存在しますが、LibraryImportAttributeでそちらを使おうとするとエラーになります:

using System.Runtime.InteropServices;
using System.Runtime.InteropServices.ComTypes;

namespace Test;

internal struct SYSTEMTIME
{
    internal short wYear;
    internal short wMonth;
    internal short wDayOfWeek;
    internal short wDay;
    internal short wHour;
    internal short wMinute;
    internal short wSecond;
    internal short wMilliseconds;
}
internal static partial class NativeMethods
{
    // 標準のFILETIMEを使おうとするとエラーになる
    [LibraryImport("Kernel32.dll", EntryPoint = "FileTimeToSystemTime")]
    [return: MarshalAs(UnmanagedType.Bool)]
    internal static partial bool FileTimeToSystemTime(in FILETIME lpFileTime, out SYSTEMTIME lpSystemTime);
}
Error SYSLIB1051 Runtime marshalling must be disabled in this project by applying the 'System.Runtime.CompilerServices.DisableRuntimeMarshallingAttribute' to the assembly to enable marshalling this type. The generated source will not handle marshalling of parameter 'lpFileTime'.

記事最初の方に書いたように、ref assemblyでは型情報が欠落していることが原因かもしれません。.NET標準ソース内容をローカルコピペして使うとエラーにならず正しくコード生成されました。

配列型やSpan系統は標準マーシャラー経由で先頭要素アドレスを指すポインターへ変換される

.NET 7からSystem.Runtime.InteropServices.Marshalling Namespaceが追加されています。配列型やSpan<T>型などは、当該名前空間以下のマーシャラー経由でポインターに変換するコードが生成されます:

using System;
using System.Net.Sockets;
using System.Runtime.InteropServices;

namespace Test;

internal static partial class NativeMethods
{
    [LibraryImport("Psapi.dll", SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    internal static partial bool EnumProcesses(int[] lpidProcess, int cb, out int lpcbNeeded);

    [LibraryImport("Psapi.dll", SetLastError = true)]
    [return: MarshalAs(UnmanagedType.Bool)]
    internal static partial bool EnumProcesses(Span<int> lpidProcess, int cb, out int lpcbNeeded);

    [LibraryImport("Ws2_32.dll")]
    internal static partial int send(SafeSocketHandle s, ReadOnlySpan<byte> buf, int len, int flags);
}
// <auto-generated/>
namespace Test
{
    internal static unsafe partial class NativeMethods
    {
        [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Interop.LibraryImportGenerator", "7.0.7.6804")]
        [System.Runtime.CompilerServices.SkipLocalsInitAttribute]
        internal static partial bool EnumProcesses(int[] lpidProcess, int cb, out int lpcbNeeded)
        {
            int __lastError;
            System.Runtime.CompilerServices.Unsafe.SkipInit(out lpcbNeeded);
            bool __retVal;
            int __retVal_native;
            // Pin - Pin data in preparation for calling the P/Invoke.
            fixed (void* __lpidProcess_native = &global::System.Runtime.InteropServices.Marshalling.ArrayMarshaller<int, int>.ManagedToUnmanagedIn.GetPinnableReference(lpidProcess))
            fixed (int* __lpcbNeeded_native = &lpcbNeeded)
            {
                System.Runtime.InteropServices.Marshal.SetLastSystemError(0);
                __retVal_native = __PInvoke((int*)__lpidProcess_native, cb, __lpcbNeeded_native);
                __lastError = System.Runtime.InteropServices.Marshal.GetLastSystemError();
            }

            // Unmarshal - Convert native data to managed data.
            __retVal = __retVal_native != 0;
            System.Runtime.InteropServices.Marshal.SetLastPInvokeError(__lastError);
            return __retVal;
            // Local P/Invoke
            [System.Runtime.InteropServices.DllImportAttribute("Psapi.dll", EntryPoint = "EnumProcesses", ExactSpelling = true)]
            static extern unsafe int __PInvoke(int* lpidProcess, int cb, int* lpcbNeeded);
        }
    }
}
namespace Test
{
    internal static unsafe partial class NativeMethods
    {
        [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Interop.LibraryImportGenerator", "7.0.7.6804")]
        [System.Runtime.CompilerServices.SkipLocalsInitAttribute]
        internal static partial bool EnumProcesses(global::System.Span<int> lpidProcess, int cb, out int lpcbNeeded)
        {
            int __lastError;
            System.Runtime.CompilerServices.Unsafe.SkipInit(out lpcbNeeded);
            bool __retVal;
            int __retVal_native;
            // Pin - Pin data in preparation for calling the P/Invoke.
            fixed (void* __lpidProcess_native = &global::System.Runtime.InteropServices.Marshalling.SpanMarshaller<int, int>.ManagedToUnmanagedIn.GetPinnableReference(lpidProcess))
            fixed (int* __lpcbNeeded_native = &lpcbNeeded)
            {
                System.Runtime.InteropServices.Marshal.SetLastSystemError(0);
                __retVal_native = __PInvoke((int*)__lpidProcess_native, cb, __lpcbNeeded_native);
                __lastError = System.Runtime.InteropServices.Marshal.GetLastSystemError();
            }

            // Unmarshal - Convert native data to managed data.
            __retVal = __retVal_native != 0;
            System.Runtime.InteropServices.Marshal.SetLastPInvokeError(__lastError);
            return __retVal;
            // Local P/Invoke
            [System.Runtime.InteropServices.DllImportAttribute("Psapi.dll", EntryPoint = "EnumProcesses", ExactSpelling = true)]
            static extern unsafe int __PInvoke(int* lpidProcess, int cb, int* lpcbNeeded);
        }
    }
}
namespace Test
{
    internal static unsafe partial class NativeMethods
    {
        [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Interop.LibraryImportGenerator", "7.0.7.6804")]
        [System.Runtime.CompilerServices.SkipLocalsInitAttribute]
        internal static partial int send(global::System.Net.Sockets.SafeSocketHandle s, global::System.ReadOnlySpan<byte> buf, int len, int flags)
        {
            System.IntPtr __s_native = default;
            int __retVal;
            // Setup - Perform required setup.
            bool s__addRefd = false;
            try
            {
                // Marshal - Convert managed data to native data.
                s.DangerousAddRef(ref s__addRefd);
                __s_native = s.DangerousGetHandle();
                // Pin - Pin data in preparation for calling the P/Invoke.
                fixed (void* __buf_native = &global::System.Runtime.InteropServices.Marshalling.ReadOnlySpanMarshaller<byte, byte>.ManagedToUnmanagedIn.GetPinnableReference(buf))
                {
                    __retVal = __PInvoke(__s_native, (byte*)__buf_native, len, flags);
                }
            }
            finally
            {
                // Cleanup - Perform required cleanup.
                if (s__addRefd)
                    s.DangerousRelease();
            }

            return __retVal;
            // Local P/Invoke
            [System.Runtime.InteropServices.DllImportAttribute("Ws2_32.dll", EntryPoint = "send", ExactSpelling = true)]
            static extern unsafe int __PInvoke(System.IntPtr s, byte* buf, int len, int flags);
        }
    }
}

SafeSocketHandleの存在を知ったので使ってみました。.NET Core 3.0から存在していたとのことです。Microsoft.Win32.SafeHandles以下の型の派生型がSystem以下にあるのが意外に感じました。

文字列型のマーシャリングはUTF-16やUTF-8、BStrの場合は簡単にできて、Ansiの場合も標準でできる

文字列型を引数等に含むAPIの場合は、LibraryImportAttribute.StringMarshallingCustomTypeを指定するか、引数にMarshalAsAttributeを指定する必要があります。なお、もしも指定を忘れていてもエラーで教えてくれるので安心です:

Error SYSLIB1051 Marshalling string or char without explicit marshalling information is not supported. Specify 'LibraryImportAttribute.StringMarshalling', 'LibraryImportAttribute.StringMarshallingCustomType', 'MarshalUsingAttribute' or 'MarshalAsAttribute'. The generated source will not handle marshalling of parameter 'lpString'.

関連しているので、stringchar[]Span<char>の使用例をまとめて紹介します。長くなりますがご容赦ください:

using System;
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.Marshalling;

namespace Test;

internal static partial class NativeMethods
{
    // UTF-16にすることをStringMarshallingで指定
    [LibraryImport("Kernel32.dll", EntryPoint = "lstrlenW", StringMarshalling = StringMarshalling.Utf16)]
    internal static partial int lstrlenW1(string lpString);
    // UTF-16にすることをMarshalAsAtributeで指定
    [LibraryImport("Kernel32.dll", EntryPoint = "lstrlenW")]
    internal static partial int lstrlenW2([MarshalAs(UnmanagedType.LPWStr)] string lpString);

    // AnsiにすることをStringMarshallingCustomTypeで指定。なおStringMarshallingメンバーのデフォルト値はCustomなので指定する必要はありません
    [LibraryImport("Kernel32.dll", EntryPoint = "lstrlenA", StringMarshallingCustomType = typeof(AnsiStringMarshaller))]
    internal static partial int lstrlenA1(string lpString);
    // AnsiにすることをMarshalAsAttributeで指定
    [LibraryImport("Kernel32.dll", EntryPoint = "lstrlenA")]
    internal static partial int lstrlenA2([MarshalAs(UnmanagedType.LPStr)] string lpString);

    // char[]やSpan<char>の場合は、MarshalAsAttributeでのマーシャリング方法指定はできず、StringMarshallingでの指定が必要なようです
    // char[]をマーシャリングする例
    [LibraryImport("Kernel32.dll", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)]
    internal static partial int GetEnvironmentVariableW(string? lpName, char[]? lpBuffer, int nSize);
    // Span<char>をマーシャリングする例
    [LibraryImport("Kernel32.dll", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)]
    internal static partial int GetEnvironmentVariableW(string? lpName, Span<char> lpBuffer, int nSize);

    // [out] BStr*の例。なお前述したように、LibraryImportAttributeにはHRESULTの自動エラー処理をさせる機能はないので、呼び出し元で判断する必要があります
    [LibraryImport("OleAut32.dll")]
    internal static partial int VarBstrFromI4(int lIn, int lcid, int dwFlags, [MarshalAs(UnmanagedType.BStr)] out string pbstrOut);

    // UTF-8の例
    [LibraryImport("Kernel32.dll", SetLastError = true, StringMarshalling = StringMarshalling.Utf16)]
    internal static partial int MultiByteToWideChar(
        int CodePage, // CP_UTF8を前提としています
        int dwFlags,
        [MarshalAs(UnmanagedType.LPUTF8Str)] string lpMultiByteStr, // UTF-8引数としてマーシャリングさせます
        int cbMultiByte,
        Span<char> lpWideCharStr, // Span型は個別のMarshalAsAttributeによる指定が出来ないので、StringMarshalling側で指定しています
        int cchWideChar);
}
// <auto-generated/>
namespace Test
{
    internal static unsafe partial class NativeMethods
    {
        [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Interop.LibraryImportGenerator", "7.0.7.6804")]
        [System.Runtime.CompilerServices.SkipLocalsInitAttribute]
        internal static partial int lstrlenW1(string lpString)
        {
            int __retVal;
            // Pin - Pin data in preparation for calling the P/Invoke.
            fixed (void* __lpString_native = &global::System.Runtime.InteropServices.Marshalling.Utf16StringMarshaller.GetPinnableReference(lpString))
            {
                __retVal = __PInvoke((ushort*)__lpString_native);
            }

            return __retVal;
            // Local P/Invoke
            [System.Runtime.InteropServices.DllImportAttribute("Kernel32.dll", EntryPoint = "lstrlenW", ExactSpelling = true)]
            static extern unsafe int __PInvoke(ushort* lpString);
        }
    }
}
namespace Test
{
    internal static unsafe partial class NativeMethods
    {
        [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Interop.LibraryImportGenerator", "7.0.7.6804")]
        [System.Runtime.CompilerServices.SkipLocalsInitAttribute]
        internal static partial int lstrlenW2(string lpString)
        {
            int __retVal;
            // Pin - Pin data in preparation for calling the P/Invoke.
            fixed (void* __lpString_native = &global::System.Runtime.InteropServices.Marshalling.Utf16StringMarshaller.GetPinnableReference(lpString))
            {
                __retVal = __PInvoke((ushort*)__lpString_native);
            }

            return __retVal;
            // Local P/Invoke
            [System.Runtime.InteropServices.DllImportAttribute("Kernel32.dll", EntryPoint = "lstrlenW", ExactSpelling = true)]
            static extern unsafe int __PInvoke(ushort* lpString);
        }
    }
}
namespace Test
{
    internal static unsafe partial class NativeMethods
    {
        [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Interop.LibraryImportGenerator", "7.0.7.6804")]
        [System.Runtime.CompilerServices.SkipLocalsInitAttribute]
        internal static partial int lstrlenA1(string lpString)
        {
            byte* __lpString_native = default;
            int __retVal;
            // Setup - Perform required setup.
            global::System.Runtime.InteropServices.Marshalling.AnsiStringMarshaller.ManagedToUnmanagedIn __lpString_native__marshaller = new();
            try
            {
                // Marshal - Convert managed data to native data.
                byte* __lpString_native__stackptr = stackalloc byte[global::System.Runtime.InteropServices.Marshalling.AnsiStringMarshaller.ManagedToUnmanagedIn.BufferSize];
                __lpString_native__marshaller.FromManaged(lpString, new System.Span<byte>(__lpString_native__stackptr, global::System.Runtime.InteropServices.Marshalling.AnsiStringMarshaller.ManagedToUnmanagedIn.BufferSize));
                {
                    // PinnedMarshal - Convert managed data to native data that requires the managed data to be pinned.
                    __lpString_native = __lpString_native__marshaller.ToUnmanaged();
                    __retVal = __PInvoke(__lpString_native);
                }
            }
            finally
            {
                // Cleanup - Perform required cleanup.
                __lpString_native__marshaller.Free();
            }

            return __retVal;
            // Local P/Invoke
            [System.Runtime.InteropServices.DllImportAttribute("Kernel32.dll", EntryPoint = "lstrlenA", ExactSpelling = true)]
            static extern unsafe int __PInvoke(byte* lpString);
        }
    }
}
namespace Test
{
    internal static unsafe partial class NativeMethods
    {
        [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Interop.LibraryImportGenerator", "7.0.7.6804")]
        [System.Runtime.CompilerServices.SkipLocalsInitAttribute]
        internal static partial int lstrlenA2(string lpString)
        {
            byte* __lpString_native = default;
            int __retVal;
            // Setup - Perform required setup.
            global::System.Runtime.InteropServices.Marshalling.AnsiStringMarshaller.ManagedToUnmanagedIn __lpString_native__marshaller = new();
            try
            {
                // Marshal - Convert managed data to native data.
                byte* __lpString_native__stackptr = stackalloc byte[global::System.Runtime.InteropServices.Marshalling.AnsiStringMarshaller.ManagedToUnmanagedIn.BufferSize];
                __lpString_native__marshaller.FromManaged(lpString, new System.Span<byte>(__lpString_native__stackptr, global::System.Runtime.InteropServices.Marshalling.AnsiStringMarshaller.ManagedToUnmanagedIn.BufferSize));
                {
                    // PinnedMarshal - Convert managed data to native data that requires the managed data to be pinned.
                    __lpString_native = __lpString_native__marshaller.ToUnmanaged();
                    __retVal = __PInvoke(__lpString_native);
                }
            }
            finally
            {
                // Cleanup - Perform required cleanup.
                __lpString_native__marshaller.Free();
            }

            return __retVal;
            // Local P/Invoke
            [System.Runtime.InteropServices.DllImportAttribute("Kernel32.dll", EntryPoint = "lstrlenA", ExactSpelling = true)]
            static extern unsafe int __PInvoke(byte* lpString);
        }
    }
}
namespace Test
{
    internal static unsafe partial class NativeMethods
    {
        [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Interop.LibraryImportGenerator", "7.0.7.6804")]
        [System.Runtime.CompilerServices.SkipLocalsInitAttribute]
        internal static partial int GetEnvironmentVariableW(string lpName, char[] lpBuffer, int nSize)
        {
            int __lastError;
            int __retVal;
            // Pin - Pin data in preparation for calling the P/Invoke.
            fixed (void* __lpName_native = &global::System.Runtime.InteropServices.Marshalling.Utf16StringMarshaller.GetPinnableReference(lpName))
            fixed (void* __lpBuffer_native = &global::System.Runtime.InteropServices.Marshalling.ArrayMarshaller<char, char>.ManagedToUnmanagedIn.GetPinnableReference(lpBuffer))
            {
                System.Runtime.InteropServices.Marshal.SetLastSystemError(0);
                __retVal = __PInvoke((ushort*)__lpName_native, (char*)__lpBuffer_native, nSize);
                __lastError = System.Runtime.InteropServices.Marshal.GetLastSystemError();
            }

            System.Runtime.InteropServices.Marshal.SetLastPInvokeError(__lastError);
            return __retVal;
            // Local P/Invoke
            [System.Runtime.InteropServices.DllImportAttribute("Kernel32.dll", EntryPoint = "GetEnvironmentVariableW", ExactSpelling = true)]
            static extern unsafe int __PInvoke(ushort* lpName, char* lpBuffer, int nSize);
        }
    }
}
namespace Test
{
    internal static unsafe partial class NativeMethods
    {
        [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Interop.LibraryImportGenerator", "7.0.7.6804")]
        [System.Runtime.CompilerServices.SkipLocalsInitAttribute]
        internal static partial int GetEnvironmentVariableW(string lpName, global::System.Span<char> lpBuffer, int nSize)
        {
            int __lastError;
            int __retVal;
            // Pin - Pin data in preparation for calling the P/Invoke.
            fixed (void* __lpName_native = &global::System.Runtime.InteropServices.Marshalling.Utf16StringMarshaller.GetPinnableReference(lpName))
            fixed (void* __lpBuffer_native = &global::System.Runtime.InteropServices.Marshalling.SpanMarshaller<char, char>.ManagedToUnmanagedIn.GetPinnableReference(lpBuffer))
            {
                System.Runtime.InteropServices.Marshal.SetLastSystemError(0);
                __retVal = __PInvoke((ushort*)__lpName_native, (char*)__lpBuffer_native, nSize);
                __lastError = System.Runtime.InteropServices.Marshal.GetLastSystemError();
            }

            System.Runtime.InteropServices.Marshal.SetLastPInvokeError(__lastError);
            return __retVal;
            // Local P/Invoke
            [System.Runtime.InteropServices.DllImportAttribute("Kernel32.dll", EntryPoint = "GetEnvironmentVariableW", ExactSpelling = true)]
            static extern unsafe int __PInvoke(ushort* lpName, char* lpBuffer, int nSize);
        }
    }
}
namespace Test
{
    internal static unsafe partial class NativeMethods
    {
        [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Interop.LibraryImportGenerator", "7.0.7.6804")]
        [System.Runtime.CompilerServices.SkipLocalsInitAttribute]
        internal static partial int VarBstrFromI4(int lIn, int lcid, int dwFlags, out string pbstrOut)
        {
            System.Runtime.CompilerServices.Unsafe.SkipInit(out pbstrOut);
            ushort* __pbstrOut_native = default;
            int __retVal;
            try
            {
                {
                    __retVal = __PInvoke(lIn, lcid, dwFlags, &__pbstrOut_native);
                }

                // Unmarshal - Convert native data to managed data.
                pbstrOut = global::System.Runtime.InteropServices.Marshalling.BStrStringMarshaller.ConvertToManaged(__pbstrOut_native);
            }
            finally
            {
                // Cleanup - Perform required cleanup.
                global::System.Runtime.InteropServices.Marshalling.BStrStringMarshaller.Free(__pbstrOut_native);
            }

            return __retVal;
            // Local P/Invoke
            [System.Runtime.InteropServices.DllImportAttribute("OleAut32.dll", EntryPoint = "VarBstrFromI4", ExactSpelling = true)]
            static extern unsafe int __PInvoke(int lIn, int lcid, int dwFlags, ushort** pbstrOut);
        }
    }
}
namespace Test
{
    internal static unsafe partial class NativeMethods
    {
        [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Interop.LibraryImportGenerator", "7.0.7.6804")]
        [System.Runtime.CompilerServices.SkipLocalsInitAttribute]
        internal static partial int MultiByteToWideChar(int CodePage, int dwFlags, string lpMultiByteStr, int cbMultiByte, global::System.Span<char> lpWideCharStr, int cchWideChar)
        {
            int __lastError;
            byte* __lpMultiByteStr_native = default;
            int __retVal;
            // Setup - Perform required setup.
            global::System.Runtime.InteropServices.Marshalling.Utf8StringMarshaller.ManagedToUnmanagedIn __lpMultiByteStr_native__marshaller = new();
            try
            {
                // Marshal - Convert managed data to native data.
                byte* __lpMultiByteStr_native__stackptr = stackalloc byte[global::System.Runtime.InteropServices.Marshalling.Utf8StringMarshaller.ManagedToUnmanagedIn.BufferSize];
                __lpMultiByteStr_native__marshaller.FromManaged(lpMultiByteStr, new System.Span<byte>(__lpMultiByteStr_native__stackptr, global::System.Runtime.InteropServices.Marshalling.Utf8StringMarshaller.ManagedToUnmanagedIn.BufferSize));
                // Pin - Pin data in preparation for calling the P/Invoke.
                fixed (void* __lpWideCharStr_native = &global::System.Runtime.InteropServices.Marshalling.SpanMarshaller<char, char>.ManagedToUnmanagedIn.GetPinnableReference(lpWideCharStr))
                {
                    // PinnedMarshal - Convert managed data to native data that requires the managed data to be pinned.
                    __lpMultiByteStr_native = __lpMultiByteStr_native__marshaller.ToUnmanaged();
                    System.Runtime.InteropServices.Marshal.SetLastSystemError(0);
                    __retVal = __PInvoke(CodePage, dwFlags, __lpMultiByteStr_native, cbMultiByte, (char*)__lpWideCharStr_native, cchWideChar);
                    __lastError = System.Runtime.InteropServices.Marshal.GetLastSystemError();
                }
            }
            finally
            {
                // Cleanup - Perform required cleanup.
                __lpMultiByteStr_native__marshaller.Free();
            }

            System.Runtime.InteropServices.Marshal.SetLastPInvokeError(__lastError);
            return __retVal;
            // Local P/Invoke
            [System.Runtime.InteropServices.DllImportAttribute("Kernel32.dll", EntryPoint = "MultiByteToWideChar", ExactSpelling = true)]
            static extern unsafe int __PInvoke(int CodePage, int dwFlags, byte* lpMultiByteStr, int cbMultiByte, char* lpWideCharStr, int cchWideChar);
        }
    }
}

指定エンコーディング方法に応じてUtf16StringMarshallerBStrStringMarshallerUtf8StringMarshaller.ManagedToUnmanagedInAnsiStringMarshaller.ManagedToUnmanagedIn等を使用していることが分かります。また、StringMarshalling.Utf16を指定した場合とMarshalAs(UnmanagedType.LPWStr)を指定した場合で生成結果ソースは同一になるようです。

生成結果を観察すると、Utf8StringMarshaller.ManagedToUnmanagedInAnsiStringMarshaller.ManagedToUnmanagedIn関係の処理でBufferSizeを使用したSpan<byte>確保があります。マーシャリング結果がそのサイズを超過する場合にどうなるか気になりましたが、Utf8StringMarshaller.csAnsiStringMarshaller.csの実装を見ると「マーシャリング結果がSpanサイズを超える可能性がある場合はヒープから確保」する分岐があるため問題なさそうです。

UnmanagedCallConvAttributeを使用している場合は、その内容がそのまま生成結果ソースへ反映される

UnmanagedCallConvAttributeの使用例として、呼び出し規約がcdeclであるネイティブAPIの例を示します:

using System;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;

namespace Test;

internal static partial class NativeMethods
{
    [LibraryImport("msvcrt.dll")]
    [UnmanagedCallConv(CallConvs = new Type[] { typeof(CallConvCdecl) })]
    internal static partial int puts([MarshalAs(UnmanagedType.LPStr)] string s);

    [LibraryImport("msvcrt.dll")]
    [UnmanagedCallConv(CallConvs = new[] { typeof(CallConvCdecl) })]
    internal static unsafe partial int memcmp(byte* buffer1, byte* buffer2, uint count);
}
// <auto-generated/>
namespace Test
{
    internal static unsafe partial class NativeMethods
    {
        [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Interop.LibraryImportGenerator", "7.0.7.6804")]
        [System.Runtime.CompilerServices.SkipLocalsInitAttribute]
        internal static partial int puts(string s)
        {
            byte* __s_native = default;
            int __retVal;
            // Setup - Perform required setup.
            global::System.Runtime.InteropServices.Marshalling.AnsiStringMarshaller.ManagedToUnmanagedIn __s_native__marshaller = new();
            try
            {
                // Marshal - Convert managed data to native data.
                byte* __s_native__stackptr = stackalloc byte[global::System.Runtime.InteropServices.Marshalling.AnsiStringMarshaller.ManagedToUnmanagedIn.BufferSize];
                __s_native__marshaller.FromManaged(s, new System.Span<byte>(__s_native__stackptr, global::System.Runtime.InteropServices.Marshalling.AnsiStringMarshaller.ManagedToUnmanagedIn.BufferSize));
                {
                    // PinnedMarshal - Convert managed data to native data that requires the managed data to be pinned.
                    __s_native = __s_native__marshaller.ToUnmanaged();
                    __retVal = __PInvoke(__s_native);
                }
            }
            finally
            {
                // Cleanup - Perform required cleanup.
                __s_native__marshaller.Free();
            }

            return __retVal;
            // Local P/Invoke
            [System.Runtime.InteropServices.DllImportAttribute("msvcrt.dll", EntryPoint = "puts", ExactSpelling = true)]
            [System.Runtime.InteropServices.UnmanagedCallConvAttribute(CallConvs = new System.Type[]{typeof(global::System.Runtime.CompilerServices.CallConvCdecl)})]
            static extern unsafe int __PInvoke(byte* s);
        }
    }
}
namespace Test
{
    internal static unsafe partial class NativeMethods
    {
        [System.Runtime.InteropServices.DllImportAttribute("msvcrt.dll", EntryPoint = "memcmp", ExactSpelling = true)]
        internal static unsafe extern partial int memcmp(byte* buffer1, byte* buffer2, uint count);
    }
}

putsの場合は、最終的にP/Invokeを行う__PInvokeメソッドにUnmanagedCallConvAttributeが適用されていることが分かります。

memcmpの場合は、一見UnmanagedCallConvAttributeが適用されていないように見えます。しかし実際は、partialメソッドそのものにDllImportAttributeが適用されているため、C#言語仕様のAttributes21.2.3 Positional and named parametersThe attributes of a type declared in multiple parts are determined by combiningとある通り、自身で記述したUnmanagedCallConvAttributeが適用されます。そのため問題ありません。

独自マーシャラーの実装が必要な用途の例

本項目では、独自マーシャラーの実装方法と、独自マーシャラーを使用した場合の生成結果ソースについて解説します。

サンプル: 独自マーシャラーの実装方法

独自マーシャルの実装には以下の型が関わります:

これらの属性や列挙型は以下のように使用します:

using System.Runtime.InteropServices;
using System.Runtime.InteropServices.Marshalling;

namespace Test;

// NativeMarshallingAttributeが指定されている場合は、LibraryImportAttibute付きメソッドの引数でこの型が使われている場合にMarshalUsingAttributeを省略できます。
// その場合はNativeMarshallingAttributeで指定した独自マーシャラーが使用されます。
[NativeMarshalling(typeof(FooMarshaller))]
internal struct FooManaged { internal int Bar; }

// CustomMarshallerAttributeを指定して、独自マーシャラーを作成します。
// 状態が不要な場合はstatic classに、状態が必要な場合はstructにします。
// MarshalModeの値に応じて、必要なメソッドは異なります。
[CustomMarshaller(typeof(FooManaged), MarshalMode.Default, typeof(FooMarshaller))]
internal static class FooMarshaller
{
    internal struct FooUnmanaged { internal int Bar; }

    public static FooUnmanaged ConvertToUnmanaged(FooManaged managed)
    {
        // Blittableメンバーはコピーし、そうでないメンバーは適宜変換します
        return new FooUnmanaged() { Bar = managed.Bar };
    }

    public static FooManaged ConvertToManaged(FooUnmanaged unmanaged)
    {
        // Blittableメンバーはコピーし、そうでないメンバーは適宜変換します
        return new FooManaged() { Bar = unmanaged.Bar };
    }
}

internal static partial class NativeMethods
{
    // FooManaged型にはNativeMarshallerAttributeが適用されているため、引数でのMarshalUsingAttribute指定は任意です。
    [LibraryImport("Sample")]
    internal static partial void Test1(FooManaged managed);

    // MarshalUsingAttributeを明記することも出来ます。
    // もしFooManaged型にNativeMarshallerAttributeが適用されていない場合は、MarshalUsingAttribute指定は必須です。
    [LibraryImport("Sample")]
    internal static partial void Test2([MarshalUsing(typeof(FooMarshaller))] FooManaged managed);
}

なお、CustomMarshallerAttributeの第3引数の型にカーソルを合わせてQuick Actionを使えば、「エラーは出なくなる」メソッド定義を生成できます:

Quick Actionでエラーが消える最低限のメソッド定義を生成する例

ただしほとんどの場合に、Unmanaged側はnintではなく独自のBlittable型に変更する必要があるでしょう。また、必要に応じてFree(Unmanaged)メソッド等も追加する必要があるでしょう。

なお、Quick Actionで生成されるメソッドは、CustomMarshallerAttributeの第2引数で指定するMarshalModeの値で変化します。いろいろ試してみると学習が進みやすいと思います。

ちなみにですが、MSDNに独自マーシャラー実装関連の記事が存在します:

説明はあるのですが、残念ながら肝心のサンプルコードのメソッド内容の多くがthrow new NotImplementedExceptionになっています。実践的なサンプルコードになれば確実に参考になると思うので、改善されることを期待しています。また、サンプルコードを見るにList<T>をマーシャリングできるようで、夢が広がりそうです。(とはいえSpan<T>のマーシャリングで大体事足りるかも?)

Blittableな値型の独自マーシャリングはボイラープレートになってしまう

値型を独自マーシャリングする場合は、マーシャリング対象の型と、Bittable型の間を相互変換する形になります。正直サイズを合わせてひたすらコピーするだけです。UnsafeMemoryMarshal型のメソッドで多少は楽ができますが、どうしても手間を感じてしまいます:

using System;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.Marshalling;

namespace Test;

[CustomMarshaller(typeof(Guid), MarshalMode.Default, typeof(GuidMarshaller))]
internal static class GuidMarshaller
{
    internal unsafe struct GuidUnmanaged
    {
        internal fixed byte data[16];
    }

    static unsafe GuidMarshaller()
    {
        if (sizeof(Guid) != sizeof(GuidUnmanaged))
        {
            throw new InvalidOperationException();
        }
    }

    [SkipLocalsInit]
    public static GuidUnmanaged ConvertToUnmanaged(in Guid managed)
    {
        Unsafe.SkipInit<GuidUnmanaged>(out var unmanaged);
        var source = MemoryMarshal.AsBytes(MemoryMarshal.CreateSpan(ref Unsafe.AsRef(managed), 1));
        var destination = MemoryMarshal.AsBytes(MemoryMarshal.CreateSpan(ref unmanaged, 1));
        source.CopyTo(destination);
        return unmanaged;
    }

    [SkipLocalsInit]
    public static Guid ConvertToManaged(in GuidUnmanaged unmanaged)
    {
        Unsafe.SkipInit<Guid>(out var managed);
        var source = MemoryMarshal.AsBytes(MemoryMarshal.CreateSpan(ref Unsafe.AsRef(unmanaged), 1));
        var destination = MemoryMarshal.AsBytes(MemoryMarshal.CreateSpan(ref Unsafe.AsRef(managed), 1));
        source.CopyTo(destination);
        return managed;
    }

    // 値型をマーシャリングする場合は、残念ながらGetPinnableReferenceは呼び出されないようです
    public static ref readonly byte GetPinnableReference(in Guid guid)
    {
        return ref Unsafe.As<Guid, byte>(ref Unsafe.AsRef(in guid));
    }
}

internal static partial class NativeMethods
{
    [LibraryImport("Ole32.dll", EntryPoint = "IsEqualGUID")]
    [return: MarshalAs(UnmanagedType.Bool)]
    internal static unsafe partial bool IsEqualGUID_UsePointer(
        Guid* rguid1,
        Guid* rguid2);

    [LibraryImport("Ole32.dll", EntryPoint = "IsEqualGUID")]
    [return: MarshalAs(UnmanagedType.Bool)]
    internal static partial bool IsEqualGUID_UseInRef(
        [MarshalUsing(typeof(GuidMarshaller))] in Guid rguid1,
        [MarshalUsing(typeof(GuidMarshaller))] ref Guid rguid2);

    [LibraryImport("Ole32.dll", SetLastError = true)]
    internal static partial int IIDFromString(
        [MarshalAs(UnmanagedType.LPWStr)] string lpsz,
        [MarshalUsing(typeof(GuidMarshaller))] out Guid pclsid);
}
// <auto-generated/>
namespace Test
{
    internal static unsafe partial class NativeMethods
    {
        [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Interop.LibraryImportGenerator", "7.0.7.6804")]
        [System.Runtime.CompilerServices.SkipLocalsInitAttribute]
        internal static unsafe partial bool IsEqualGUID_UsePointer(global::System.Guid* rguid1, global::System.Guid* rguid2)
        {
            bool __retVal;
            int __retVal_native;
            {
                __retVal_native = __PInvoke(rguid1, rguid2);
            }

            // Unmarshal - Convert native data to managed data.
            __retVal = __retVal_native != 0;
            return __retVal;
            // Local P/Invoke
            [System.Runtime.InteropServices.DllImportAttribute("Ole32.dll", EntryPoint = "IsEqualGUID", ExactSpelling = true)]
            static extern unsafe int __PInvoke(global::System.Guid* rguid1, global::System.Guid* rguid2);
        }
    }
}
namespace Test
{
    internal static unsafe partial class NativeMethods
    {
        [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Interop.LibraryImportGenerator", "7.0.7.6804")]
        [System.Runtime.CompilerServices.SkipLocalsInitAttribute]
        internal static partial bool IsEqualGUID_UseInRef(in global::System.Guid rguid1, ref global::System.Guid rguid2)
        {
            global::Test.GuidMarshaller.GuidUnmanaged __rguid1_native;
            global::Test.GuidMarshaller.GuidUnmanaged __rguid2_native;
            bool __retVal;
            int __retVal_native;
            // Marshal - Convert managed data to native data.
            __rguid1_native = global::Test.GuidMarshaller.ConvertToUnmanaged(rguid1);
            __rguid2_native = global::Test.GuidMarshaller.ConvertToUnmanaged(rguid2);
            {
                __retVal_native = __PInvoke(&__rguid1_native, &__rguid2_native);
            }

            // Unmarshal - Convert native data to managed data.
            __retVal = __retVal_native != 0;
            rguid2 = global::Test.GuidMarshaller.ConvertToManaged(__rguid2_native);
            return __retVal;
            // Local P/Invoke
            [System.Runtime.InteropServices.DllImportAttribute("Ole32.dll", EntryPoint = "IsEqualGUID", ExactSpelling = true)]
            static extern unsafe int __PInvoke(global::Test.GuidMarshaller.GuidUnmanaged* rguid1, global::Test.GuidMarshaller.GuidUnmanaged* rguid2);
        }
    }
}
namespace Test
{
    internal static unsafe partial class NativeMethods
    {
        [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Interop.LibraryImportGenerator", "7.0.7.6804")]
        [System.Runtime.CompilerServices.SkipLocalsInitAttribute]
        internal static partial int IIDFromString(string lpsz, out global::System.Guid pclsid)
        {
            int __lastError;
            System.Runtime.CompilerServices.Unsafe.SkipInit(out pclsid);
            global::Test.GuidMarshaller.GuidUnmanaged __pclsid_native;
            int __retVal;
            // Pin - Pin data in preparation for calling the P/Invoke.
            fixed (void* __lpsz_native = &global::System.Runtime.InteropServices.Marshalling.Utf16StringMarshaller.GetPinnableReference(lpsz))
            {
                System.Runtime.InteropServices.Marshal.SetLastSystemError(0);
                __retVal = __PInvoke((ushort*)__lpsz_native, &__pclsid_native);
                __lastError = System.Runtime.InteropServices.Marshal.GetLastSystemError();
            }

            // Unmarshal - Convert native data to managed data.
            pclsid = global::Test.GuidMarshaller.ConvertToManaged(__pclsid_native);
            System.Runtime.InteropServices.Marshal.SetLastPInvokeError(__lastError);
            return __retVal;
            // Local P/Invoke
            [System.Runtime.InteropServices.DllImportAttribute("Ole32.dll", EntryPoint = "IIDFromString", ExactSpelling = true)]
            static extern unsafe int __PInvoke(ushort* lpsz, global::Test.GuidMarshaller.GuidUnmanaged* pclsid);
        }
    }
}

ref引数やout引数、in引数それぞれについて、MarshalUsingAttributeで指定した型のConvertToUnmanagedConvertToManagedが適切に呼び出されていることが分かります。

ただし、Managed型とUnmanaged型との相互変換のために、どうしてもコピーが必要になります。効率を求める場合は、DllImportAttribute適用時点で最初からポインター型でやり取りする必要がありそうです。

メンバーに文字列等のマーシャリングを含む場合は手動でそれぞれマーシャリングが必要

大量のボイラープレートが必要になります……:

using System;
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.Marshalling;

namespace Test;

[NativeMarshalling(typeof(STARTUPINFOUnicodeMarshaller))]
internal struct STARTUPINFO
{
    internal int cb;
    internal string? lpReserved;
    internal string? lpDesktop;
    internal string? lpTitle;
    internal int dwX;
    internal int dwY;
    internal int dwXSize;
    internal int dwYSize;
    internal int dwXCountChars;
    internal int dwYCountChars;
    internal int dwFillAttribute;
    internal int dwFlags;
    internal short wShowWindow;
    internal short cbReserved2;
    internal IntPtr lpReserved2;
    internal IntPtr hStdInput;
    internal IntPtr hStdOutput;
    internal IntPtr hStdError;
}

[CustomMarshaller(typeof(STARTUPINFO), MarshalMode.Default, typeof(STARTUPINFOUnicodeMarshaller))]
internal unsafe ref struct STARTUPINFOUnicodeMarshaller
{
    internal ref struct STARTUPINFOUnmanaged
    {
        internal int cb;
        internal ushort* lpReserved;
        internal ushort* lpDesktop;
        internal ushort* lpTitle;
        internal int dwX;
        internal int dwY;
        internal int dwXSize;
        internal int dwYSize;
        internal int dwXCountChars;
        internal int dwYCountChars;
        internal int dwFillAttribute;
        internal int dwFlags;
        internal short wShowWindow;
        internal short cbReserved2;
        internal IntPtr lpReserved2;
        internal IntPtr hStdInput;
        internal IntPtr hStdOutput;
        internal IntPtr hStdError;
    }

    private bool _allocated;
    private STARTUPINFOUnmanaged _unmanaged;
    private STARTUPINFO _managed;

    public void FromManaged(in STARTUPINFO managed)
    {
        _allocated = true;
        _unmanaged = new STARTUPINFOUnmanaged()
        {
            cb = managed.cb,
            lpReserved = Utf16StringMarshaller.ConvertToUnmanaged(managed.lpReserved),
            lpDesktop = Utf16StringMarshaller.ConvertToUnmanaged(managed.lpDesktop),
            lpTitle = Utf16StringMarshaller.ConvertToUnmanaged(managed.lpTitle),
            dwX = managed.dwX,
            dwY = managed.dwY,
            dwXSize = managed.dwXSize,
            dwYSize = managed.dwYSize,
            dwXCountChars = managed.dwXCountChars,
            dwYCountChars = managed.dwYCountChars,
            dwFlags = managed.dwFlags,
            wShowWindow = managed.wShowWindow,
            cbReserved2 = managed.cbReserved2,
            lpReserved2 = managed.lpReserved2,
            hStdInput = managed.hStdInput,
            hStdOutput = managed.hStdOutput,
            hStdError = managed.hStdError,
        };
    }

    public STARTUPINFOUnmanaged ToUnmanaged() => _unmanaged;

    public void FromUnmanaged(in STARTUPINFOUnmanaged unmanaged)
    {
        _managed = new STARTUPINFO()
        {
            cb = unmanaged.cb,
            lpReserved = Utf16StringMarshaller.ConvertToManaged(unmanaged.lpReserved),
            lpDesktop = Utf16StringMarshaller.ConvertToManaged(unmanaged.lpDesktop),
            lpTitle = Utf16StringMarshaller.ConvertToManaged(unmanaged.lpTitle),
            dwX = unmanaged.dwX,
            dwY = unmanaged.dwY,
            dwXSize = unmanaged.dwXSize,
            dwYSize = unmanaged.dwYSize,
            dwXCountChars = unmanaged.dwXCountChars,
            dwYCountChars = unmanaged.dwYCountChars,
            dwFlags = unmanaged.dwFlags,
            wShowWindow = unmanaged.wShowWindow,
            cbReserved2 = unmanaged.cbReserved2,
            lpReserved2 = unmanaged.lpReserved2,
            hStdInput = unmanaged.hStdInput,
            hStdOutput = unmanaged.hStdOutput,
            hStdError = unmanaged.hStdError,
        };
    }

    public STARTUPINFO ToManaged() => _managed;

    public void Free()
    {
        // ConvertToUnmanagedを使っていた場合にだけ解放する必要があります。
        if (_allocated)
        {
            Utf16StringMarshaller.Free(_unmanaged.lpReserved);
            Utf16StringMarshaller.Free(_unmanaged.lpDesktop);
            Utf16StringMarshaller.Free(_unmanaged.lpTitle);
        }
    }
}

internal static partial class NativeMethods
{
    [LibraryImport("Kernel32.dll", EntryPoint = "GetStartupInfoW")]
    internal static partial void GetStartupInfo(out STARTUPINFO lpStartupInfo);
}
namespace Test
{
    internal static unsafe partial class NativeMethods
    {
        [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Interop.LibraryImportGenerator", "7.0.7.6804")]
        [System.Runtime.CompilerServices.SkipLocalsInitAttribute]
        internal static partial void GetStartupInfo(out global::Test.STARTUPINFO lpStartupInfo)
        {
            System.Runtime.CompilerServices.Unsafe.SkipInit(out lpStartupInfo);
            global::Test.STARTUPINFOUnicodeMarshaller.STARTUPINFOUnmanaged __lpStartupInfo_native = default;
            // Setup - Perform required setup.
            global::Test.STARTUPINFOUnicodeMarshaller __lpStartupInfo_native__marshaller = new();
            try
            {
                {
                    __PInvoke(&__lpStartupInfo_native);
                }

                // UnmarshalCapture - Capture the native data into marshaller instances in case conversion to managed data throws an exception.
                __lpStartupInfo_native__marshaller.FromUnmanaged(__lpStartupInfo_native);
                // Unmarshal - Convert native data to managed data.
                lpStartupInfo = __lpStartupInfo_native__marshaller.ToManaged();
            }
            finally
            {
                // Cleanup - Perform required cleanup.
                __lpStartupInfo_native__marshaller.Free();
            }

            // Local P/Invoke
            [System.Runtime.InteropServices.DllImportAttribute("Kernel32.dll", EntryPoint = "GetStartupInfoW", ExactSpelling = true)]
            static extern unsafe void __PInvoke(global::Test.STARTUPINFOUnicodeMarshaller.STARTUPINFOUnmanaged* lpStartupInfo);
        }
    }
}

static classな独自マーシャラーではネイティブAPIから返ったBlittable構造体についてFreeを呼び出すべきかどうかが判断できないように思ったので、structな独自マーシャラーにして状態を持たせています。string型をUtf16StringMarshallerで変換するだけの独自マーシャラーなら十分自動生成できそうです。このような独自マーシャラーを生成してくれるソースジェネレーターが欲しくなりました。

参照型の独自マーシャラーではGetPinnableReferenceがあれば呼ばれる模様

using System;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.Marshalling;

namespace Test;

// この例ではclassであることに注目してください。
[StructLayout(LayoutKind.Sequential)]
internal class SYSTEMTIME
{
    internal short wYear;
    internal short wMonth;
    internal short wDayOfWeek;
    internal short wDay;
    internal short wHour;
    internal short wMinute;
    internal short wSecond;
    internal short wMilliseconds;
}

[CustomMarshaller(typeof(SYSTEMTIME), MarshalMode.Default, typeof(ClassSystemTimeMarshallerWithPinnableReference))]
internal static class ClassSystemTimeMarshallerWithPinnableReference
{
    // 今回の例ではConvertToUnmanaged/Managedは使われないので手抜きをしています。
    public static nint ConvertToUnmanaged(SYSTEMTIME managed) => throw new NotSupportedException();

    public static SYSTEMTIME ConvertToManaged(nint unmanaged) => throw new NotSupportedException();

    public static ref short GetPinnableReference(SYSTEMTIME managed) => ref managed.wYear;
}

[CustomMarshaller(typeof(SYSTEMTIME), MarshalMode.Default, typeof(ClassSystemTimeMarshallerWithoutPinnableReference))]
internal static class ClassSystemTimeMarshallerWithoutPinnableReference
{
    internal unsafe struct SYSTEMTIMEUnmanaged { public fixed short data[8]; }

    unsafe static ClassSystemTimeMarshallerWithoutPinnableReference()
    {
        // sizeof(参照型)はsizeof(IntPtr)の値と同じになるので、アンマネージ領域サイズをM取れるarshal.SizeOfが必要です。
        if (Marshal.SizeOf<SYSTEMTIME>() != sizeof(SYSTEMTIMEUnmanaged))
        {
            throw new InvalidOperationException();
        }
    }

    public static SYSTEMTIMEUnmanaged ConvertToUnmanaged(SYSTEMTIME managed)
    {
        return Unsafe.As<short, SYSTEMTIMEUnmanaged>(ref managed.wYear);
    }

    public static SYSTEMTIME ConvertToManaged(SYSTEMTIMEUnmanaged unmanaged)
    {
        var result = new SYSTEMTIME();
        Unsafe.As<short, SYSTEMTIMEUnmanaged>(ref result.wYear) = unmanaged;
        return result;
    }
}

internal static partial class NativeMethods
{
    // GetPinnableReferenceがあればポインターに変換してネイティブAPIへ渡してくれます。
    [LibraryImport("Kernel32.dll", EntryPoint = "GetLocalTime")]
    internal static partial void GetLocalTime_WithPinnableReference(
        [MarshalUsing(typeof(ClassSystemTimeMarshallerWithPinnableReference))] SYSTEMTIME lpSystemTime);

    // GetPinnableReferenceがなく、かつrefやoutの指定等がない場合はConvertToManagedが呼ばれないため、取得結果が反映されません。
    [LibraryImport("Kernel32.dll", EntryPoint = "GetLocalTime")]
    internal static partial void GetLocalTime_WithoutPinnableReference_Bad(
        [MarshalUsing(typeof(ClassSystemTimeMarshallerWithoutPinnableReference))] SYSTEMTIME lpSystemTime);

    // GetPinnableReferenceがなく、かつrefやoutの指定がある場合はConvertToManagedが呼ばれるため、正しく取得結果が反映されます。
    [LibraryImport("Kernel32.dll", EntryPoint = "GetLocalTime")]
    internal static partial void GetLocalTime_WithoutPinnableReference_Good(
        [MarshalUsing(typeof(ClassSystemTimeMarshallerWithoutPinnableReference))] out SYSTEMTIME lpSystemTime);
}
// <auto-generated/>
namespace Test
{
    internal static unsafe partial class NativeMethods
    {
        [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Interop.LibraryImportGenerator", "7.0.7.6804")]
        [System.Runtime.CompilerServices.SkipLocalsInitAttribute]
        internal static partial void GetLocalTime_WithPinnableReference(global::Test.SYSTEMTIME lpSystemTime)
        {
            // Pin - Pin data in preparation for calling the P/Invoke.
            fixed (void* __lpSystemTime_native = &global::Test.ClassSystemTimeMarshallerWithPinnableReference.GetPinnableReference(lpSystemTime))
            {
                __PInvoke((nint)__lpSystemTime_native);
            }

            // Local P/Invoke
            [System.Runtime.InteropServices.DllImportAttribute("Kernel32.dll", EntryPoint = "GetLocalTime", ExactSpelling = true)]
            static extern unsafe void __PInvoke(nint lpSystemTime);
        }
    }
}
namespace Test
{
    internal static unsafe partial class NativeMethods
    {
        [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Interop.LibraryImportGenerator", "7.0.7.6804")]
        [System.Runtime.CompilerServices.SkipLocalsInitAttribute]
        internal static partial void GetLocalTime_WithoutPinnableReference_Bad(global::Test.SYSTEMTIME lpSystemTime)
        {
            global::Test.ClassSystemTimeMarshallerWithoutPinnableReference.SYSTEMTIMEUnmanaged __lpSystemTime_native;
            // Marshal - Convert managed data to native data.
            __lpSystemTime_native = global::Test.ClassSystemTimeMarshallerWithoutPinnableReference.ConvertToUnmanaged(lpSystemTime);
            {
                __PInvoke(__lpSystemTime_native);
            }

            // Local P/Invoke
            [System.Runtime.InteropServices.DllImportAttribute("Kernel32.dll", EntryPoint = "GetLocalTime", ExactSpelling = true)]
            static extern unsafe void __PInvoke(global::Test.ClassSystemTimeMarshallerWithoutPinnableReference.SYSTEMTIMEUnmanaged lpSystemTime);
        }
    }
}
namespace Test
{
    internal static unsafe partial class NativeMethods
    {
        [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Interop.LibraryImportGenerator", "7.0.7.6804")]
        [System.Runtime.CompilerServices.SkipLocalsInitAttribute]
        internal static partial void GetLocalTime_WithoutPinnableReference_Good(out global::Test.SYSTEMTIME lpSystemTime)
        {
            System.Runtime.CompilerServices.Unsafe.SkipInit(out lpSystemTime);
            global::Test.ClassSystemTimeMarshallerWithoutPinnableReference.SYSTEMTIMEUnmanaged __lpSystemTime_native;
            {
                __PInvoke(&__lpSystemTime_native);
            }

            // Unmarshal - Convert native data to managed data.
            lpSystemTime = global::Test.ClassSystemTimeMarshallerWithoutPinnableReference.ConvertToManaged(__lpSystemTime_native);
            // Local P/Invoke
            [System.Runtime.InteropServices.DllImportAttribute("Kernel32.dll", EntryPoint = "GetLocalTime", ExactSpelling = true)]
            static extern unsafe void __PInvoke(global::Test.ClassSystemTimeMarshallerWithoutPinnableReference.SYSTEMTIMEUnmanaged* lpSystemTime);
        }
    }
}

ClassSystemTimeMarshallerWithPinnableReferenceでは、GetPinnableReference経由でfixedされていることが分かります。一方でClassSystemTimeMarshallerWithoutPinnableReferenceでは、classの値渡しをしている場合では、Unmanagedへ変換されるだけで、取得結果をManagedへ戻していないことがわかります。classであったとしても、適切にin、out、refを指定する必要があることが分かります。

おまけ: 生成ソースから呼び出される独自マーシャラーの各種メソッド色々乗せ

runtime/UserTypeMarshallingV2.md at main · dotnet/runtimeに、独自マーシャラーが満たすべき要件が記述されています。各メソッドが例外を送出可能かどうかも記載があるので、一読をおすすめします。また、本記事では扱いませんがLinear (Array-like) Collection Marshaller Shapesについての言及もあります(Span<T>のデフォルトマーシャリングで事足りる可能性はあります)。

当該記事を見ながら、Value Marshaller Shapesについて色々試しました。ただ、with Caller Allocated Buffer版でref引数を使った場合の生成ソースが明らかにおかしいもの(報告済み)だったりしています。GetPinnableReferenceが呼び出されなかったり生成結果コードがエラーを起こしたりするものは、自分の書き方が変な可能性を捨てきれないので未報告です……:

using System;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Runtime.InteropServices.Marshalling;

namespace Test;

internal struct Managed { /* 簡単のためにメンバーなしです */ }
internal struct Unmanaged { /* 簡単のためにメンバーなしです */ }

[CustomMarshaller(typeof(Managed), MarshalMode.Default, typeof(StatelessStructWithoutBufferMarshaller))]
internal static class StatelessStructWithoutBufferMarshaller
{
    // 値渡し、in引数、ref引数に使用されます
    public static Unmanaged ConvertToUnmanaged(Managed managed) => new();
    // out引数、ref引数に使用されます
    public static Managed ConvertToManaged(Unmanaged unmanaged) => new();
    // out引数、ref引数に使用されます
    public static Managed ConvertToManagedFinally(Unmanaged unmanaged) => new();
    // 値渡し、in引数、out引数、ref引数の全てに使用されます
    public static void Free(Unmanaged unmanaged) { }
}

// 「Stateless Managed->Unmanaged」
// 「Stateless Managed->Unmanaged with Caller-Allocated Buffer」
// 「Stateless Unmanaged->Managed」
// 「Stateless Unmanaged->Managed with Guaranteed Unmarshalling」
[CustomMarshaller(typeof(Managed), MarshalMode.Default, typeof(StatelessStructWithBufferMarshaller))]
internal static class StatelessStructWithBufferMarshaller
{
    // ソース生成処理としては、ConvertToUnmanaged(ValueManaged , Span<byte>)が存在する場合に、BufferSizeを取得しようとする処理が生成されるようです
    // 値渡し、in引数用のstackalloc byteのサイズとして使用されます
    public static int BufferSize => 16;
    // ref引数に使用されます(MSDNドキュメントには記載がないので仕様かどうか不明、struct版のref引数の動作が怪しかったのでそちらは報告している、ついでに備考として本現象も記述はしました)
    public static Unmanaged ConvertToUnmanaged(Managed managed) => new();
    // 値渡し、in引数に使用されます
    public static Unmanaged ConvertToUnmanaged(Managed managed, Span<byte> callerAllocatedBuffer) => new();
    // out引数、ref引数に使用されます
    public static Managed ConvertToManaged(Unmanaged unmanaged) => new();
    // out引数、ref引数に使用されます
    public static Managed ConvertToManagedFinally(Unmanaged unmanaged) => new();
    // 値渡し、in引数、out引数、ref引数の全てに使用されます
    public static void Free(Unmanaged unmanaged) { }
}

// 「Stateful Managed->Unmanaged」
// 「Stateful Unmanaged->Managed」
// 「Stateful Unmanaged->Managed with Guaranteed Unmarshalling」
[CustomMarshaller(typeof(Managed), MarshalMode.Default, typeof(StatefullStructWithoutBufferMarshaller))]
internal struct StatefullStructWithoutBufferMarshaller
{
    // 値渡し、in引数、ref引数に使用されます
    public void FromManaged(Managed managed) { }
    // UserTypeMarshallingV2では記載がありますが、使用される条件が分かりませんでした……
    public ref byte GetPinnableReference() => ref Unsafe.NullRef<byte>();
    // これがあると、値渡しのマーシャリング時にvoid*をStructUnmanagedへ変換しようとしてエラーを起こします……
    // public static ref StructUnmanaged GetPinnableReference(StructManaged managed) => ref Unsafe.NullRef<StructUnmanaged>();
    // 値渡し、in引数、ref引数に使用されます
    public Unmanaged ToUnmanaged() => new();
    // 値渡し、in引数、ref引数に使用されます
    public void OnInvoked() { }
    // out引数、ref引数に使用されます
    public void FromUnmanaged(Unmanaged unmanaged) { }
    // out引数、ref引数に使用されます
    public Managed ToManaged() => new();
    // out引数、ref引数に使用されます
    public Managed ToManagedFinally() => new();
    // 値渡し、in引数、out引数、ref引数の全てに使用されます
    public void Free() { }
}

// 「Stateful Managed->Unmanaged with Caller Allocated Buffer」
// 「Stateful Unmanaged->Managed」
// 「Stateful Unmanaged->Managed with Guaranteed Unmarshalling」
[CustomMarshaller(typeof(Managed), MarshalMode.Default, typeof(StatefullStructWithBufferMarshaller))]
internal struct StatefullStructWithBufferMarshaller
{
    // 値渡し、in引数用のstackalloc byteのサイズとして使用されます
    public static int BufferSize => 16;
    // 値渡し、in引数に使用されます。ref引数で使用されないのはバグに見えたので報告済みです。
    public void FromManaged(Managed managed, Span<byte> buffer) { }
    // UserTypeMarshallingV2では記載がありますが、使用される条件が分かりませんでした……
    public ref byte GetPinnableReference() => ref Unsafe.NullRef<byte>();
    // これがあると、値渡しのマーシャリング時にvoid*をStructUnmanagedへ変換しようとしてエラーを起こします……
    // public static ref StructUnmanaged GetPinnableReference(StructManaged managed) => ref Unsafe.NullRef<StructUnmanaged>();
    // 値渡し、in引数、ref引数に使用されます
    public Unmanaged ToUnmanaged() => new();
    // 値渡し、in引数、ref引数に使用されます
    public void OnInvoked() { }
    // out引数、ref引数に使用されます
    public void FromUnmanaged(Unmanaged unmanaged) { }
    // out引数、ref引数に使用されます
    public Managed ToManaged() => new();
    // out引数、ref引数に使用されます
    public Managed ToManagedFinally() => new();
    // 値渡し、in引数、out引数、ref引数の全てに使用されます
    public void Free() { }
}

internal static partial class NativeMethods
{
    // 1: static class、BufferSizeプロパティなし
    // 2: static class、BufferSizeプロパティあり
    // 3: struct, BufferSizeプロパティなし
    // 4: struct, BufferSizeプロパティなし
    [LibraryImport("Sample")]
    internal static partial void Sample(
        [MarshalUsing(typeof(StatelessStructWithoutBufferMarshaller))] Managed value1,
        [MarshalUsing(typeof(StatelessStructWithoutBufferMarshaller))] in Managed in1,
        [MarshalUsing(typeof(StatelessStructWithoutBufferMarshaller))] out Managed out1,
        [MarshalUsing(typeof(StatelessStructWithoutBufferMarshaller))] ref Managed ref1,
        [MarshalUsing(typeof(StatelessStructWithBufferMarshaller))] Managed value2,
        [MarshalUsing(typeof(StatelessStructWithBufferMarshaller))] in Managed in2,
        [MarshalUsing(typeof(StatelessStructWithBufferMarshaller))] out Managed out2,
        [MarshalUsing(typeof(StatelessStructWithBufferMarshaller))] ref Managed ref2,
        [MarshalUsing(typeof(StatefullStructWithoutBufferMarshaller))] Managed value3,
        [MarshalUsing(typeof(StatefullStructWithoutBufferMarshaller))] in Managed in3,
        [MarshalUsing(typeof(StatefullStructWithoutBufferMarshaller))] out Managed out3,
        [MarshalUsing(typeof(StatefullStructWithoutBufferMarshaller))] ref Managed ref3,
        [MarshalUsing(typeof(StatefullStructWithBufferMarshaller))] Managed value4,
        [MarshalUsing(typeof(StatefullStructWithBufferMarshaller))] in Managed in4,
        [MarshalUsing(typeof(StatefullStructWithBufferMarshaller))] out Managed out4,
        [MarshalUsing(typeof(StatefullStructWithBufferMarshaller))] ref Managed ref4);
}
// <auto-generated/>
namespace Test
{
    internal static unsafe partial class NativeMethods
    {
        [System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.Interop.LibraryImportGenerator", "7.0.7.6804")]
        [System.Runtime.CompilerServices.SkipLocalsInitAttribute]
        internal static partial void Sample(global::Test.Managed value1, in global::Test.Managed in1, out global::Test.Managed out1, ref global::Test.Managed ref1, global::Test.Managed value2, in global::Test.Managed in2, out global::Test.Managed out2, ref global::Test.Managed ref2, global::Test.Managed value3, in global::Test.Managed in3, out global::Test.Managed out3, ref global::Test.Managed ref3, global::Test.Managed value4, in global::Test.Managed in4, out global::Test.Managed out4, ref global::Test.Managed ref4)
        {
            bool __invokeSucceeded = default;
            System.Runtime.CompilerServices.Unsafe.SkipInit(out out1);
            System.Runtime.CompilerServices.Unsafe.SkipInit(out out2);
            System.Runtime.CompilerServices.Unsafe.SkipInit(out out3);
            System.Runtime.CompilerServices.Unsafe.SkipInit(out out4);
            global::Test.Unmanaged __value1_native = default;
            global::Test.Unmanaged __in1_native = default;
            global::Test.Unmanaged __out1_native = default;
            global::Test.Unmanaged __ref1_native = default;
            global::Test.Unmanaged __value2_native = default;
            global::Test.Unmanaged __in2_native = default;
            global::Test.Unmanaged __out2_native = default;
            global::Test.Unmanaged __ref2_native = default;
            global::Test.Unmanaged __value3_native = default;
            global::Test.Unmanaged __in3_native = default;
            global::Test.Unmanaged __out3_native = default;
            global::Test.Unmanaged __ref3_native = default;
            global::Test.Unmanaged __value4_native = default;
            global::Test.Unmanaged __in4_native = default;
            global::Test.Unmanaged __out4_native = default;
            global::Test.Unmanaged __ref4_native = default;
            // Setup - Perform required setup.
            global::Test.StatefullStructWithoutBufferMarshaller __value3_native__marshaller = new();
            global::Test.StatefullStructWithoutBufferMarshaller __in3_native__marshaller = new();
            global::Test.StatefullStructWithoutBufferMarshaller __out3_native__marshaller = new();
            global::Test.StatefullStructWithoutBufferMarshaller __ref3_native__marshaller = new();
            global::Test.StatefullStructWithBufferMarshaller __value4_native__marshaller = new();
            global::Test.StatefullStructWithBufferMarshaller __in4_native__marshaller = new();
            global::Test.StatefullStructWithBufferMarshaller __out4_native__marshaller = new();
            global::Test.StatefullStructWithBufferMarshaller __ref4_native__marshaller = new();
            try
            {
                // Marshal - Convert managed data to native data.
                __value1_native = global::Test.StatelessStructWithoutBufferMarshaller.ConvertToUnmanaged(value1);
                __in1_native = global::Test.StatelessStructWithoutBufferMarshaller.ConvertToUnmanaged(in1);
                __ref1_native = global::Test.StatelessStructWithoutBufferMarshaller.ConvertToUnmanaged(ref1);
                System.Span<byte> __value2_native__buffer = stackalloc byte[global::Test.StatelessStructWithBufferMarshaller.BufferSize];
                __value2_native = global::Test.StatelessStructWithBufferMarshaller.ConvertToUnmanaged(value2, __value2_native__buffer);
                System.Span<byte> __in2_native__buffer = stackalloc byte[global::Test.StatelessStructWithBufferMarshaller.BufferSize];
                __in2_native = global::Test.StatelessStructWithBufferMarshaller.ConvertToUnmanaged(in2, __in2_native__buffer);
                __ref2_native = global::Test.StatelessStructWithBufferMarshaller.ConvertToUnmanaged(ref2);
                __value3_native__marshaller.FromManaged(value3);
                __in3_native__marshaller.FromManaged(in3);
                __ref3_native__marshaller.FromManaged(ref3);
                byte* __value4_native__stackptr = stackalloc byte[global::Test.StatefullStructWithBufferMarshaller.BufferSize];
                __value4_native__marshaller.FromManaged(value4, new System.Span<byte>(__value4_native__stackptr, global::Test.StatefullStructWithBufferMarshaller.BufferSize));
                byte* __in4_native__stackptr = stackalloc byte[global::Test.StatefullStructWithBufferMarshaller.BufferSize];
                __in4_native__marshaller.FromManaged(in4, new System.Span<byte>(__in4_native__stackptr, global::Test.StatefullStructWithBufferMarshaller.BufferSize));
                // Pin - Pin data in preparation for calling the P/Invoke.
                fixed (void* __value3_native__unused = __value3_native__marshaller)
                fixed (void* __in3_native__unused = __in3_native__marshaller)
                fixed (void* __value4_native__unused = __value4_native__marshaller)
                fixed (void* __in4_native__unused = __in4_native__marshaller)
                {
                    // PinnedMarshal - Convert managed data to native data that requires the managed data to be pinned.
                    __value3_native = __value3_native__marshaller.ToUnmanaged();
                    __in3_native = __in3_native__marshaller.ToUnmanaged();
                    __ref3_native = __ref3_native__marshaller.ToUnmanaged();
                    __value4_native = __value4_native__marshaller.ToUnmanaged();
                    __in4_native = __in4_native__marshaller.ToUnmanaged();
                    __ref4_native = __ref4_native__marshaller.ToUnmanaged();
                    __PInvoke(__value1_native, &__in1_native, &__out1_native, &__ref1_native, __value2_native, &__in2_native, &__out2_native, &__ref2_native, __value3_native, &__in3_native, &__out3_native, &__ref3_native, __value4_native, &__in4_native, &__out4_native, &__ref4_native);
                }

                __invokeSucceeded = true;
                // NotifyForSuccessfulInvoke - Keep alive any managed objects that need to stay alive across the call.
                __value3_native__marshaller.OnInvoked();
                __in3_native__marshaller.OnInvoked();
                __ref3_native__marshaller.OnInvoked();
                __value4_native__marshaller.OnInvoked();
                __in4_native__marshaller.OnInvoked();
                __ref4_native__marshaller.OnInvoked();
                // UnmarshalCapture - Capture the native data into marshaller instances in case conversion to managed data throws an exception.
                __ref4_native__marshaller.FromUnmanaged(__ref4_native);
                __out4_native__marshaller.FromUnmanaged(__out4_native);
                __ref3_native__marshaller.FromUnmanaged(__ref3_native);
                __out3_native__marshaller.FromUnmanaged(__out3_native);
                // Unmarshal - Convert native data to managed data.
                ref4 = __ref4_native__marshaller.ToManaged();
                out4 = __out4_native__marshaller.ToManaged();
                ref3 = __ref3_native__marshaller.ToManaged();
                out3 = __out3_native__marshaller.ToManaged();
                ref2 = global::Test.StatelessStructWithBufferMarshaller.ConvertToManaged(__ref2_native);
                out2 = global::Test.StatelessStructWithBufferMarshaller.ConvertToManaged(__out2_native);
                ref1 = global::Test.StatelessStructWithoutBufferMarshaller.ConvertToManaged(__ref1_native);
                out1 = global::Test.StatelessStructWithoutBufferMarshaller.ConvertToManaged(__out1_native);
            }
            finally
            {
                if (__invokeSucceeded)
                {
                    // GuaranteedUnmarshal - Convert native data to managed data even in the case of an exception during the non-cleanup phases.
                    ref4 = __ref4_native__marshaller.ToManagedFinally();
                    out4 = __out4_native__marshaller.ToManagedFinally();
                    ref3 = __ref3_native__marshaller.ToManagedFinally();
                    out3 = __out3_native__marshaller.ToManagedFinally();
                    ref2 = global::Test.StatelessStructWithBufferMarshaller.ConvertToManagedFinally(__ref2_native);
                    out2 = global::Test.StatelessStructWithBufferMarshaller.ConvertToManagedFinally(__out2_native);
                    ref1 = global::Test.StatelessStructWithoutBufferMarshaller.ConvertToManagedFinally(__ref1_native);
                    out1 = global::Test.StatelessStructWithoutBufferMarshaller.ConvertToManagedFinally(__out1_native);
                }

                // Cleanup - Perform required cleanup.
                global::Test.StatelessStructWithoutBufferMarshaller.Free(__value1_native);
                global::Test.StatelessStructWithoutBufferMarshaller.Free(__in1_native);
                global::Test.StatelessStructWithoutBufferMarshaller.Free(__out1_native);
                global::Test.StatelessStructWithoutBufferMarshaller.Free(__ref1_native);
                global::Test.StatelessStructWithBufferMarshaller.Free(__value2_native);
                global::Test.StatelessStructWithBufferMarshaller.Free(__in2_native);
                global::Test.StatelessStructWithBufferMarshaller.Free(__out2_native);
                global::Test.StatelessStructWithBufferMarshaller.Free(__ref2_native);
                __value3_native__marshaller.Free();
                __in3_native__marshaller.Free();
                __out3_native__marshaller.Free();
                __ref3_native__marshaller.Free();
                __value4_native__marshaller.Free();
                __in4_native__marshaller.Free();
                __out4_native__marshaller.Free();
                __ref4_native__marshaller.Free();
            }

            // Local P/Invoke
            [System.Runtime.InteropServices.DllImportAttribute("Sample", EntryPoint = "Sample", ExactSpelling = true)]
            static extern unsafe void __PInvoke(global::Test.Unmanaged value1, global::Test.Unmanaged* in1, global::Test.Unmanaged* out1, global::Test.Unmanaged* ref1, global::Test.Unmanaged value2, global::Test.Unmanaged* in2, global::Test.Unmanaged* out2, global::Test.Unmanaged* ref2, global::Test.Unmanaged value3, global::Test.Unmanaged* in3, global::Test.Unmanaged* out3, global::Test.Unmanaged* ref3, global::Test.Unmanaged value4, global::Test.Unmanaged* in4, global::Test.Unmanaged* out4, global::Test.Unmanaged* ref4);
        }
    }
}

コメントも生成されているので、各メソッドが行うべき動作は大体は推測できると思います。また、P/Invokeする際に、値渡し引数は値のまま、in/out/ref引数は&演算子で取得したアドレスを渡します(=値渡し、in/ref/out引数のセマンティクス通り)。なお、試しにマーシャリング対象の型をstructからclassに変えてみましたが、生成コードは同一になるようです。__PInvokeの引数の型も(StructUnmanaged, StructUnmanaged*, StructUnmanaged*, StructUnmanaged*, 省略)のまま変化しませんでした。DllImportAttributeのclassのマーシャリングを考えると(StructUnmanaged*, StructUnmanaged**, StructUnmanaged**, StructUnmanaged**, 省略)と1段階間接参照が増える印象だったので、注意が必要そうです。

その他色々

ここまでの記述で力尽きつつあるので簡単に済ませます。ご容赦ください。

複数のManaged型に対応できるジェネリックな独自マーシャラーは現状では実装不可能?

CustomMarshallerAttribute.GenericPlaceholder Structという、非常に夢が広がりそうな型があります。現状では標準の型ではArrayMarshaller.cs等で使われているようです。

C# 11で可能になったジェネリック属性等と組み合わせたりして面白いことができるのでは、と思ったのですが試した範囲ではエラーになるものばかりでした。また、MarshalUsing does not work with genericsというissueのコメントBased on our offline conversation, using GenericPlaceholder as-is doesn’t work correctly at the moment.との記述も見つかりました。修正を待つことになりそうです。

関連するAPI

関連するAPIです。なお、一部は.NET 6に導入されたものもあります。

その他のその他

  • CustomMarshllerAttribute等を使う代わりにDisableRuntimeMarshallingAttribute Classを使う手法があるようです(出典: Disabled runtime marshalling)。ただしDisableRuntimeMarshallingAttributeの適用対象はアセンブリ単位のみであり、パラメーター単位やメソッド単位ではありません。影響範囲が大きすぎるため、CustomMarshallerAttributeを使用するほうが良いと思います。
  • MarshalUsingAttributeのメンバーには、Size関係を表すメンバーがいくつかあります。それらを使用すると面白いことができるかもしれません。

感想

軽い気持ちで書き始めたら、想像以上のボリュームになってしまいました。奥深いと言える気もしますし、今までDllImportAttributeが生成していたマーシャリングコードに大いに頼ってたとも言える気もします。

ところで本記事を書き始めた理由は「.NET 7公開から約1か月経つのに、LibraryImportAttributeの解説はGithub/runtimeとMSDNだけに見える!他の資料も必要だろう!」というものです。どうか皆様も、新しいライブラリ等を触ってみたらぜひ感想等を発信してみてください。