C# 原生互操作实战

讲解 .NET 与原生代码互操作的完整路径,覆盖 P/Invoke 与 LibraryImport 源生成、字符串与结构体封送规则、SafeHandle 资源管理、回调与 COM 互操作,以及跨平台原生库加载与调试方法。

1. 互操作的场景与总体策略

一句话总结: 需要调用 C 库、复用已有原生资产或对接系统 API 时才用互操作,优先级依次是托管替代、进程外隔离、最后才是进程内 P/Invoke。

互操作不是首选方案,而是权衡后的结果。决策顺序如下:

场景首选方案理由
有托管等价实现纯托管库无封送开销、可裁剪、可移植
仅需少量系统调用运行时内置 API如 File、Socket,已封装好
复杂原生库进程外调用或 gRPC崩溃隔离、无封送复杂度
高性能数值库P/Invoke 或 NativeAOT避免跨进程序列化开销
已有 C 库无替代P/Invoke / LibraryImport唯一选择

进程内互操作的风险在于:原生代码崩溃会直接终止 .NET 进程,内存错误无法被 GC 或异常机制拦截。因此只有在性能或功能确实需要时才选它。

// 系统调用示例:读取文件系统块大小
[DllImport("libc", SetLastError = true)]
private static extern int statvfs(string path, out Statvfs buf);

2. P/Invoke 基础

一句话总结: DllImport 声明必须精确匹配原生签名,参数类型、调用约定、字符集与错误处理缺一不可,任何一处不匹配都会导致内存破坏而非友好异常。

一个完整的 DllImport 声明包含四类信息:库名、入口点、调用约定、封送指令。

using System.Runtime.InteropServices;

internal static partial class NativeMethods
{
    // 最简声明:库名 + 入口点,默认 Cdecl 之外的平台默认约定
    [DllImport("sqlite3", EntryPoint = "sqlite3_libversion")]
    private static extern IntPtr sqlite3_libversion_raw();

    // 推荐:显式指定约定、字符集与错误处理
    [DllImport("kernel32.dll", SetLastError = true,
        CharSet = CharSet.Unicode, ExactSpelling = true)]
    private static extern IntPtr CreateFileW(
        string lpFileName, uint dwDesiredAccess, uint dwShareMode,
        IntPtr lpSecurityAttributes, uint dwCreationDisposition,
        uint dwFlagsAndAttributes, IntPtr hTemplateFile);
}

SetLastError = true 会告诉运行时在调用返回后立即读取并保存 GetLastError,之后用 Marshal.GetLastWin32Error() 获取。若不设置,错误码可能被后续调用覆盖。

IntPtr handle = CreateFileW(path, 0x80000000, 0, IntPtr.Zero, 3, 0, IntPtr.Zero);
if (handle == new IntPtr(-1))
{
    int err = Marshal.GetLastWin32Error();
    throw new IOException($"CreateFile 失败,错误码 {err}");
}

2.1 调用约定与库名解析

一句话总结: 调用约定必须与原生头文件一致,Windows 上多数 Win32 API 用 StdCall,Linux 与 macOS 用 Cdecl;库名可用 DllImportResolver 在运行时动态解析。

[DllImport("mylib", CallingConvention = CallingConvention.Cdecl)]
private static extern int my_func(int x);

跨平台库名差异(Windows 的 foo.dll、Linux 的 libfoo.so、macOS 的 libfoo.dylib)可用 NativeLibrary.SetDllImportResolver 统一处理:

static NativeMethods()
{
    NativeLibrary.SetDllImportResolver(typeof(NativeMethods).Assembly,
        (name, asm, paths) =>
        {
            if (name != "mylib") return IntPtr.Zero;   // 交给默认解析
            string fileName = OperatingSystem.IsWindows() ? "mylib.dll"
                : OperatingSystem.IsMacOS() ? "libmylib.dylib" : "libmylib.so";
            return NativeLibrary.Load(fileName, asm, paths);
        });
}

2.2 布尔与整数类型的映射

一句话总结: C 的 bool 是 1 字节而 C# 的 bool 在封送下默认是 4 字节,Win32 BOOL 是 4 字节,三者必须用不同声明区分。

原生类型C# 声明说明
intint直接映射
unsigned intuint避免符号扩展错误
size_tnuint随平台变化
BOOL (Win32)int4 字节,用 != 0 判断
bool (stdbool)byte1 字节,需自定义封送
long (LP64)nint 或 longWindows 上 long 是 4 字节
char*byte* 或 string视编码而定

long 的跨平台差异是最隐蔽的坑:Windows 上 C 的 long 是 32 位,Linux 与 macOS 上是 64 位,直接用 C# long 声明在 Windows 上会读错相邻字段。

3. LibraryImport 源生成

一句话总结: .NET 7 起应优先使用 LibraryImport 源生成器,它把封送代码编译期生成,兼容 AOT 且性能更好,逐步取代 DllImport。

internal static partial class NativeMethods
{
    [LibraryImport("sqlite3", EntryPoint = "sqlite3_open_v2",
        StringMarshalling = StringMarshalling.Utf8)]
    internal static partial int sqlite3_open_v2(
        string filename, out IntPtr db, int flags, IntPtr vfs);

    [LibraryImport("libc", SetLastError = true)]
    internal static partial int getpid();
}

关键约束:

  • 所在类必须是 partial,方法必须是 partial。
  • 不支持 CharSet,改用 StringMarshalling(Utf8 或 Utf16)。
  • 不支持 ref 返回、Variant、IDispatch 等少量高级封送。
  • 无法生成时会在编译期报 SYSLIB 诊断,而不是运行时静默出错。
维度DllImportLibraryImport
封送时机运行时生成编译期生成
AOT 兼容需裁剪根完全支持
字符串编码CharSetStringMarshalling
生成失败反馈运行时异常编译期诊断
性能一般更优

3.1 迁移步骤

一句话总结: 迁移只需三步——类与方法加 partial、加 LibraryImport 特性、把 CharSet 换成 StringMarshalling,剩下的编译错误会逐一指出不支持的签名。

// 迁移前
[DllImport("libz", CharSet = CharSet.Ansi, EntryPoint = "compress")]
private static extern int compress_old(byte[] dest, ref ulong destLen,
                                       byte[] source, ulong sourceLen);

// 迁移后:字节数组用 ReadOnlySpan 表达,更安全且零拷贝
[LibraryImport("libz", EntryPoint = "compress")]
private static partial int compress(
    Span<byte> dest, ref nuint destLen,
    ReadOnlySpan<byte> source, nuint sourceLen);

LibraryImport 对 Span<byte> 与 ReadOnlySpan<byte> 有专门支持,可以直接传递而不复制,这是相比 byte[] 的显著改进。

4. 结构体封送

一句话总结: 结构体封送依赖字段顺序与对齐,必须用 LayoutKind.Sequential 或 Explicit 显式声明,blittable 结构体可零拷贝传递,含引用类型的结构体则需要逐字段封送。

[StructLayout(LayoutKind.Sequential)]
internal struct Statvfs
{
    public ulong f_bsize;
    public ulong f_frsize;
    public ulong f_blocks;
    public ulong f_bfree;
    public ulong f_bavail;
    public ulong f_files;
    public ulong f_ffree;
}

若结构体只含基元类型(blittable),运行时可以直接按位复制,无封送开销。一旦包含 string、数组或类字段,就需要逐字段转换。

[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
internal struct Win32FindData
{
    public uint dwFileAttributes;
    public long ftCreationTime;
    public long ftLastAccessTime;
    public long ftLastWriteTime;
    public uint nFileSizeHigh;
    public uint nFileSizeLow;

    // 定长字符数组用 ByValTStr 表达,长度必须与原生一致
    [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)]
    public string cFileName;
}

4.1 显式布局与联合体

一句话总结: 原生联合体用 LayoutKind.Explicit 加 FieldOffset 表达,必须确保各字段偏移与原生一致,并用 Size 校验总大小。

[StructLayout(LayoutKind.Explicit, Size = 8)]
internal struct ValueUnion
{
    [FieldOffset(0)] public long AsLong;
    [FieldOffset(0)] public double AsDouble;
    [FieldOffset(0)] public int AsIntLow;
    [FieldOffset(4)] public int AsIntHigh;
}

在测试中应断言 Marshal.SizeOf<ValueUnion>() 与原生 sizeof 一致(上例应为 8),这是发现布局错误最直接的手段,也能在原生头文件变更时第一时间失败。

4.2 数组与 out 参数

一句话总结: 传数组给原生代码时优先用 Span 避免拷贝,出参用 out 或 ref,缓冲区大小必须由调用方保证,原生代码不会做边界检查。

[LibraryImport("libc", EntryPoint = "read", SetLastError = true)]
private static partial nint read(int fd, Span<byte> buf, nuint count);

public static int 读取(int fd, Span<byte> buffer)
{
    nint n = read(fd, buffer, (nuint)buffer.Length);
    if (n < 0) throw new IOException($"read 失败 {Marshal.GetLastWin32Error()}");
    return (int)n;
}

Span<byte> 在 LibraryImport 下会被固定(pin)并直接传递指针,无需中间数组复制。

5. SafeHandle 与资源管理

一句话总结: 原生句柄必须用 SafeHandle 包装,它保证即使发生异常也能释放,且防止句柄在调用过程中被 GC 提前回收。

裸 IntPtr 句柄有三个风险:异常路径漏释放、句柄被 GC 终结器提前回收导致 use-after-free、以及在并发释放与使用之间产生竞态。SafeHandle 解决全部三个问题。

internal sealed class SqliteHandle : SafeHandleZeroOrMinusOneIsInvalid
{
    private SqliteHandle() : base(ownsHandle: true) { }

    public static SqliteHandle Open(string path)
    {
        var handle = new SqliteHandle();
        int rc = NativeMethods.sqlite3_open_v2(path, out IntPtr db, 0x2, IntPtr.Zero);
        if (rc != 0) throw new InvalidOperationException($"打开失败 rc={rc}");
        handle.SetHandle(db);
        return handle;
    }

    protected override bool ReleaseHandle()
    {
        int rc = NativeMethods.sqlite3_close(handle);
        return rc == 0;
    }
}

SafeHandleZeroOrMinusOneIsInvalid 把 0 与 -1 视为无效值,SafeHandleMinusOneIsInvalid 只把 -1 视为无效。选择错误的基类会让 IsInvalid 判断失准。

5.1 DangerousAddRef 与并发安全

一句话总结: 在跨多次原生调用的场景中,SafeHandle 的引用计数会阻止句柄在调用序列中途被释放,这是它优于裸 IntPtr 的核心机制。

public static void 使用句柄(SqliteHandle handle, Action<IntPtr> action)
{
    bool added = false;
    try
    {
        handle.DangerousAddRef(ref added);
        action(handle.DangerousGetHandle());   // 调用期间句柄保证有效
    }
    finally
    {
        if (added) handle.DangerousRelease();
    }
}

命名中的 “Dangerous” 是警告:一旦调用 DangerousGetHandle,就绕过了引用计数保护,必须自己保证句柄存活期间不被释放。

5.2 终结器与 Dispose

一句话总结: SafeHandle 自带终结器,因此包装类不必再实现终结器,只需实现 IDisposable 并调用 Dispose,避免双重释放。

public sealed class Database : IDisposable
{
    private readonly SqliteHandle _handle;

    public Database(string path) => _handle = SqliteHandle.Open(path);

    public void Dispose() => _handle.Dispose();   // 不需要终结器
}

6. 回调与委托封送

一句话总结: 把托管委托传给原生代码时,必须用 [UnmanagedCallersOnly] 或显式保持委托引用,否则委托被 GC 回收后原生调用会崩溃。

最安全的方式是使用函数指针与 [UnmanagedCallersOnly]:

[UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })]
private static int 比较回调(IntPtr a, IntPtr b)
{
    // 必须静态、无托管引用类型参数
    return a.ToInt64().CompareTo(b.ToInt64());
}

public static unsafe void 排序(Span<nint> items)
{
    fixed (nint* p = items)
    {
        NativeMethods.qsort(p, (nuint)items.Length, (nuint)sizeof(nint),
            &比较回调);   // 直接取函数指针
    }
}

若原生 API 要求委托实例(如 COM 事件),必须显式持有引用防止 GC:

private readonly List<Delegate> _callbackKeepAlive = new();

public void 注册回调(Action<int> callback)
{
    _callbackKeepAlive.Add(callback);   // 防止被回收
    NativeMethods.register(callback);
}

[UnmanagedCallersOnly] 方法只能被原生代码调用,不能从托管代码直接调用,参数与返回值也必须是 blittable 类型。

7. COM 互操作与跨平台加载

一句话总结: Windows 上 COM 互操作优先用 ComWrappers 或 CsWinRT,跨平台则通过 NativeLibrary 显式加载并按需解析符号,避免隐式加载路径不确定。

传统 COM 互操作依赖运行时内置的 RCW/CCW 与类型库导入,在 AOT 下不可用。现代方案是 ComWrappers 与 [GeneratedComInterface]:

[GeneratedComInterface]
[Guid("00021401-0000-0000-C000-000000000046")]
internal partial interface IShellLinkW
{
    void GetPath([MarshalAs(UnmanagedType.LPWStr)] char[] pszFile,
                 int cch, IntPtr pfd, uint fFlags);
}

跨平台加载原生库则推荐显式 API:

public static IntPtr 加载(string name)
{
    if (!NativeLibrary.TryLoad(name, out IntPtr handle))
        throw new DllNotFoundException($"无法加载 {name}");
    return handle;
}

public static T 取符号<T>(IntPtr lib, string symbol) where T : Delegate
{
    IntPtr addr = NativeLibrary.GetExport(lib, symbol);
    return Marshal.GetDelegateForFunctionPointer<T>(addr);
}

调试互操作问题的三板斧:用 DllImportSearchPath 明确搜索路径、用 NativeLibrary.TryLoad 的返回值确认加载失败而非符号缺失、在 Linux 上用 ldd 检查依赖库是否齐全。

# 检查原生库依赖是否满足
ldd ./libmylib.so
# 查看导出符号是否与声明一致
nm -D --defined-only ./libmylib.so | grep sqlite3_open

8. 总结

环节要点
策略优先托管实现,其次进程外隔离,最后才进程内互操作
声明调用约定、字符集、SetLastError 必须与原生头文件精确一致
源生成新代码优先 LibraryImport,编译期生成、AOT 友好、支持 Span
结构体Sequential 或 Explicit 布局,blittable 可零拷贝,测试中校验大小
资源管理句柄一律用 SafeHandle 包装,异常路径也能释放
回调优先 UnmanagedCallersOnly 函数指针,委托实例必须防 GC 回收
调试用 ldd 与 nm 核对依赖与符号,用 DllImportResolver 统一跨平台库名

原生互操作的本质是在托管世界与 C 世界之间架一座精确的桥:任何类型大小、对齐、调用约定或生命周期上的偏差,都不会表现为友好的异常,而是内存破坏或随机崩溃。把 LibraryImport、SafeHandle 与结构体大小断言作为默认习惯,就能把绝大多数互操作缺陷挡在上线之前。下一篇换个方向,看看如何用 .NET MAUI 把同一套逻辑带到多个客户端平台。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「csharp」更多文章

  1. .NET 机器学习实战
  2. 内存剖析与 dump 分析
  3. 分布式事务与 Saga 编排