mcpField
D
dotnet-pinvoke

dotnet-pinvoke

github.com/dotnet开发5.8k3.7k 次安装1 天前更新
代码仓库

关于 dotnet-pinvoke

这项技能用于编写和审查调用原生库的 .NET P/Invoke 声明。它指导开发者映射签名、处理字符串与内存,并根据目标框架和需求选择 `DllImport` 或 `LibraryImport`。

主要功能

  1. 01选择 `DllImport` 或 `LibraryImport`
  2. 02映射原生类型与 .NET 类型
  3. 03处理字符串和内存所有权
  4. 04使用 `SafeHandle` 管理原生句柄
  5. 05管理回调和错误

适用场景

  1. 01根据 C 头文件编写 P/Invoke 声明
  2. 02检查签名中的类型和编码问题
  3. 03调试原生边界崩溃或数据损坏
  4. 04为 AOT 或修剪需求迁移声明

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/dotnet/skills --skill dotnet-pinvoke

安装后在 Claude Code 中输入/dotnet-pinvoke确认已加载。

附带文件references/diagnostics.mdreferences/type-mapping.md

详细介绍

.NET P/Invoke

从 .NET 调用原生代码功能强大,但容错性很低。不正确的签名、乱码字符串以及内存泄漏或被错误释放,是最常见的错误来源——它们都可能表现为间歇性崩溃、静默数据损坏,或发生在远离实际缺陷位置的访问冲突。

本技能涵盖 DllImport(自 .NET Framework 1.0 起提供)和 LibraryImport(源生成,适用于 .NET 7+)。以 .NET Framework 为目标时,始终使用 DllImport。以 .NET 7+ 为目标时,新代码优先使用 LibraryImport。如果需要原生 AOT,则只能使用 LibraryImport。

何时使用本技能

  • 根据 C/C++ 头文件编写新的 [DllImport] 或 [LibraryImport] 声明
  • 检查 P/Invoke 签名是否正确(类型大小、调用约定、字符串编码)
  • 封装整个 C 库,以便在 .NET 中使用
  • 调试原生边界处的 AccessViolationException、DllNotFoundException 或静默数据损坏
  • 将 DllImport 声明迁移到 LibraryImport,以兼容 AOT/修剪
  • 诊断涉及原生句柄或缓冲区的内存泄漏或堆损坏

停止信号

  • 只有一个函数? 映射签名(步骤 1-3),仅在相关时处理字符串/内存,并跳过工具和迁移部分。
  • 不要迁移 现有的 DllImport 到 LibraryImport,除非用户提出要求,或 AOT/修剪是明确的需求。
  • 不要推荐 CsWin32,除非目标明确是 Win32 API。
  • 不要生成回调(步骤 8),除非原生 API 要求函数指针。
  • 是代码审查请求? 使用验证清单,不要重写可正常工作的代码。

输入

输入必需描述
原生头文件或文档是C/C++ 函数签名、结构体定义、调用约定
目标框架是决定使用 DllImport 还是 LibraryImport
目标平台建议提供影响类型大小(long、size_t)和库命名
内存所有权约定是谁分配和释放每个缓冲区或句柄

Agent 行为: 当文档与原生头文件不一致时,始终以头文件为准。在线文档(包括官方 Win32 API 文档)经常省略或简化类型、调用约定和结构体布局的细节,而这些细节对正确的 P/Invoke 签名至关重要。


工作流

步骤 1:选择 DllImport 或 LibraryImport
方面DllImportLibraryImport(.NET 7+)
机制运行时封送源生成器(编译时)
兼容 AOT/修剪否是
字符串封送CharSet 枚举StringMarshalling 枚举
错误处理SetLastErrorSetLastError
可用性.NET Framework 1.0+仅限 .NET 7+
步骤 2:将原生类型映射到 .NET 类型

最危险的映射——大多数错误都由此引起:

C / Win32 类型.NET 类型原因
longCLong在 Windows 上为 32 位,在 64 位 Unix 上为 64 位。使用 LibraryImport 时,需要 [assembly: DisableRuntimeMarshalling]
size_tnuint / UIntPtr指针大小。在 .NET 8+ 上使用 nuint,在更早版本的 .NET 上使用 UIntPtr。切勿使用 ulong
BOOL(Win32)int不是 bool——Win32 的 BOOL 为 4 字节
bool(C99)[MarshalAs(UnmanagedType.U1)] bool必须指定 1 字节封送
HANDLE、HWNDSafeHandle优先于原始 IntPtr
LPWSTR / wchar_t*stringWindows 上为 UTF-16(用于 in 字符串时成本最低)。避免在跨平台代码中使用——wchar_t 的宽度由编译器定义(非 Windows 平台上通常为 UTF-32)
LPSTR / char*string必须指定编码(ANSI 或 UTF-8)。in 参数始终需要封送开销

完整的类型映射表、结构体布局和可直接封送类型规则,请参阅 references/type-mapping.md。

❌ 切勿对 C 的 long 使用 int 或 long——它在 Windows 上为 32 位,在 Unix 上为 64 位。始终使用 CLong。 ❌ 切勿对 size_t 使用 ulong——这会导致 32 位平台上的堆栈损坏。请使用 nuint 或 UIntPtr。 ❌ 切勿在不使用 MarshalAs 的情况下使用 bool——默认封送大小不正确。

步骤 3:编写声明

给定一个 C 头文件:

int32_t process_records(const Record* records, size_t count, uint32_t* out_processed);

DllImport:

[DllImport("mylib")]
private static extern int ProcessRecords(
    [In] Record[] records, UIntPtr count, out uint outProcessed);

LibraryImport:

[LibraryImport("mylib")]
internal static partial int ProcessRecords(
    [In] Record[] records, nuint count, out uint outProcessed);

只有在面向 Windows x86(32 位)时才需要指定调用约定,此时 Cdecl 与 StdCall 不同。在 x64、ARM 和 ARM64 上,调用约定只有一种,因此无需使用该特性。

Agent 行为: 如果检测到目标平台包含 Windows x86——例如通过项目属性(如 <PlatformTarget>x86</PlatformTarget>)、运行时标识符(如 win-x86)、构建脚本、注释或开发者指令——请向开发者指出这一点,并建议在所有 P/Invoke 声明中显式指定调用约定。

// DllImport (x86 targets)
[DllImport("mylib", CallingConvention = CallingConvention.Cdecl)]

// LibraryImport (x86 targets)
[LibraryImport("mylib")]
[UnmanagedCallConv(CallConvs = [typeof(CallConvCdecl)])]

如果托管方法名称与原生导出名称不同,请指定 EntryPoint,以避免 EntryPointNotFoundException:

// DllImport
[DllImport("mylib", EntryPoint = "process_records")]
private static extern int ProcessRecords(
    [In] Record[] records, UIntPtr count, out uint outProcessed);

// LibraryImport
[LibraryImport("mylib", EntryPoint = "process_records")]
internal static partial int ProcessRecords(
    [In] Record[] records, nuint count, out uint outProcessed);
步骤 4:正确处理字符串
  1. 了解原生函数所需的编码。 没有安全的默认值。
  2. Windows API: 始终调用 W(UTF-16)变体。只有在有特定理由并显式指定 ANSI 编码时,才使用 A 变体。
  3. 跨平台 C 库: 通常需要 UTF-8。
  4. 显式指定编码。 切勿依赖 CharSet.Auto。
  5. 切勿为输出缓冲区引入 StringBuilder。

❌ 切勿依赖 CharSet.Auto 或省略字符串编码——没有安全的默认值。

// DllImport — Windows API (UTF-16)
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern int GetModuleFileNameW(
    IntPtr hModule, [Out] char[] filename, int size);

// DllImport — Cross-platform C library (UTF-8)
[DllImport("mylib")]
private static extern int SetName(
    [MarshalAs(UnmanagedType.LPUTF8Str)] string name);

// LibraryImport — UTF-16
[LibraryImport("kernel32", StringMarshalling = StringMarshalling.Utf16,
    SetLastError = true)]
internal static partial int GetModuleFileNameW(
    IntPtr hModule, [Out] char[] filename, int size);

// LibraryImport — UTF-8
[LibraryImport("mylib", StringMarshalling = StringMarshalling.Utf8)]
internal static partial int SetName(string name);

字符串生命周期警告: 封送后的字符串会在调用返回后释放。如果原生代码存储了指针(而不是复制内容),则必须手动管理其生命周期。在 Windows 或 .NET Framework 上,跨边界所有权管理应优先使用 CoTaskMemAlloc/CoTaskMemFree;在非 Windows 目标平台上,请使用 NativeMemory API。库也可能有必须使用的自有分配器。

步骤 5:明确内存所有权

当内存跨越边界时,必须且只能由一方拥有——并且双方必须对此达成一致。

❌ 切勿使用不匹配的分配器释放内存——对 malloc 分配的内存调用 Marshal.FreeHGlobal 会造成堆损坏。

模型 1——调用方分配,调用方释放(最安全):

[LibraryImport("mylib")]
private static partial int GetName(
    Span<byte> buffer, nuint bufferSize, out nuint actualSize);

public static string GetName()
{
    Span<byte> buffer = stackalloc byte[256];
    int result = GetName(buffer, (nuint)buffer.Length, out nuint actualSize);
    if (result != 0) throw new InvalidOperationException($"Failed: {result}");
    return Encoding.UTF8.GetString(buffer[..(int)actualSize]);
}

模型 2——被调用方分配,调用方释放(Win32 中常见):

[LibraryImport("mylib")]
private static partial IntPtr GetVersion();
[LibraryImport("mylib")]
private static partial void FreeString(IntPtr s);

public static string GetVersion()
{
    IntPtr ptr = GetVersion();
    try { return Marshal.PtrToStringUTF8(ptr) ?? throw new InvalidOperationException(); }
    finally { FreeString(ptr); } // Must use the library's own free function
}

关键规则: 始终使用匹配的分配器释放内存。切勿对 malloc 分配的内存使用 Marshal.FreeHGlobal 或 Marshal.FreeCoTaskMem。

模型 3——基于句柄(被调用方分配,被调用方释放): 使用 SafeHandle(见步骤 6)。

固定托管对象——当原生代码存储指针或异步运行时:

// Synchronous: use fixed
public static unsafe void ProcessSync(byte[] data)
{
    fixed (byte* ptr = data) { ProcessData(ptr, (nuint)data.Length); }
}

// Asynchronous: use GCHandle
var gcHandle = GCHandle.Alloc(data, GCHandleType.Pinned);
// Must keep pinned until native processing completes, then call gcHandle.Free()
步骤 6:对原生句柄使用 SafeHandle

原始 IntPtr 在发生异常时会泄漏,也没有防止重复释放的保护。SafeHandle 是必需的。

internal sealed class MyLibHandle : SafeHandleZeroOrMinusOneIsInvalid
{
    // Required by the marshalling infrastructure to instantiate the handle.
    // Do not remove — there are no direct callers.
    private MyLibHandle() : base(ownsHandle: true) { }

    [LibraryImport("mylib", StringMarshalling = StringMarshalling.Utf8)]
    private static partial MyLibHandle CreateHandle(string config);

    [LibraryImport("mylib")]
    private static partial int UseHandle(MyLibHandle h, ReadOnlySpan<byte> data, nuint len);

    [LibraryImport("mylib")]
    private static partial void DestroyHandle(IntPtr h);

    protected override bool ReleaseHandle() { DestroyHandle(handle); return true; }

    public static MyLibHandle Create(string config)
    {
        var h = CreateHandle(config);
        if (h.IsInvalid) throw new InvalidOperationException("Failed to create handle");
        return h;
    }

    public int Use(ReadOnlySpan<byte> data) => UseHandle(this, data, (nuint)data.Length);
}

// Usage: SafeHandle is IDisposable
using var handle = MyLibHandle.Create("config=value");
int result = handle.Use(myData);
步骤 7:处理错误

当原生 API 的约定要求捕获操作系统的最后错误值时,将 LibraryImportAttribute.SetLastError 设为 true,然后使用 Marshal.GetLastPInvokeError() 获取该值。

// Win32 APIs — check SetLastError
[LibraryImport("kernel32", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static partial bool CloseHandle(IntPtr hObject);

if (!CloseHandle(handle))
    throw new Win32Exception(Marshal.GetLastPInvokeError());

// HRESULT APIs
int hr = NativeDoWork(context);
Marshal.ThrowExceptionForHR(hr);
步骤 8:处理回调(如需要)

首选方案(.NET 8+):UnmanagedCallersOnly ——完全避免使用委托,不存在 GC 生命周期风险:

[UnmanagedCallersOnly]
private static void LogCallback(int level, IntPtr message)
{
    string msg = Marshal.PtrToStringUTF8(message) ?? string.Empty;
    Console.WriteLine($"[{level}] {msg}");
}

[LibraryImport("mylib")]
private static unsafe partial void SetLogCallback(
    delegate* unmanaged<int, IntPtr, void> cb);

unsafe { SetLogCallback(&LogCallback); }

该方法必须是 static,不得向原生代码抛出异常,且只能使用可直接封送的参数类型。

备用方案(较旧的 TFM 或需要实例状态时):对委托进行根引用

[UnmanagedFunctionPointer(CallingConvention.Cdecl)] // Only needed on Windows x86
private delegate void LogCallbackDelegate(int level, IntPtr message);

// CRITICAL: prevent delegate from being garbage collected
private static LogCallbackDelegate? s_logCallback;

public static void EnableLogging(Action<int, string> handler)
{
    s_logCallback = (level, msgPtr) =>
    {
        string msg = Marshal.PtrToStringUTF8(msgPtr) ?? string.Empty;
        handler(level, msg);
    };
    SetLogCallback(s_logCallback);
}

如果原生代码会保存函数指针,委托在其整个生命周期内必须保持根引用。委托被回收会导致崩溃。

短期回调使用 GC.KeepAlive: 使用 Marshal.GetFunctionPointerForDelegate 将委托转换为函数指针时,GC 不会跟踪该指针与委托之间的关系。使用 GC.KeepAlive,以防在原生调用完成前委托被回收:

var callback = new LogCallbackDelegate((level, msgPtr) =>
{
    string msg = Marshal.PtrToStringUTF8(msgPtr) ?? string.Empty;
    Console.WriteLine($"[{level}] {msg}");
});

IntPtr fnPtr = Marshal.GetFunctionPointerForDelegate(callback);
NativeUsesCallback(fnPtr);
GC.KeepAlive(callback); // prevent collection — fnPtr does not root the delegate

跨平台库加载

对于复杂场景,使用 NativeLibrary.SetDllImportResolver;对于简单场景,使用条件编译。对 C 的 long/unsigned long 使用 CLong/CULong。注意:在 LibraryImport 中使用 CLong/CULong 需要 [assembly: DisableRuntimeMarshalling]。

// Trivial: use platform naming convention
// The default naming convention adds corresponding prefix and extension when
// searching the nativelibrary. The resultant file name will be mylib.dll on
// Windows, libmylib.so on Linux and libmylib.dylib on macOS.
private const string LibName = "mylib";

// Simple: conditional compilation
// WINDOWS, LINUX, MACOS are predefined only when targeting an OS-specific TFM
// (e.g., net8.0-windows). For portable TFMs (e.g., net8.0), these symbols are
// not defined — use the runtime resolver approach below instead.
#if WINDOWS
    private const string LibName = "mylib.dll";
#elif LINUX
    private const string LibName = "libmylib.so";
#elif MACOS
    private const string LibName = "libmylib.dylib";
#endif

// Complex: runtime resolver
// When targeting netstandard2.0 or other frameworks when OperatingSystem.IsXXX
// is not available, use RuntimeInformation.IsOSPlatform(OSPlatform.XXX) api.
NativeLibrary.SetDllImportResolver(typeof(MyLib).Assembly,
    (name, assembly, searchPath) =>
    {
        if (name != "mylib") return IntPtr.Zero;
        string libName = OperatingSystem.IsWindows()
            ? "mylib.dll"
            : OperatingSystem.IsMacOS()
                ? "libmylib.dylib" : "libmylib.so";
        NativeLibrary.TryLoad(libName, assembly, searchPath, out var handle);
        return handle;
    });

从 DllImport 迁移到 LibraryImport

对于面向 .NET 7+ 的代码库,迁移可提供 AOT 兼容性和修剪安全性。

  1. 为包含该方法的类添加 partial,并将方法声明为 static partial
  2. 将 [DllImport] 替换为 [LibraryImport]
  3. 将 CharSet 替换为 StringMarshalling
  4. 对于需要捕获操作系统最后错误值的 API,保留 SetLastError = true
  5. 除非面向 Windows x86,否则移除 CallingConvention
  6. 构建并修复 SYSLIB1054–SYSLIB1057 分析器警告

启用互操作分析器:

<PropertyGroup>
    <EnableTrimAnalyzer>true</EnableTrimAnalyzer>
    <EnableAotAnalyzer>true</EnableAotAnalyzer>
</PropertyGroup>

工具

CsWin32(Win32 API)

对于 Win32 P/Invoke,优先使用 Microsoft.Windows.CsWin32,而不是手写签名。它会根据元数据生成正确的源代码声明。添加一个列出所需 API 的 NativeMethods.txt 文件:

dotnet add package Microsoft.Windows.CsWin32
CsWinRT(WinRT API)

对于 WinRT 互操作,使用 Microsoft.Windows.CsWinRT 从 .winmd 文件生成 .NET 投影。

Objective Sharpie(Objective-C API)

对于 Objective-C 库绑定(macOS/iOS),使用 Objective Sharpie 根据 Objective-C 头文件生成初始 P/Invoke 和绑定定义。


验证

审查清单
  • 每个签名都与原生头文件完全匹配(类型、大小)
  • 面向 Windows x86 时指定调用约定;其他情况下省略
  • 显式指定字符串编码,不依赖默认值或 CharSet.Auto
  • 记录并匹配内存所有权(由谁分配、由谁释放,以及如何释放)
  • 所有原生句柄都使用 SafeHandle(没有原始 IntPtr 逸出互操作层)
  • 作为回调传递的委托都进行根引用,以防被 GC 回收
  • 当原生 API 要求捕获最后错误值时,将 DllImportAttribute.SetLastError 或 LibraryImportAttribute.SetLastError 设为 true
  • 结构体布局与原生端匹配(打包、对齐、字段顺序)
  • 跨平台代码中,对 C 的 long/unsigned long 使用 CLong/CULong
  • 如果在 LibraryImport 中使用 CLong/CULong,则应用 [assembly: DisableRuntimeMarshalling]
  • 不要使用未显式指定 MarshalAs 的 bool;始终指定 UnmanagedType.Bool(4 字节)或 UnmanagedType.U1(1 字节),以确保语言边界两侧的规范化一致。
可运行的验证步骤
  1. 启用互操作分析器进行构建 — 确认没有 SYSLIB1054–SYSLIB1057 警告:
    <EnableTrimAnalyzer>true</EnableTrimAnalyzer>
    <EnableAotAnalyzer>true</EnableAotAnalyzer>
    
  2. 验证结构体大小一致 — 对于每个跨越边界的结构体,断言 Marshal.SizeOf<T>() 等于原生 sizeof
  3. 往返测试 — 使用已知输入调用原生函数,并验证预期输出
  4. 使用非 ASCII 字符串进行测试 — 传入包含 ASCII 范围以外字符的字符串,以确认编码正确

参考文件

  • references/type-mapping.md — 完整的原生到 .NET 类型映射表、结构体布局模式和可直接封送类型规则。在以下情况下加载: 遇到上文第 2 步未涵盖的类型,或处理结构体布局或可直接封送类型相关问题时。
  • references/diagnostics.md — 常见陷阱、故障模式及恢复方法、调试方法和外部资源。在以下情况下加载: 调试现有的 P/Invoke 故障,或检查互操作代码是否存在正确性问题时。

评论

支持 Markdown