1. 跨语言调用的核心逻辑与方案选型
C# 调用 C 编译出来的动态链接库,本质上是在托管世界和非托管世界之间搭一座桥。C# 跑在 CLR 之上,内存由垃圾回收器管理,类型系统是强类型的元数据驱动;C 编译出来的.dll或.so则是赤裸裸的机器码,导出的是符号名和地址,没有类型信息,没有 GC,内存全靠手动。这两者要对话,就必须有一个双方都能听懂的“中间语言”——这个中间语言就是P/Invoke(Platform Invocation Services)和它的底层约定。
我最早接触这块是在做一个图像处理模块的时候。核心算法用 C 写,因为要直接操作像素缓冲区、做 SIMD 优化,用 C# 写性能差一大截;但上层业务逻辑、界面、配置管理全在 C# 里。当时第一反应是“能不能直接引用”,答案是不能,C 编译出来的东西不是 .NET 程序集,CLR 不认识它的元数据。于是只能走 P/Invoke 这条路。
P/Invoke 的工作机制其实不复杂:你在 C# 里用[DllImport]声明一个外部方法,CLR 在运行时根据这个声明去加载对应的动态库,找到导出的符号,然后按照你声明的签名把参数从托管堆封送(marshal)到非托管内存,调用完成后再把返回值封送回来。整个过程对调用方来说是透明的,你写起来就像调用一个普通的 C# 静态方法。
但“透明”不等于“没有代价”。封送是有开销的,尤其是字符串、数组、结构体这些复杂类型,CLR 需要在托管和非托管内存之间来回拷贝。如果调用频率很高,比如每帧调用几千次,这个开销就会变成瓶颈。所以方案选型的时候,第一件事就是判断:这个调用是低频的还是高频的?低频的话,P/Invoke 完全够用,写起来简单直接;高频的话,就得考虑减少封送次数,比如批量传数组而不是逐个传元素,或者干脆把循环逻辑也放到 C 那边去。
另一个关键决策是调用约定(Calling Convention)。C 编译器默认用cdecl,Windows API 用stdcall,而 .NET 默认也是stdcall。如果你在 C# 里不显式指定,调一个cdecl的 C 函数,栈就会不平衡,轻则返回值错乱,重则直接崩溃。我踩过这个坑:一个简单的加法函数,C 那边用cdecl编译,C# 这边没写CallingConvention,结果每次调用完栈指针偏移了 4 字节,循环调用几十次之后程序就挂了。后来加上CallingConvention = CallingConvention.Cdecl就正常了。所以记住一条:C 编译出来的库,默认按cdecl处理,除非你明确知道它用的是stdcall。
还有一点容易被忽略的是导出符号的名字修饰。C 编译器在导出函数时,可能会在函数名前面加下划线,或者后面加@加参数字节数(stdcall的情况)。比如int Add(int a, int b)在stdcall下可能导出成_Add@8。C# 的DllImport默认按你写的名字去找,找不到就报EntryPointNotFoundException。解决办法有两个:一是用extern "C"和.def文件控制导出名,让它保持干净;二是在DllImport里用EntryPoint属性显式指定实际符号名。我一般推荐第一种,从源头控制,省得后面猜名字。
方案选型的最后一块是平台兼容性。Windows 上是.dll,Linux 上是.so,macOS 上是.dylib。如果你的 C# 程序要跨平台,就不能把库文件名写死。常见的做法是用条件编译或者运行时判断,根据RuntimeInformation.IsOSPlatform来决定DllImport里的库名。更优雅的方式是用NativeLibrary.SetDllImportResolver注册一个解析器,在运行时动态决定加载哪个文件。这个后面会详细讲。
2. 核心细节解析与实操要点
2.1 基础声明:从DllImport开始
最简单的 P/Invoke 声明长这样:
using System.Runtime.InteropServices; public static class NativeMethods { [DllImport("MyNativeLib.dll", CallingConvention = CallingConvention.Cdecl)] public static extern int Add(int a, int b); }这几行代码里藏着好几个关键点。DllImport的第一个参数是库名,Windows 下可以不写.dll后缀,CLR 会自动补全;但 Linux 下必须写全.so,否则找不到。CallingConvention前面说过了,C 库默认cdecl。extern关键字告诉编译器这个方法没有 C# 实现,实现在外部。
这里有个细节:DllImport修饰的方法必须是static extern,不能是实例方法,也不能有方法体。返回类型和参数类型必须是“封送兼容”的,也就是说 CLR 知道怎么把它转换成非托管类型。int、double、bool这些基元类型没问题,但string、byte[]、自定义结构体就需要额外处理。
2.2 字符串封送:ANSI还是Unicode
字符串是跨语言调用里最容易出问题的地方。C 里的字符串就是一个以\0结尾的字节数组,没有长度信息,也没有编码信息。C# 的string是 UTF-16 编码的,有长度,由 GC 管理。两者之间的转换需要明确指定编码方式。
DllImport有一个CharSet属性,默认是CharSet.Ansi。这意味着 C# 的string会被转换成 ANSI 编码(Windows 上通常是当前系统代码页,中文系统就是 GBK)的字节数组传给 C。如果 C 那边期望的是 UTF-8,就会乱码。我遇到过好几次中文路径传过去变成问号的情况,就是因为编码没对上。
正确的做法是显式指定CharSet:
[DllImport("MyNativeLib.dll", CharSet = CharSet.Unicode, CallingConvention = CallingConvention.Cdecl)] public static extern int ProcessString(string input);CharSet.Unicode表示用 UTF-16 传递,对应 C 那边的wchar_t*。如果 C 那边用的是char*且期望 UTF-8,那就得用CharSet.Ansi配合[MarshalAs(UnmanagedType.LPStr)],或者手动用Encoding.UTF8.GetBytes转成字节数组再传。
对于返回字符串的情况,更麻烦。C 函数如果返回char*,这个内存是谁分配的?谁负责释放?如果是 C 那边用malloc分配的,C# 这边默认的封送行为是把它复制成 C# 的string,但不会释放原始内存,就会泄漏。如果是静态缓冲区,那还好,但要注意线程安全。我一般推荐的做法是:让调用方分配缓冲区,C 函数往里面写。这样内存归属清晰,不会泄漏。
[DllImport("MyNativeLib.dll", CallingConvention = CallingConvention.Cdecl)] public static extern int GetName(byte[] buffer, int bufferSize); // 调用 byte[] buffer = new byte[256]; int len = GetName(buffer, buffer.Length); string name = Encoding.UTF8.GetString(buffer, 0, len);这种方式虽然多写几行,但可控性最强,不会出现内存泄漏或者编码混乱的问题。
2.3 数组与指针:IntPtr和Marshal
数组的封送比字符串更复杂。C# 的数组是托管对象,有长度信息,内存可能在堆上也可能在栈上;C 的数组就是一个连续的内存块,通常用指针加长度来表示。P/Invoke 支持直接封送数组,比如:
[DllImport("MyNativeLib.dll", CallingConvention = CallingConvention.Cdecl)] public static extern void ProcessArray(int[] data, int length);CLR 会把int[]固定(pin)在内存里,然后把首地址传给 C。调用完成后解除固定。这个过程叫“固定”,是为了防止 GC 在调用过程中移动数组导致 C 那边的指针失效。但固定是有代价的,频繁固定会影响 GC 效率。如果数组很大或者调用很频繁,更好的方式是手动分配非托管内存:
IntPtr ptr = Marshal.AllocHGlobal(data.Length * sizeof(int)); try { Marshal.Copy(data, 0, ptr, data.Length); ProcessArrayNative(ptr, data.Length); Marshal.Copy(ptr, data, 0, data.Length); } finally { Marshal.FreeHGlobal(ptr); }这种方式虽然代码多,但内存归属清晰,而且可以复用缓冲区,减少分配次数。我在做图像处理的时候就是用这种方式:预先分配好几块非托管缓冲区,每次处理时把像素数据拷进去,调用完再拷回来。虽然多了一次拷贝,但避免了每次调用都固定大数组,整体性能反而更好。
2.4 结构体封送:布局必须匹配
结构体是跨语言调用里最考验细节的部分。C# 的结构体默认按Sequential布局,但字段对齐方式可能和 C 不一样。C 编译器默认按字段自然对齐,比如int按 4 字节对齐,double按 8 字节对齐,中间可能有填充字节。C# 的StructLayout可以控制这些。
[StructLayout(LayoutKind.Sequential, Pack = 8)] public struct Point3D { public float X; public float Y; public float Z; }Pack = 8表示按 8 字节对齐,和 C 的默认行为一致。如果 C 那边用了#pragma pack(1)紧凑排列,C# 这边也要写Pack = 1,否则字段偏移对不上,读出来的数据就是错的。
还有一个坑是bool类型。C 的bool通常是 1 字节(stdbool.h),但 C# 的bool默认封送成 4 字节的BOOL(Windows API 风格)。如果 C 结构体里有个bool字段,C# 这边必须用[MarshalAs(UnmanagedType.U1)]修饰,否则整个结构体的布局就乱了。
[StructLayout(LayoutKind.Sequential)] public struct Config { public int Id; [MarshalAs(UnmanagedType.U1)] public bool Enabled; public double Threshold; }这种细节问题,调试的时候特别难找。因为程序不会崩溃,只是数据不对,你得一个字段一个字段地对比内存布局才能发现。我的经验是:结构体定义完之后,用Marshal.SizeOf打印一下大小,和 C 那边的sizeof对比,不一致就说明布局有问题。
3. 实操过程与核心环节实现
3.1 从零写一个 C 库并导出
先写一个简单的 C 库,包含几个典型函数:加法、字符串处理、数组求和、结构体操作。
// native_lib.h #ifndef NATIVE_LIB_H #define NATIVE_LIB_H #ifdef _WIN32 #define EXPORT __declspec(dllexport) #else #define EXPORT __attribute__((visibility("default"))) #endif #ifdef __cplusplus extern "C" { #endif EXPORT int Add(int a, int b); EXPORT int ConcatStrings(const char* str1, const char* str2, char* output, int outputSize); EXPORT int SumArray(const int* data, int length); typedef struct { float X; float Y; float Z; } Point3D; EXPORT float Distance(const Point3D* p1, const Point3D* p2); #ifdef __cplusplus } #endif #endif// native_lib.c #include "native_lib.h" #include <string.h> #include <math.h> #include <stdio.h> EXPORT int Add(int a, int b) { return a + b; } EXPORT int ConcatStrings(const char* str1, const char* str2, char* output, int outputSize) { int len1 = strlen(str1); int len2 = strlen(str2); if (len1 + len2 + 1 > outputSize) { return -1; // 缓冲区不够 } strcpy(output, str1); strcat(output, str2); return len1 + len2; } EXPORT int SumArray(const int* data, int length) { int sum = 0; for (int i = 0; i < length; i++) { sum += data[i]; } return sum; } EXPORT float Distance(const Point3D* p1, const Point3D* p2) { float dx = p1->X - p2->X; float dy = p1->Y - p2->Y; float dz = p1->Z - p2->Z; return sqrtf(dx * dx + dy * dy + dz * dz); }编译命令,Windows 下用 MSVC:
cl /LD native_lib.c /Fe:native_lib.dllLinux 下用 GCC:
gcc -shared -fPIC -o libnative_lib.so native_lib.c -lm注意 Linux 下要加-fPIC生成位置无关代码,否则链接成共享库会报错。-lm是链接数学库,因为用了sqrtf。
3.2 C# 侧的完整封装
C# 这边,我习惯把 P/Invoke 声明放在一个独立的静态类里,命名成NativeMethods或者NativeLib,然后把上层调用封装成更友好的 C# 接口。
using System; using System.Runtime.InteropServices; using System.Text; public static class NativeMethods { private const string LibName = "native_lib"; [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] public static extern int Add(int a, int b); [DllImport(LibName, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)] public static extern int ConcatStrings(string str1, string str2, byte[] output, int outputSize); [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] public static extern int SumArray(int[] data, int length); [StructLayout(LayoutKind.Sequential)] public struct Point3D { public float X; public float Y; public float Z; } [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] public static extern float Distance(ref Point3D p1, ref Point3D p2); }上层封装:
public static class NativeLibWrapper { public static int Add(int a, int b) { return NativeMethods.Add(a, b); } public static string ConcatStrings(string str1, string str2) { byte[] buffer = new byte[1024]; int len = NativeMethods.ConcatStrings(str1, str2, buffer, buffer.Length); if (len < 0) { throw new InvalidOperationException("缓冲区不足"); } return Encoding.UTF8.GetString(buffer, 0, len); } public static int SumArray(int[] data) { return NativeMethods.SumArray(data, data.Length); } public static float Distance(NativeMethods.Point3D p1, NativeMethods.Point3D p2) { return NativeMethods.Distance(ref p1, ref p2); } }这里有几个细节值得说。ConcatStrings的 C 声明里,str1和str2是const char*,C# 这边直接用string配合CharSet.Ansi就能自动封送。但输出缓冲区用byte[]而不是StringBuilder,因为StringBuilder的封送行为在不同 .NET 版本里不太一致,用byte[]更可控。Distance函数接收的是结构体指针,C# 这边用ref传递,CLR 会自动取地址。
3.3 跨平台库名解析
前面写的LibName = "native_lib"在 Windows 下会找native_lib.dll,Linux 下会找libnative_lib.so,macOS 下会找libnative_lib.dylib。这是 CLR 的默认行为,大部分情况下够用。但如果库文件名有特殊前缀或者放在非标准路径,就需要自定义解析。
using System.Reflection; using System.Runtime.InteropServices; using System.IO; public static class NativeLibraryResolver { public static void Register() { NativeLibrary.SetDllImportResolver(typeof(NativeMethods).Assembly, Resolve); } private static IntPtr Resolve(string libraryName, Assembly assembly, DllImportSearchPath? searchPath) { if (libraryName != "native_lib") { return IntPtr.Zero; // 交给默认解析器 } string fileName; if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { fileName = "native_lib.dll"; } else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) { fileName = "libnative_lib.so"; } else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) { fileName = "libnative_lib.dylib"; } else { return IntPtr.Zero; } string baseDir = AppDomain.CurrentDomain.BaseDirectory; string fullPath = Path.Combine(baseDir, "runtimes", GetRid(), "native", fileName); if (File.Exists(fullPath)) { return NativeLibrary.Load(fullPath); } return IntPtr.Zero; } private static string GetRid() { string os = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "win" : RuntimeInformation.IsOSPlatform(OSPlatform.Linux) ? "linux" : "osx"; string arch = RuntimeInformation.ProcessArchitecture switch { Architecture.X64 => "x64", Architecture.X86 => "x86", Architecture.Arm64 => "arm64", _ => "x64" }; return $"{os}-{arch}"; } }这个解析器会在程序启动时注册,之后所有对native_lib的DllImport都会走这个逻辑。好处是库文件可以按 RID 分目录存放,部署的时候把对应平台的库拷进去就行,不用改代码。
4. 常见问题与排查技巧实录
4.1 典型错误与解决方案速查
| 错误现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
DllNotFoundException | 库文件不在搜索路径 | 检查输出目录是否有库文件 | 把库文件拷到 exe 同目录,或用SetDllImportResolver指定路径 |
EntryPointNotFoundException | 函数名修饰不匹配 | 用dumpbin /exports或nm -D查看导出符号 | 用extern "C"和.def文件控制导出名,或用EntryPoint属性指定 |
AccessViolationException | 内存访问越界或指针无效 | 检查数组长度、缓冲区大小、指针是否为空 | 加边界检查,用Marshal.AllocHGlobal分配非托管内存 |
| 返回值乱码 | 调用约定不匹配 | 确认 C 库用的是cdecl还是stdcall | 在DllImport里显式指定CallingConvention |
| 字符串乱码 | 编码不一致 | 确认 C 那边期望的编码 | 用CharSet和MarshalAs指定编码,或手动转字节数组 |
| 结构体数据错位 | 字段对齐或类型大小不一致 | 对比Marshal.SizeOf和 C 的sizeof | 调整StructLayout的Pack和字段类型 |
| 程序崩溃在调用后 | 栈不平衡 | 检查调用约定 | 确保CallingConvention和 C 库一致 |
| 内存泄漏 | 非托管内存未释放 | 用性能监视器观察内存增长 | 确保AllocHGlobal配FreeHGlobal,或让调用方分配缓冲区 |
4.2 调试技巧:如何定位封送问题
封送问题最难的地方在于它往往不报错,只是数据不对。我的经验是分三步走。
第一步,用最简单的函数验证通路。先调一个int Add(int, int),确认能通。如果这个都失败,说明库加载或者调用约定有问题,先解决这个。如果这个能通,说明基础通路没问题,再逐步增加复杂度。
第二步,打印内存布局。对于结构体,在 C 那边用printf("%zu\n", sizeof(MyStruct))打印大小,在 C# 这边用Marshal.SizeOf<MyStruct>()打印大小,对比是否一致。如果不一致,再逐个字段对比偏移量。C 那边可以用offsetof宏,C# 这边可以用Marshal.OffsetOf。
Console.WriteLine($"Size: {Marshal.SizeOf<Config>()}"); Console.WriteLine($"Id offset: {Marshal.OffsetOf<Config>(nameof(Config.Id))}"); Console.WriteLine($"Enabled offset: {Marshal.OffsetOf<Config>(nameof(Config.Enabled))}"); Console.WriteLine($"Threshold offset: {Marshal.OffsetOf<Config>(nameof(Config.Threshold))}");第三步,用Marshal.Copy检查原始字节。如果怀疑某个字段的值不对,可以把结构体转成字节数组,逐个字节打印出来,和 C 那边的内存对比。
int size = Marshal.SizeOf<Config>(); byte[] bytes = new byte[size]; IntPtr ptr = Marshal.AllocHGlobal(size); Marshal.StructureToPtr(config, ptr, false); Marshal.Copy(ptr, bytes, 0, size); Marshal.FreeHGlobal(ptr); for (int i = 0; i < bytes.Length; i++) { Console.Write($"{bytes[i]:X2} "); }这种方式虽然原始,但非常有效。我当年调一个图像处理的结构体,就是靠这个方法发现bool字段占了 4 字节而不是 1 字节,导致后面所有字段都错位了。
4.3 性能优化:减少封送开销
P/Invoke 的性能瓶颈主要在封送。每次调用,CLR 都要做参数转换、栈切换、异常处理框架的建立。如果调用频率是每秒几万次,这些开销就会累积成可观的数字。
优化的第一原则是减少调用次数。比如你要对一个数组的每个元素做处理,不要在 C# 里循环调用 C 函数,而是把整个数组传给 C,让 C 在内部循环。这样封送只发生一次,而不是 N 次。
第二原则是避免不必要的封送。如果参数是基元类型,封送开销很小;如果是字符串或数组,开销就大。能用IntPtr的地方就用IntPtr,手动管理内存,虽然代码丑一点,但性能好很多。
第三原则是复用缓冲区。如果每次调用都需要一个临时缓冲区,不要每次都new byte[],而是预先分配好,反复使用。这样可以减少 GC 压力,也减少封送时的内存分配。
我做过一个测试:对一个 10000 个元素的int数组求和,用 C# 循环调用 C 的Add函数,耗时约 15 毫秒;改成把数组一次性传给 C 的SumArray,耗时约 0.1 毫秒。差距是 150 倍。所以如果性能敏感,一定要批量处理。
4.4 异常处理与资源释放
非托管代码不会抛 C# 异常,它只会返回错误码或者直接崩溃。所以调用 C 函数时,必须检查返回值,不能假设它一定成功。
int result = NativeMethods.ConcatStrings(str1, str2, buffer, buffer.Length); if (result < 0) { throw new InvalidOperationException($"字符串拼接失败,错误码:{result}"); }对于分配了非托管内存的情况,一定要用try/finally确保释放:
IntPtr ptr = Marshal.AllocHGlobal(size); try { // 使用 ptr } finally { Marshal.FreeHGlobal(ptr); }如果 C 库内部有全局状态或者需要初始化/清理,记得在 C# 这边用IDisposable模式封装:
public sealed class NativeContext : IDisposable { private IntPtr _handle; private bool _disposed; public NativeContext() { _handle = NativeMethods.CreateContext(); if (_handle == IntPtr.Zero) { throw new InvalidOperationException("创建上下文失败"); } } public void Dispose() { if (!_disposed) { NativeMethods.DestroyContext(_handle); _handle = IntPtr.Zero; _disposed = true; } } }这样上层用using语句就能确保资源释放,不会泄漏。
5. 进阶话题:从 P/Invoke 到更高效的互操作
5.1 用LibraryImport替代DllImport
.NET 7 之后引入了源生成的LibraryImport,它在编译时生成封送代码,而不是运行时反射。好处是启动更快,AOT 编译友好,而且生成的代码可以内联优化。
using System.Runtime.InteropServices; public static partial class NativeMethods { [LibraryImport("native_lib", EntryPoint = "Add")] [UnmanagedCallConv(CallConvs = new[] { typeof(CallConvCdecl) })] public static partial int Add(int a, int b); }注意LibraryImport要求方法声明为partial,而且类也必须是partial。调用约定用UnmanagedCallConv指定。字符串参数需要用StringMarshalling指定编码:
[LibraryImport("native_lib", StringMarshalling = StringMarshalling.Utf8)] public static partial int ProcessString(string input);LibraryImport目前还不支持所有DllImport能封送的类型,比如StringBuilder和某些自定义封送器。如果遇到不支持的,还是得回退到DllImport。但对于新项目,我建议优先用LibraryImport,尤其是打算做 AOT 编译的话。
5.2 函数指针与回调
有时候 C 库需要回调函数,比如遍历某个数据结构时对每个元素调用一个 C# 的方法。这时候就需要把 C# 的委托转换成非托管函数指针。
[UnmanagedFunctionPointer(CallingConvention.Cdecl)] public delegate int CallbackDelegate(int value); [DllImport("native_lib", CallingConvention = CallingConvention.Cdecl)] public static extern void ForEach(CallbackDelegate callback, int count);调用的时候,CLR 会自动把委托封送成函数指针。但要注意,委托对象必须保持存活,否则 GC 回收了委托,C 那边调用时就会崩溃。所以要么把委托存到一个静态字段里,要么用GCHandle.Alloc固定住。
private static CallbackDelegate _callback = MyCallback; public static void Run() { NativeMethods.ForEach(_callback, 10); } private static int MyCallback(int value) { Console.WriteLine(value); return value * 2; }.NET 5 之后还可以用函数指针语法delegate* unmanaged[Cdecl]<int, int>,更高效,但写起来更底层,适合对性能有极致要求的场景。
5.3 内存管理与 GC 交互
托管和非托管内存的交互是跨语言调用里最微妙的部分。核心原则是:谁分配,谁释放。如果 C 分配了内存返回给 C#,C# 必须知道怎么释放它。如果 C 提供了释放函数,就在 C# 里调用那个函数;如果没有,那就得用 C 的free,但前提是 C 用的是标准库的malloc。
我一般推荐的设计是:C# 分配,C 填充。这样内存归属清晰,C# 用Marshal.AllocHGlobal分配,用完FreeHGlobal释放。C 只负责往里面写数据,不负责分配和释放。这样两边职责分明,不会出现“谁该释放”的扯皮。
对于需要长时间持有的非托管资源,可以用SafeHandle封装。SafeHandle是 .NET 提供的专门用于管理非托管句柄的类,它和 GC 集成,能在对象被回收时自动释放句柄,而且能防止句柄在调用过程中被意外回收。
public sealed class NativeBuffer : SafeHandle { public NativeBuffer(int size) : base(IntPtr.Zero, true) { SetHandle(Marshal.AllocHGlobal(size)); } public override bool IsInvalid => handle == IntPtr.Zero; protected override bool ReleaseHandle() { Marshal.FreeHGlobal(handle); return true; } }用SafeHandle的好处是,即使调用过程中抛异常,句柄也能被正确释放。而且它和 P/Invoke 的封送系统集成得很好,可以直接作为参数类型。
6. 实战经验与避坑清单
6.1 我踩过的那些坑
第一个坑是库文件路径。开发的时候库文件放在项目目录里,用相对路径能加载;发布的时候库文件没被拷到输出目录,程序一运行就报DllNotFoundException。解决办法是在.csproj里配置None项,设置CopyToOutputDirectory为PreserveNewest:
<ItemGroup> <None Include="native_lib.dll"> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> </None> </ItemGroup>第二个坑是32 位和 64 位不匹配。C# 程序编译成 AnyCPU,在 64 位系统上以 64 位运行,但 C 库编译成了 32 位,加载时就会报BadImageFormatException。解决办法是确保两边架构一致,或者用 RID 分目录存放不同架构的库。
第三个坑是调试时库文件被占用。在 Visual Studio 里调试,C 库被进程加载后,重新编译 C 库会失败,因为文件被锁住了。解决办法是先停止调试,再编译 C 库,然后再启动调试。或者用Edit and Continue只改 C# 代码,不改 C 代码。
第四个坑是字符串编码。前面说过了,中文乱码十有八九是编码问题。我的经验是:统一用 UTF-8。C 那边用char*存 UTF-8,C# 这边用byte[]接收,然后Encoding.UTF8.GetString转换。这样跨平台也一致,Windows 和 Linux 下行为相同。
第五个坑是结构体里的数组。C 结构体里如果有固定大小的数组,比如char name[32],C# 这边不能用string或者byte[]直接对应,必须用fixed缓冲区或者ByValArray:
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)] public struct Person { public int Id; [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 32)] public string Name; }ByValTStr表示内联的字符串,SizeConst指定缓冲区大小。这种方式封送时会自动处理编码和长度,但要注意SizeConst必须和 C 那边的数组大小完全一致。
6.2 性能与可维护性的平衡
P/Invoke 代码写多了会发现,它很容易变得又臭又长。每个函数都要声明一遍,每个结构体都要定义一遍,参数类型还要仔细对应。如果 C 库很大,几百个函数,手写 P/Invoke 声明就是噩梦。
我的做法是分层封装。最底层是NativeMethods类,只包含DllImport声明,不做任何逻辑。中间层是NativeLibWrapper,把底层声明封装成符合 C# 习惯的接口,处理错误码、编码转换、内存管理。最上层是业务代码,只调用中间层,完全不感知底层的非托管细节。
这样分层的好处是:底层声明可以自动生成(有些工具能从 C 头文件生成 P/Invoke 声明),中间层可以手写测试,业务层不受底层变化影响。如果 C 库升级了,只需要改底层和中间层,业务层不用动。
另一个经验是尽量用简单类型。能用int就不用IntPtr,能用IntPtr就不用自定义结构体。每增加一层复杂度,就多一个出错的可能。如果 C 库的接口设计得很复杂,可以考虑在 C 那边再包一层简单的接口专门给 C# 用,把复杂性留在 C 那边。
6.3 测试策略
跨语言调用的测试比纯 C# 代码难,因为涉及两个运行时。我的测试策略是分三层。
第一层是单元测试,针对每个 P/Invoke 函数写一个简单的测试,验证基本功能。比如Add(2, 3)返回 5,SumArray(new[] {1, 2, 3})返回 6。这些测试跑得很快,能覆盖大部分封送问题。
第二层是边界测试,测试极端情况。比如空数组、超长字符串、空指针、缓冲区刚好够和差一个字节。这些测试能发现边界条件处理的问题。
第三层是集成测试,模拟真实使用场景,连续调用几百次,观察内存是否泄漏,性能是否达标。我一般会用GC.GetTotalMemory和Process.GetCurrentProcess().WorkingSet64来监控内存变化。
如果条件允许,最好在多个平台上跑测试。Windows 和 Linux 的库加载行为、路径分隔符、编码默认值都不一样,在一个平台上通过不代表在另一个平台也能通过。用 CI 流水线在多个平台上自动跑测试是最稳妥的。
6.4 一个完整的调用示例
最后给一个完整的、可直接参考的示例,把前面说的要点都串起来。
C 库:
// image_lib.h #ifndef IMAGE_LIB_H #define IMAGE_LIB_H #ifdef _WIN32 #define EXPORT __declspec(dllexport) #else #define EXPORT __attribute__((visibility("default"))) #endif #ifdef __cplusplus extern "C" { #endif typedef struct { int Width; int Height; int Channels; } ImageInfo; EXPORT int InvertImage(unsigned char* data, int length); EXPORT int GetImageInfo(const char* path, ImageInfo* info); EXPORT int ProcessImage(unsigned char* data, int width, int height, int channels); #ifdef __cplusplus } #endif #endifC# 封装:
using System; using System.Runtime.InteropServices; using System.Text; public static class ImageLib { private const string LibName = "image_lib"; [StructLayout(LayoutKind.Sequential)] public struct ImageInfo { public int Width; public int Height; public int Channels; } [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] private static extern int InvertImage(byte[] data, int length); [DllImport(LibName, CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)] private static extern int GetImageInfo(string path, out ImageInfo info); [DllImport(LibName, CallingConvention = CallingConvention.Cdecl)] private static extern int ProcessImage(byte[] data, int width, int height, int channels); public static void Invert(byte[] pixels) { int result = InvertImage(pixels, pixels.Length); if (result != 0) { throw new InvalidOperationException($"反色失败,错误码:{result}"); } } public static ImageInfo GetInfo(string path) { int result = GetImageInfo(path, out ImageInfo info); if (result != 0) { throw new InvalidOperationException($"获取图像信息失败,错误码:{result}"); } return info; } public static void Process(byte[] pixels, int width, int height, int channels) { int result = ProcessImage(pixels, width, height, channels); if (result != 0) { throw new InvalidOperationException($"处理图像失败,错误码:{result}"); } } }调用:
byte[] pixels = new byte[1920 * 1080 * 3]; // 填充像素数据... ImageLib.Invert(pixels); var info = ImageLib.GetInfo("test.jpg"); Console.WriteLine($"图像尺寸:{info.Width}x{info.Height},通道数:{info.Channels}");这个示例里,InvertImage直接接收byte[],CLR 会自动固定数组并传递指针。GetImageInfo用out参数返回结构体,CLR 会在栈上分配结构体内存,调用完成后复制回托管堆。ProcessImage接收多个参数,都是基元类型,封送开销很小。
如果InvertImage需要频繁调用,比如对视频的每一帧都调用,那么每次都固定byte[]会有性能问题。这时候可以改成用IntPtr手动管理内存,预先分配好缓冲区,反复使用。
IntPtr buffer = Marshal.AllocHGlobal(maxSize); try { // 每帧:拷贝数据到 buffer,调用,拷贝回来 Marshal.Copy(frameData, 0, buffer, frameSize); NativeMethods.InvertImagePtr(buffer, frameSize); Marshal.Copy(buffer, frameData, 0, frameSize); } finally { Marshal.FreeHGlobal(buffer); }这种方式虽然代码多,但性能最好,适合对帧率有要求的场景。
跨语言调用这件事,说到底就是“约定大于配置”。两边的类型、编码、调用约定、内存归属,每一项都必须严格对齐。对齐了,它就像调用本地方法一样顺畅;对不齐,就是各种莫名其妙的崩溃和乱码。我的经验是:从最简单的函数开始,逐步增加复杂度,每一步都验证,不要一次性写一大堆然后一起调试。这样出问题的时候,你能快速定位是哪一层的问题。另外,多写测试,多打印日志,尤其是在封送边界上,把关键参数的值和内存布局打出来,比盯着代码猜要高效得多。