1. 项目概述与核心价值
最近在做一个工业控制的上位机项目,底层硬件驱动和核心算法库是C++写的,上层界面和业务逻辑用C#开发,这就绕不开C#和C++的互操作。网上资料不少,但真到自己动手,各种“坑”就冒出来了:内存怎么对齐?字符串怎么传?回调函数怎么设?一个参数没对好,程序直接崩溃,连个像样的错误提示都没有。这不仅仅是调用一个函数那么简单,它涉及到两种语言运行时、内存管理模型、数据类型体系的深度对接。搞明白了,你就能在C#里优雅地驾驭那些高性能的C++遗产代码或第三方库;搞不明白,就是无尽的调试噩梦。这篇文章,我就结合自己趟过的雷,把C#与C++互操作的核心套路、关键细节和避坑指南系统地捋一遍,目标是让你看完就能在自己的项目里用起来,少走弯路。
2. 互操作的核心机制与方案选型
C#(.NET)和C++是两套完全不同的“生态系统”。C#运行在托管环境(CLR)中,享受自动垃圾回收(GC)的便利,但内存访问受限;C++则是原生环境,直接操作内存,性能极致但风险自担。让它们对话,主要靠以下几种桥梁,选择哪种,取决于你的具体场景和C++代码的形态。
2.1 P/Invoke:调用标准C接口DLL的首选
这是最常用、最直接的方式。你的C++代码需要编译成标准的、导出C风格函数的动态链接库(DLL)。C#通过[DllImport]属性来声明这些外部函数,CLR在运行时负责完成从托管堆到原生堆的参数封送(Marshaling)。
为什么首选P/Invoke?
- 简单直接:对于已有的、符合C调用约定的DLL,接入成本最低。你不需要修改C++代码的内部实现(只要接口是C风格的)。
- 通用性强:Windows平台原生支持,.NET Framework和.NET Core/.NET 5+都提供完善支持。
- 场景匹配:非常适合调用操作系统API、 legacy的C库、或者专门为跨语言调用而编写的C接口封装层。
它的局限性也很明显:
- 只能调用平面函数(flat function),无法直接调用C++的类(class)、成员函数。
- 参数和返回值的封送需要仔细处理,特别是涉及指针、结构体、回调函数时。
- 错误处理依赖返回值或Win32的
GetLastError机制。
2.2 C++/CLI:托管与原生代码的“混血儿”
如果你的互操作需求非常复杂,需要频繁在C#和C++对象之间交互,或者需要将现有的C++类库直接暴露给C#使用,C++/CLI是一个强大的选项。它是一种特殊的C++方言,可以编译成托管程序集(.dll),其中既可以包含纯原生C++代码,也可以包含使用托管类型(ref class)的代码。
为什么考虑C++/CLI?
- 无缝集成:可以在同一个项目、甚至同一个函数里混合编写原生C++和托管C#代码(通过
gcnew等)。 - 对象互操作:可以直接将原生C++对象指针包装成托管对象,让C#以引用对象的方式使用它。
- 充当完美适配层:对于复杂的C++类库,可以编写一个C++/CLI中间层,将C++类的方法包装成托管类的方法,对C#提供非常自然的API。
它的代价是:
- 增加了技术栈的复杂性,需要开发者同时熟悉C++和.NET。
- 编译出的程序集依赖于特定的.NET运行时和VC++运行时。
- 在纯.NET Core(非Windows)场景下支持有限(传统上更多用于Windows桌面开发)。
2.3 COM Interop:对接传统Windows组件
如果互操作的对方是COM组件(一种古老的Windows二进制组件标准),.NET提供了完整的COM互操作支持。你可以通过“添加引用”的方式直接引入COM组件,Visual Studio会自动生成一个互操作程序集(Interop Assembly),其中包含了COM组件中接口和类的托管包装。
何时使用COM Interop?主要是在维护或集成遗留的、基于COM技术构建的软件模块时,例如一些老的自动化控件、Office插件等。对于全新的项目,除非有强制要求,一般不再推荐主动采用COM技术。
在我们的实践中,P/Invoke是覆盖场景最广、也最需要扎实掌握的基础技能。接下来的内容将主要围绕P/Invoke展开,因为其中遇到的绝大多数问题,其解决思路也适用于其他互操作方式。
3. P/Invoke实战:从基础调用到复杂数据传递
让我们从一个最简单的例子开始,逐步增加复杂度。
3.1 基础函数调用与数据类型映射
假设我们有一个C++ DLL,导出了一个简单的加法函数。
C++端 (NativeMath.dll):
// 使用 extern "C" 避免C++的名称修饰(name mangling) extern "C" { __declspec(dllexport) int Add(int a, int b) { return a + b; } }C#端调用:
using System; using System.Runtime.InteropServices; public class NativeMathInterop { // 关键:DllImport属性 [DllImport("NativeMath.dll", CallingConvention = CallingConvention.Cdecl)] public static extern int Add(int a, int b); } class Program { static void Main() { int result = NativeMathInterop.Add(5, 3); Console.WriteLine($"5 + 3 = {result}"); // 输出 8 } }这里有几个关键点:
extern "C":在C++中,编译器会对函数名进行修饰(根据参数类型、命名空间等生成唯一符号),这会导致C#端找不到函数。extern "C"强制使用C语言的链接约定,确保导出的函数名是简单的Add。__declspec(dllexport):这是Windows VC++的语法,用于声明该函数需要从DLL中导出。[DllImport]属性:指定DLL的名称和路径。如果DLL不在应用程序目录或系统路径,需要提供完整路径或通过SetDllDirectory设置。CallingConvention:指定调用约定。C++中常用的有Cdecl(C语言默认)和Stdcall(Win32 API常用)。必须与C++端的声明匹配,否则会导致栈不平衡和程序崩溃。上例中C++函数默认是Cdecl,所以我们也指定为CallingConvention.Cdecl。
基本数据类型映射表:这是互操作的基石,必须牢记。
| C/C++ 类型 | Windows 类型 | C# 类型 | 说明 |
|---|---|---|---|
bool | BOOL | bool或int | C++的bool是1字节,Windows的BOOL是int(4字节),值通常为0/1。 |
char | CHAR | byte或sbyte | 单字节字符。处理文本时通常不用它。 |
wchar_t | WCHAR | char | 宽字符(2字节),对应C#的Unicode字符。 |
short | SHORT | short | 16位有符号整数。 |
int | INT,LONG | int | 32位有符号整数。注意:Windows的LONG总是32位。 |
long long | LONGLONG | long | 64位有符号整数。 |
float | FLOAT | float | 单精度浮点数。 |
double | DOUBLE | double | 双精度浮点数。 |
char*(ANSI) | LPCSTR | string | 指向以null结尾的ANSI字符串的指针。C#会自动进行封送。 |
wchar_t*(Unicode) | LPCWSTR | string | 指向以null结尾的Unicode字符串的指针。C#默认使用Unicode,这是最推荐的。 |
void* | LPVOID | IntPtr | 指向任意类型内存的指针。是处理复杂内存操作的“万能钥匙”。 |
int&(引用) | - | ref int | 传递整数的引用。 |
int*(指针) | int* | ref int或IntPtr | 传递整数的指针。如果函数内部会修改值,用ref;如果传递指针数组或复杂结构,用IntPtr。 |
注意:字符串传递的坑。默认情况下,C#的
string封送给C++时是作为LPCWSTR(Unicode)传递的。如果你的C++函数期望的是LPCSTR(ANSI),必须显式指定字符集:[DllImport("...", CharSet = CharSet.Ansi)]。反之亦然。最佳实践是统一使用Unicode(CharSet.Unicode),并在C++端使用wchar_t*。
3.2 结构体(Struct)的封送:内存布局是关键
当需要传递多个相关数据时,结构体就派上用场了。互操作中的结构体,核心是内存布局必须完全一致。
假设C++端有一个表示点的结构体和相关函数:
C++端:
extern "C" { struct Point { int x; int y; }; __declspec(dllexport) void MovePoint(Point* pt, int deltaX, int deltaY) { if (pt) { pt->x += deltaX; pt->y += deltaY; } } __declspec(dllexport) Point CreatePoint(int x, int y) { Point pt = {x, y}; return pt; // 返回结构体副本 } }C#端定义与调用:
[StructLayout(LayoutKind.Sequential)] // 关键:顺序布局 public struct Point { public int x; public int y; } public class PointInterop { [DllImport("NativeGeometry.dll", CallingConvention = CallingConvention.Cdecl)] public static extern void MovePoint(ref Point pt, int deltaX, int deltaY); [DllImport("NativeGeometry.dll", CallingConvention = CallingConvention.Cdecl)] public static extern Point CreatePoint(int x, int y); } class Program { static void Main() { // 调用返回结构体的函数 Point p1 = PointInterop.CreatePoint(10, 20); Console.WriteLine($"Created Point: ({p1.x}, {p1.y})"); // 调用修改结构体指针的函数 PointInterop.MovePoint(ref p1, 5, -5); Console.WriteLine($"Moved Point: ({p1.x}, {p1.y})"); // 输出 (15, 15) } }核心要点与避坑指南:
[StructLayout(LayoutKind.Sequential)]:这是必须的!它告诉CLR按照字段声明的顺序在内存中排列结构体成员,这与C/C++的默认行为一致。如果没有这个属性,CLR可能会为了优化而重新排列字段,导致内存布局对不上。- 字段顺序和类型必须严格匹配:C#结构体中的字段定义顺序、类型必须与C++结构体完全一致。
int对int,float对float。 - 处理指针/引用:如果C++函数接受结构体指针(
Point*),在C#中对应使用ref Point(如果函数内部修改)或out Point(如果函数负责填充)。ref和out在封送时都会传递指针。 - 返回结构体:像
CreatePoint这样直接返回结构体是可行的,但要注意,如果结构体很大,返回值的效率可能不高(涉及拷贝)。更常见的做法是让C++函数通过指针参数来“返回”结构体。 - 内存对齐(Pack):编译器可能会在结构体成员之间插入填充字节(padding)以使数据对齐到特定内存地址(通常是4或8字节的倍数),这能提升CPU访问速度。C++和C#的默认对齐规则可能不同。这是结构体互操作中最隐蔽的坑!
- 问题现象:你定义的结构体字段顺序、类型都对,但调用函数后,读到的值全是乱码或者程序崩溃。
- 解决方案:使用
[StructLayout(LayoutKind.Sequential, Pack = n)]来显式指定包装大小。Pack = 1表示按1字节对齐(无填充),Pack = 4表示按4字节对齐。你需要查看C++编译器的设置(通常是#pragma pack(n))或反汇编来确定C++端的对齐方式,然后在C#端匹配。 - 实操技巧:一个快速验证内存布局的方法是,在C++端用
sizeof(YourStruct),在C#端用Marshal.SizeOf(typeof(YourStruct)),比较两者大小是否一致。如果不一致,几乎肯定是内存对齐或字段定义出了问题。
3.3 回调函数(Callbacks)与委托(Delegates)
让C++代码能够调用回C#的函数,这是实现事件驱动、异步通知等高级功能的核心。在C#中,我们使用委托(Delegate)来对应C++的函数指针。
C++端声明一个接受回调的函数:
// 定义回调函数类型:接受一个int和const char*,返回void typedef void (*LogCallback)(int level, const char* message); extern "C" { __declspec(dllexport) void SetLogger(LogCallback callback) { // 保存回调函数指针,在适当的时候调用 if (callback) { callback(1, "Logger initialized from C++."); } } }C#端实现与调用:
public class LoggerInterop { // 1. 定义与C++函数指针匹配的委托 // [UnmanagedFunctionPointer(CallingConvention.Cdecl)] // 必须指定调用约定! public delegate void LogCallbackDelegate(int level, [MarshalAs(UnmanagedType.LPStr)] string message); // 2. 声明导入函数 [DllImport("NativeLogger.dll", CallingConvention = CallingConvention.Cdecl)] public static extern void SetLogger(LogCallbackDelegate callback); } class Program { // 3. 实现一个符合委托签名的方法 private static void MyLogger(int level, string message) { Console.WriteLine($"[Level {level}] {message}"); } static void Main() { // 4. 创建委托实例并传递 LoggerInterop.LogCallbackDelegate callback = MyLogger; // 关键:必须保持委托实例的引用,防止被GC回收! // 通常保存为类的静态字段或长生命周期的变量。 LoggerInterop.SetLogger(callback); // 程序运行后,C++端会调用MyLogger,输出 "[Level 1] Logger initialized from C++." } }回调函数的核心陷阱与解决方案:
- 调用约定必须匹配:
[UnmanagedFunctionPointer]属性中的CallingConvention必须与C++端函数指针的调用约定一致(通常是Cdecl或Stdcall)。不匹配会导致栈损坏和崩溃。 - 委托实例的生命周期:这是新手最容易栽跟头的地方。当你把委托实例传递给C++后,C++保存的只是一个函数指针。如果C#端的委托实例被垃圾回收(GC)了,那么这个函数指针就变成了“野指针”,C++再调用它必然导致访问违规(Access Violation)。
- 解决方案:将委托实例保存为一个静态字段或类的成员字段(只要该类的实例一直存活),确保在C++可能调用的整个生命周期内,委托都不会被GC回收。
- 错误示例:
SetLogger(new LogCallbackDelegate(MyLogger));// 临时委托,很快被回收,危险!
- 字符串封送:上例中C++端是
const char*(ANSI),所以C#端委托参数需要用[MarshalAs(UnmanagedType.LPStr)]修饰。如果C++是const wchar_t*,则用UnmanagedType.LPWStr。 - 在多线程环境下:如果C++可能从非创建委托的线程(比如一个工作线程)调用回调,你需要确保C#端的回调方法是线程安全的。更复杂的情况是,C#端的回调需要更新UI,这时就必须通过控件的
Invoke或BeginInvoke方法切换到UI线程。
3.4 复杂数据交互:数组、字符串缓冲区与手动内存管理
当需要传递大量数据(如图像像素数组)或可变长度的字符串时,就需要更精细地控制内存。
场景:C++函数填充一个字符串缓冲区。
C++端:
extern "C" { // 函数:将整数value格式化为字符串,写入提供的缓冲区。 // buffer: 输出缓冲区 // bufferSize: 缓冲区大小(字符数,包括结尾的null) // 返回:实际写入的字符数(不包括null),如果缓冲区不足则返回需要的字符数。 __declspec(dllexport) int FormatValue(int value, char* buffer, int bufferSize) { if (!buffer || bufferSize <= 0) return -1; // 错误 int needed = snprintf(nullptr, 0, "Value: %d", value); if (needed < bufferSize) { snprintf(buffer, bufferSize, "Value: %d", value); return needed; // 成功,返回写入长度 } else { return needed; // 缓冲区不足,返回所需大小 } } }C#端调用策略:这里有两种常见模式,体现了互操作中“先查询,后分配”的经典思维。
模式一:C#分配固定大小的缓冲区。
[DllImport("NativeString.dll", CallingConvention = CallingConvention.Cdecl)] public static extern int FormatValue(int value, byte[] buffer, int bufferSize); static void Main() { int value = 42; int bufferSize = 256; // 预估一个足够大的大小 byte[] buffer = new byte[bufferSize]; // 分配字节数组 int result = FormatValue(value, buffer, bufferSize); if (result >= 0 && result < bufferSize) { // 成功,将字节数组转换为字符串(假设是ANSI) string str = System.Text.Encoding.Default.GetString(buffer, 0, result); Console.WriteLine(str); // 输出 "Value: 42" } else if (result >= bufferSize) { Console.WriteLine($"Buffer too small. Need {result} bytes."); } }注意:这里使用
byte[]对应char*,因为char在C++中是1字节。封送器会自动将byte[]的指针传递给C++函数。
模式二(更安全):两次调用法。
[DllImport("NativeString.dll", CallingConvention = CallingConvention.Cdecl)] public static extern int FormatValue(int value, IntPtr buffer, int bufferSize); // 改为IntPtr static void Main() { int value = 42; // 第一次调用:传入空指针,获取所需缓冲区大小 int neededSize = FormatValue(value, IntPtr.Zero, 0); if (neededSize > 0) { // 分配精确大小的非托管内存 IntPtr bufferPtr = Marshal.AllocHGlobal(neededSize + 1); // +1 for null terminator try { // 第二次调用:传入分配好的内存指针 int actualSize = FormatValue(value, bufferPtr, neededSize + 1); if (actualSize >= 0) { // 从非托管内存读取字符串 string str = Marshal.PtrToStringAnsi(bufferPtr); // ANSI编码 Console.WriteLine(str); } } finally { // 务必释放非托管内存! Marshal.FreeHGlobal(bufferPtr); } } }模式二的优势:避免了预估缓冲区大小可能不足或浪费的问题,更加精确和安全。它要求C++函数支持“查询模式”(传入null或0大小返回所需长度),这是很多Win32 API的设计模式。
手动内存管理(IntPtr与Marshal类): 当数据交换变得复杂(如传递结构体指针数组、在C#中分配内存让C++填充等),IntPtr和Marshal类就是你的瑞士军刀。
Marshal.AllocHGlobal:从非托管内存中分配指定字节数的内存块,返回IntPtr。Marshal.Copy:在托管数组(如byte[],int[])和非托管内存指针(IntPtr)之间复制数据。Marshal.PtrToStructure:将非托管内存块的内容转换为托管结构体对象。Marshal.StructureToPtr:将托管结构体对象的内容复制到非托管内存块。Marshal.FreeHGlobal:释放由AllocHGlobal分配的内存。必须成对使用,否则内存泄漏!
一个综合示例:传递结构体数组。假设C++函数需要处理一个Point数组。
// C++: void ProcessPoints(Point* points, int count); [DllImport("NativeGeometry.dll")] public static extern void ProcessPoints(IntPtr pointsPtr, int count); static void ProcessPointArray(Point[] points) { // 计算整个数组需要的内存大小 int structSize = Marshal.SizeOf(typeof(Point)); int totalSize = structSize * points.Length; // 分配非托管内存 IntPtr arrayPtr = Marshal.AllocHGlobal(totalSize); try { // 将托管数组的每个元素复制到非托管内存 for (int i = 0; i < points.Length; i++) { // 计算当前结构体在非托管内存中的位置 IntPtr itemPtr = new IntPtr(arrayPtr.ToInt64() + (i * structSize)); Marshal.StructureToPtr(points[i], itemPtr, false); // false表示不销毁旧内容 } // 调用C++函数 ProcessPoints(arrayPtr, points.Length); // (可选)如果C++修改了数据,再复制回托管数组 for (int i = 0; i < points.Length; i++) { IntPtr itemPtr = new IntPtr(arrayPtr.ToInt64() + (i * structSize)); points[i] = Marshal.PtrToStructure<Point>(itemPtr); } } finally { Marshal.FreeHGlobal(arrayPtr); } }4. 高级主题与性能优化
掌握了基础,我们来看看如何让互操作更高效、更健壮。
4.1 错误处理与异常传递
P/Invoke本身不会将C++的错误自动转换为C#异常。常见的错误处理方式有:
- 返回值检查:大多数C函数使用返回值表示成功/失败(如0成功,非0错误码)。调用后检查返回值。
GetLastError:许多Win32 API在失败时会调用SetLastError设置线程最后的错误码。在C#中,需要在[DllImport]中设置SetLastError = true,然后调用后立即用Marshal.GetLastWin32Error()获取错误码。[DllImport("kernel32.dll", SetLastError = true)] public static extern IntPtr CreateFile(...); IntPtr handle = CreateFile(...); if (handle == IntPtr.Zero || handle.ToInt32() == -1) { int errorCode = Marshal.GetLastWin32Error(); // 根据errorCode抛出异常或处理 throw new Win32Exception(errorCode); }- 自定义异常:对于自己的C++库,可以设计更丰富的错误信息传递机制,比如通过回调函数返回错误详情,或者通过输出参数返回错误结构体。
4.2 性能优化要点
互操作是有开销的(参数封送、托管/非托管转换、可能的线程切换)。在性能敏感的场景下要注意:
- 减少调用频率:避免在循环中频繁调用简单的P/Invoke函数。尽量将批量操作在C++端完成,一次调用处理大量数据。
- 使用
blittable类型:“Blittable”类型是指在托管和非托管内存中具有相同位表示形式的类型,如int,double,IntPtr以及只包含blittable类型字段的结构体。封送这些类型时,CLR可以直接进行内存拷贝,速度最快。非blittable类型(如bool,char,string)需要转换,开销较大。 - 固定托管内存(Pinning):如果你需要将一个托管数组(如
byte[])的指针长期传递给C++使用(而不是一次调用),必须“固定”该数组,防止GC在压缩堆时移动它。使用fixed语句或GCHandle.Alloc(..., GCHandleType.Pinned)。byte[] data = new byte[1024]; // 使用fixed语句(仅在fixed块内有效) unsafe { fixed (byte* pData = data) { // 调用C++函数,传递 (IntPtr)pData ProcessData((IntPtr)pData, data.Length); } } // 或者使用GCHandle(长期固定) GCHandle handle = GCHandle.Alloc(data, GCHandleType.Pinned); IntPtr ptr = handle.AddrOfPinnedObject(); // ... 调用函数,ptr可以长期使用 ... handle.Free(); // 务必记得释放!警告:长期固定内存会妨碍GC工作,可能导致堆碎片化,只在必要时使用,并尽快释放。
- 考虑使用
Span<T>或Memory<T>:在.NET Core/.NET 5+中,对于需要与非托管代码交互的缓冲区,Span<byte>和Memory<byte>是更现代、更安全的选择,它们可以与fixed或MemoryMarshal结合使用,提供更好的性能和安全保障。
4.3 调试技巧:当互操作崩溃时
- 启用本机代码调试:在Visual Studio项目属性中,“调试”标签页下,勾选“启用本机代码调试”。这样当崩溃发生在C++ DLL内部时,调试器可以跳转到C++代码(如果你有源码和符号文件)。
- 使用
DebugBreak或__debugbreak():在怀疑有问题的C++代码处插入__debugbreak()(VC++),运行时会触发调试器中断。 - 检查调用堆栈:崩溃时,查看调用堆栈窗口,确定崩溃发生在托管代码还是非托管代码,以及具体的函数。
- 使用
Marshal.GetLastWin32Error:对于Win32 API调用,及时获取错误码是定位问题的关键。 - 日志输出:在C++和C#两端都添加详细的日志输出,记录函数入口、参数值、内存地址等信息。
5. 实战案例:封装一个简单的C++日志库给C#使用
让我们用一个完整的、稍微复杂点的例子来串联所有知识点。目标:一个C++的日志库,支持设置日志级别、输出回调,并在C#中优雅使用。
C++端 (NativeLogger.dll):
// Logger.h #pragma once #ifdef NATIVELOGGER_EXPORTS #define LOGGER_API __declspec(dllexport) #else #define LOGGER_API __declspec(dllimport) #endif #include <string> // 日志级别枚举 enum LogLevel { LOG_DEBUG = 0, LOG_INFO, LOG_WARN, LOG_ERROR }; // 日志回调函数指针类型 typedef void (*LogCallbackFunc)(LogLevel level, const wchar_t* message, void* userData); extern "C" { // 初始化/清理 LOGGER_API void Logger_Initialize(); LOGGER_API void Logger_Shutdown(); // 设置回调 LOGGER_API void Logger_SetCallback(LogCallbackFunc callback, void* userData); // 写日志 LOGGER_API void Logger_Log(LogLevel level, const wchar_t* message); }// Logger.cpp #include "Logger.h" #include <vector> #include <mutex> static LogCallbackFunc s_callback = nullptr; static void* s_userData = nullptr; static std::mutex s_callbackMutex; LOGGER_API void Logger_Initialize() { // 初始化内部资源 } LOGGER_API void Logger_Shutdown() { std::lock_guard<std::mutex> lock(s_callbackMutex); s_callback = nullptr; s_userData = nullptr; // 清理资源 } LOGGER_API void Logger_SetCallback(LogCallbackFunc callback, void* userData) { std::lock_guard<std::mutex> lock(s_callbackMutex); s_callback = callback; s_userData = userData; } LOGGER_API void Logger_Log(LogLevel level, const wchar_t* message) { std::lock_guard<std::mutex> lock(s_callbackMutex); if (s_callback) { s_callback(level, message, s_userData); } // 也可以同时输出到文件或控制台 // ... }C#端封装类:
using System; using System.Runtime.InteropServices; using System.Text; public enum LogLevel { Debug = 0, Info, Warn, Error } public sealed class NativeLogger : IDisposable { // 1. 定义与C++回调匹配的委托 [UnmanagedFunctionPointer(CallingConvention.Cdecl)] private delegate void LogCallbackDelegate(LogLevel level, [MarshalAs(UnmanagedType.LPWStr)] string message, IntPtr userData); // 2. 导入函数 [DllImport("NativeLogger.dll", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Unicode)] private static extern void Logger_Initialize(); [DllImport("NativeLogger.dll", CallingConvention = CallingConvention.Cdecl)] private static extern void Logger_Shutdown(); [DllImport("NativeLogger.dll", CallingConvention = CallingConvention.Cdecl)] private static extern void Logger_SetCallback(LogCallbackDelegate callback, IntPtr userData); [DllImport("NativeLogger.dll", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Unicode)] private static extern void Logger_Log(LogLevel level, [MarshalAs(UnmanagedType.LPWStr)] string message); // 3. 保存委托实例,防止GC private LogCallbackDelegate _managedCallback; private static NativeLogger _instance; // 4. 提供C#风格的事件或方法 public event Action<LogLevel, string> OnLogMessage; private NativeLogger() { Logger_Initialize(); _managedCallback = HandleLogCallback; // 将this对象的指针作为userData传递,以便在回调中识别实例 IntPtr userData = GCHandle.ToIntPtr(GCHandle.Alloc(this)); Logger_SetCallback(_managedCallback, userData); } public static NativeLogger Instance => _instance ?? (_instance = new NativeLogger()); // 5. 回调处理方法 private void HandleLogCallback(LogLevel level, string message, IntPtr userData) { // 通过userData可以找到对应的NativeLogger实例(如果需要支持多实例) // 这里我们直接触发事件 OnLogMessage?.Invoke(level, message); } public void Log(LogLevel level, string message) { Logger_Log(level, message); } public void Dispose() { if (_instance == this) { Logger_Shutdown(); _instance = null; } // 注意:这里我们简化了,实际应该释放为userData分配的GCHandle } } // 使用示例 class Program { static void Main() { var logger = NativeLogger.Instance; logger.OnLogMessage += (level, msg) => { Console.WriteLine($"[{level}] {msg}"); }; logger.Log(LogLevel.Info, "Hello from C#!"); // C++端收到日志,并通过回调触发OnLogMessage事件,输出 "[Info] Hello from C#!" // 测试C++直接触发回调 // 假设C++内部某个条件触发了Logger_Log // 输出将由C#事件处理程序打印 } }这个案例的要点总结:
- 封装:将原始的P/Invoke调用封装在一个托管类中,提供更符合C#习惯的API(如事件、属性)。
- 生命周期管理:实现了
IDisposable,确保在不再使用时能正确清理C++端的资源。 - 回调与实例关联:通过
GCHandle和userData参数,可以在静态回调中定位到具体的托管对象实例,这对于支持多实例的库是必要的。 - 线程安全:C++端使用了
std::mutex保护回调指针,因为回调可能被多个线程调用。 - Unicode字符串:全程使用
wchar_t*和CharSet.Unicode,避免ANSI/Unicode转换的麻烦和潜在性能损失。
互操作就像在两个语言王国之间修建一座坚固的桥梁。理解数据如何过桥(封送)、桥的承重规则(调用约定、内存布局)、以及如何管理桥上的交通(内存、回调),是确保这座桥畅通无阻的关键。从简单的函数调用开始,逐步处理结构体、字符串、数组、回调,再到封装完整的库,每一步都踩稳了,你就能在C#的世界里,自由地调用那些强大而高效的C++代码,把两种语言的优势结合起来,构建出更出色的应用。