news 2026/8/12 23:36:31

C#与C++互操作实战:P/Invoke核心机制、内存管理与避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C#与C++互操作实战:P/Invoke核心机制、内存管理与避坑指南

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?

  1. 简单直接:对于已有的、符合C调用约定的DLL,接入成本最低。你不需要修改C++代码的内部实现(只要接口是C风格的)。
  2. 通用性强:Windows平台原生支持,.NET Framework和.NET Core/.NET 5+都提供完善支持。
  3. 场景匹配:非常适合调用操作系统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?

  1. 无缝集成:可以在同一个项目、甚至同一个函数里混合编写原生C++和托管C#代码(通过gcnew等)。
  2. 对象互操作:可以直接将原生C++对象指针包装成托管对象,让C#以引用对象的方式使用它。
  3. 充当完美适配层:对于复杂的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 } }

这里有几个关键点:

  1. extern "C":在C++中,编译器会对函数名进行修饰(根据参数类型、命名空间等生成唯一符号),这会导致C#端找不到函数。extern "C"强制使用C语言的链接约定,确保导出的函数名是简单的Add
  2. __declspec(dllexport):这是Windows VC++的语法,用于声明该函数需要从DLL中导出。
  3. [DllImport]属性:指定DLL的名称和路径。如果DLL不在应用程序目录或系统路径,需要提供完整路径或通过SetDllDirectory设置。
  4. CallingConvention:指定调用约定。C++中常用的有Cdecl(C语言默认)和Stdcall(Win32 API常用)。必须与C++端的声明匹配,否则会导致栈不平衡和程序崩溃。上例中C++函数默认是Cdecl,所以我们也指定为CallingConvention.Cdecl

基本数据类型映射表:这是互操作的基石,必须牢记。

C/C++ 类型Windows 类型C# 类型说明
boolBOOLboolintC++的bool是1字节,Windows的BOOLint(4字节),值通常为0/1。
charCHARbytesbyte单字节字符。处理文本时通常不用它。
wchar_tWCHARchar宽字符(2字节),对应C#的Unicode字符。
shortSHORTshort16位有符号整数。
intINT,LONGint32位有符号整数。注意:Windows的LONG总是32位。
long longLONGLONGlong64位有符号整数。
floatFLOATfloat单精度浮点数。
doubleDOUBLEdouble双精度浮点数。
char*(ANSI)LPCSTRstring指向以null结尾的ANSI字符串的指针。C#会自动进行封送。
wchar_t*(Unicode)LPCWSTRstring指向以null结尾的Unicode字符串的指针。C#默认使用Unicode,这是最推荐的。
void*LPVOIDIntPtr指向任意类型内存的指针。是处理复杂内存操作的“万能钥匙”。
int&(引用)-ref int传递整数的引用。
int*(指针)int*ref intIntPtr传递整数的指针。如果函数内部会修改值,用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) } }

核心要点与避坑指南:

  1. [StructLayout(LayoutKind.Sequential)]:这是必须的!它告诉CLR按照字段声明的顺序在内存中排列结构体成员,这与C/C++的默认行为一致。如果没有这个属性,CLR可能会为了优化而重新排列字段,导致内存布局对不上。
  2. 字段顺序和类型必须严格匹配:C#结构体中的字段定义顺序、类型必须与C++结构体完全一致。intintfloatfloat
  3. 处理指针/引用:如果C++函数接受结构体指针(Point*),在C#中对应使用ref Point(如果函数内部修改)或out Point(如果函数负责填充)。refout在封送时都会传递指针。
  4. 返回结构体:像CreatePoint这样直接返回结构体是可行的,但要注意,如果结构体很大,返回值的效率可能不高(涉及拷贝)。更常见的做法是让C++函数通过指针参数来“返回”结构体。
  5. 内存对齐(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++." } }

回调函数的核心陷阱与解决方案:

  1. 调用约定必须匹配[UnmanagedFunctionPointer]属性中的CallingConvention必须与C++端函数指针的调用约定一致(通常是CdeclStdcall)。不匹配会导致栈损坏和崩溃。
  2. 委托实例的生命周期:这是新手最容易栽跟头的地方。当你把委托实例传递给C++后,C++保存的只是一个函数指针。如果C#端的委托实例被垃圾回收(GC)了,那么这个函数指针就变成了“野指针”,C++再调用它必然导致访问违规(Access Violation)。
    • 解决方案:将委托实例保存为一个静态字段类的成员字段(只要该类的实例一直存活),确保在C++可能调用的整个生命周期内,委托都不会被GC回收。
    • 错误示例SetLogger(new LogCallbackDelegate(MyLogger));// 临时委托,很快被回收,危险!
  3. 字符串封送:上例中C++端是const char*(ANSI),所以C#端委托参数需要用[MarshalAs(UnmanagedType.LPStr)]修饰。如果C++是const wchar_t*,则用UnmanagedType.LPWStr
  4. 在多线程环境下:如果C++可能从非创建委托的线程(比如一个工作线程)调用回调,你需要确保C#端的回调方法是线程安全的。更复杂的情况是,C#端的回调需要更新UI,这时就必须通过控件的InvokeBeginInvoke方法切换到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的设计模式。

手动内存管理(IntPtrMarshal类): 当数据交换变得复杂(如传递结构体指针数组、在C#中分配内存让C++填充等),IntPtrMarshal类就是你的瑞士军刀。

  • 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#异常。常见的错误处理方式有:

  1. 返回值检查:大多数C函数使用返回值表示成功/失败(如0成功,非0错误码)。调用后检查返回值。
  2. 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); }
  3. 自定义异常:对于自己的C++库,可以设计更丰富的错误信息传递机制,比如通过回调函数返回错误详情,或者通过输出参数返回错误结构体。

4.2 性能优化要点

互操作是有开销的(参数封送、托管/非托管转换、可能的线程切换)。在性能敏感的场景下要注意:

  1. 减少调用频率:避免在循环中频繁调用简单的P/Invoke函数。尽量将批量操作在C++端完成,一次调用处理大量数据。
  2. 使用blittable类型:“Blittable”类型是指在托管和非托管内存中具有相同位表示形式的类型,如int,double,IntPtr以及只包含blittable类型字段的结构体。封送这些类型时,CLR可以直接进行内存拷贝,速度最快。非blittable类型(如bool,char,string)需要转换,开销较大。
  3. 固定托管内存(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工作,可能导致堆碎片化,只在必要时使用,并尽快释放。

  4. 考虑使用Span<T>Memory<T>:在.NET Core/.NET 5+中,对于需要与非托管代码交互的缓冲区,Span<byte>Memory<byte>是更现代、更安全的选择,它们可以与fixedMemoryMarshal结合使用,提供更好的性能和安全保障。

4.3 调试技巧:当互操作崩溃时

  1. 启用本机代码调试:在Visual Studio项目属性中,“调试”标签页下,勾选“启用本机代码调试”。这样当崩溃发生在C++ DLL内部时,调试器可以跳转到C++代码(如果你有源码和符号文件)。
  2. 使用DebugBreak__debugbreak():在怀疑有问题的C++代码处插入__debugbreak()(VC++),运行时会触发调试器中断。
  3. 检查调用堆栈:崩溃时,查看调用堆栈窗口,确定崩溃发生在托管代码还是非托管代码,以及具体的函数。
  4. 使用Marshal.GetLastWin32Error:对于Win32 API调用,及时获取错误码是定位问题的关键。
  5. 日志输出:在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#事件处理程序打印 } }

这个案例的要点总结:

  1. 封装:将原始的P/Invoke调用封装在一个托管类中,提供更符合C#习惯的API(如事件、属性)。
  2. 生命周期管理:实现了IDisposable,确保在不再使用时能正确清理C++端的资源。
  3. 回调与实例关联:通过GCHandleuserData参数,可以在静态回调中定位到具体的托管对象实例,这对于支持多实例的库是必要的。
  4. 线程安全:C++端使用了std::mutex保护回调指针,因为回调可能被多个线程调用。
  5. Unicode字符串:全程使用wchar_t*CharSet.Unicode,避免ANSI/Unicode转换的麻烦和潜在性能损失。

互操作就像在两个语言王国之间修建一座坚固的桥梁。理解数据如何过桥(封送)、桥的承重规则(调用约定、内存布局)、以及如何管理桥上的交通(内存、回调),是确保这座桥畅通无阻的关键。从简单的函数调用开始,逐步处理结构体、字符串、数组、回调,再到封装完整的库,每一步都踩稳了,你就能在C#的世界里,自由地调用那些强大而高效的C++代码,把两种语言的优势结合起来,构建出更出色的应用。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/12 23:34:24

AI Agent与MCP协议实践:构建可扩展的自动化工作流

1. 项目概述&#xff1a;从“狂人”到“伙伴”的AI实践之路“狂人日记”这个标题&#xff0c;听起来有点自嘲&#xff0c;又带着点探索的狂热。这大概就是过去一年里&#xff0c;我深度使用各类AI工具&#xff0c;特别是围绕Agent&#xff08;智能体&#xff09;、MCP&#xff…

作者头像 李华
网站建设 2026/8/12 23:31:35

如何构建企业级实时协作数据平台:Grist架构深度解析与实战指南

如何构建企业级实时协作数据平台&#xff1a;Grist架构深度解析与实战指南 【免费下载链接】grist-core Grist is the evolution of spreadsheets. 项目地址: https://gitcode.com/GitHub_Trending/gr/grist-core 面对传统电子表格在多用户协作时的版本冲突、权限管理混…

作者头像 李华
网站建设 2026/8/12 23:27:51

从BBU到AAU:5G基站架构演进与关键技术原理解析

1. 项目概述&#xff1a;从零开始理解无线基站如果你对手机信号从何而来感到好奇&#xff0c;或者想踏入通信行业却对那一堆“AAU”、“BBU”的缩写感到头疼&#xff0c;那么这次“无线基站学习”的旅程就是为你准备的。这不是一份枯燥的设备说明书&#xff0c;而是一个从业者视…

作者头像 李华
网站建设 2026/8/12 23:26:32

5分钟掌握163MusicLyrics:完全免费的跨平台歌词下载与处理解决方案

5分钟掌握163MusicLyrics&#xff1a;完全免费的跨平台歌词下载与处理解决方案 【免费下载链接】163MusicLyrics 云音乐歌词获取处理工具【网易云、QQ音乐】 项目地址: https://gitcode.com/GitHub_Trending/16/163MusicLyrics 还在为找不到音乐歌词而烦恼吗&#xff1f…

作者头像 李华
网站建设 2026/8/12 23:26:07

车辆控制器Fail Safe功能:从硬件监控到软件诊断的三层安全架构

1. 从一次深夜告警说起&#xff1a;为什么Fail Safe不是“可有可无”那天凌晨两点&#xff0c;手机突然震动&#xff0c;屏幕上弹出一条来自车辆远程监控平台的告警信息&#xff1a;“VCU-001&#xff0c;主控芯片温度异常&#xff0c;即将触发降级模式”。我瞬间清醒&#xff…

作者头像 李华
网站建设 2026/8/12 23:24:08

MySQL多表查询实战:从核心原理到性能优化

1. 项目概述&#xff1a;为什么多表查询是数据库操作的核心技能 如果你用过Excel&#xff0c;肯定遇到过这种情况&#xff1a;一个表格里放着员工信息&#xff0c;另一个表格里放着部门信息。当你想知道“张三在哪个部门”时&#xff0c;你得先在员工表里找到张三&#xff0c;记…

作者头像 李华