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# 声明 | 说明 |
|---|---|---|
| int | int | 直接映射 |
| unsigned int | uint | 避免符号扩展错误 |
| size_t | nuint | 随平台变化 |
| BOOL (Win32) | int | 4 字节,用 != 0 判断 |
| bool (stdbool) | byte | 1 字节,需自定义封送 |
| long (LP64) | nint 或 long | Windows 上 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 诊断,而不是运行时静默出错。
| 维度 | DllImport | LibraryImport |
|---|---|---|
| 封送时机 | 运行时生成 | 编译期生成 |
| AOT 兼容 | 需裁剪根 | 完全支持 |
| 字符串编码 | CharSet | StringMarshalling |
| 生成失败反馈 | 运行时异常 | 编译期诊断 |
| 性能 | 一般 | 更优 |
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 把同一套逻辑带到多个客户端平台。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。