简介:面向.NET开发者与跨语言互操作学习者的C#调用C++DLL示例工程,聚焦函数参数含指针时的P/Invoke处理。资源配套C#客户端工程(CEB)与C++源工程(Sum),通过具体实例演示DllImport特性、调用约定、指针类型映射、Marshal内存分配与释放等关键操作,适合需要快速理解非托管互操作并上手调试的入门及中级程序员。压缩包共46个文件,以cs、h、cpp等源码为主,同时包含dll、lib、exp、def等编译产物,以及pdb、exe、config、sln等工程与配置文件,整体约4.87MB,目录结构清晰,便于对照源码查看编译输出与调试信息。已有8819人学习下载。示例演示了数组地址传递、IntPtr使用、异常处理与平台兼容性注意事项,运行或二次开发即可获得完整可复用代码模板,为实际项目中的跨语言调用提供直接参考与排错思路。
1. C# 调用 C++ DLL:为什么函数参数一旦带指针就让人头皮发麻
做过上位机和底层硬件联调的朋友,应该都有过被 C++ DLL 支配的经历。普通函数从 C# 里用 DllImport 一调一个准,字符串、整数都还好说;可一旦接口函数签名里出现int*、struct**、void*这种指针参数,调试器就开始跟你玩躲猫猫:不是 AccessViolationException 就是内存布局错乱,返回的数据跟 C++ 里打印出来的完全对不上。这篇文章想解决的,就是「C# 调用 C++ DLL 时,函数参数包含指针」这一整条链路上你会遇到的破事。我拆一个模拟项目 X(一个封装了工业相机 SDK 的 C++ 原生 DLL)来讲,从 DllImport 声明到结构体封送、回调函数、内存释放,一路把代码贴到底,也把我在这个过程中踩过的坑一并交代清楚。适合正在做上位机开发、或者第一次把 C++ 算法库接进 .NET 项目的开发者。
2. 调用前的技术选型:先把 C++ 侧的导出接口摸清楚
2.1 确认 C++ DLL 的导出方式和调用约定
拿到一个 C++ DLL,第一步不是写 C# 代码,而是先把对方的接口定义搞明白。最常见的导出有两种:一种是用.def文件导出,另一种是直接在函数定义前加__declspec(dllexport)。前者导出的是干净的 C 风格函数名,后者如果没加extern "C",C++ 编译器会对函数名做 name mangling,C# 这边 DllImport 的 EntryPoint 就得写 mangling 之后的名字,那东西长得像乱码,很难维护。
调用约定同样决定成败。C++ DLL 里常见的有__cdecl和__stdcall,默认是__cdecl。C# 侧 DllImport 不写 CallingConvention 时默认是Winapi,在 32 位下被解析成StdCall,和 C++ 的默认约定对不上,轻则参数错乱,重则进程直接崩。我一般会在 DllImport 里显式写上CallingConvention.Cdecl,除非确认对方用的是WINAPI(也就是__stdcall)。
下面是模拟项目 X 里相机 SDK 的一个真实导出头文件片段,我简化了一下:
#ifdef __cplusplus extern "C" { #endif // 获取设备列表,pDeviceList 为输出参数,指向一块由调用方分配的内存 int __stdcall MX_GetDeviceList(char* pDeviceList, int* pListSize); // 打开设备,hDevice 为输出句柄指针 int __stdcall MX_OpenDevice(int deviceIndex, void** hDevice); // 设置图像回调,pCallback 为函数指针 int __stdcall MX_SetCallback(void* hDevice, MX_FrameCallback pCallback); // 获取一帧图像数据,pFrameInfo 为输入输出参数 int __stdcall MX_GetFrame(void* hDevice, MX_FRAME_INFO* pFrameInfo); #ifdef __cplusplus } #endif不算复杂,但注意这里已经出现了三种指针形态:char*输出字符串、int*输出长度、void**输出句柄、结构体指针MX_FRAME_INFO*、函数指针MX_FrameCallback。这五种基本覆盖了 C# 调用 C++ DLL 时遇到的大多数场景。
2.2 C# 侧三种调用风格怎么选
C# 调用 C++ DLL 的姿势,常见的有三套路子。
第一套是直接用 DllImport 加unsafe指针。C# 里开启AllowUnsafeBlocks后,可以正儿八经地写int*、byte*,把 C++ 的接口原封不动搬过来。优点是还原度高,缺点是要自己管固定内存(fixed)和指针生命周期,代码写起来烫手。
第二套是用IntPtr加Marshal类。这是最主流也最稳的写法,所有指针都抽象成IntPtr,配合Marshal.PtrToStructure、Marshal.StructureToPtr、Marshal.AllocHGlobal这套封送工具来读写内存。好处是能避开unsafe,代码可读性强一些。我个人的习惯是默认走这套。
第三套是dynamic加载,也就是在运行时用LoadLibrary+GetProcAddress手动绑定函数地址,再用Marshal.GetDelegateForFunctionPointer转成委托来调用。这种方式适合要做插件热插拔或者运行时才确定 DLL 路径的场景。
[DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)] private static extern IntPtr LoadLibrary(string lpFileName); [DllImport("kernel32.dll", CharSet = CharSet.Auto, SetLastError = true)] private static extern IntPtr GetProcAddress(IntPtr hModule, string lpProcName); var hModule = LoadLibrary(@"D:\bin\MX_Camera.dll"); var pFunc = GetProcAddress(hModule, "MX_OpenDevice"); var openDevice = (OpenDeviceDelegate)Marshal.GetDelegateForFunctionPointer(pFunc, typeof(OpenDeviceDelegate));这段代码背后的思路是:把 DLL 加载和函数绑定拆开,函数指针拿到后通过委托闭包调用,程序要换 DLL 版本的时候不必重新编译。结构上更适合二次开发。
2.3 封送的三类模式:值、引用、指针
C# 侧声明 DllImport 函数时,参数怎么写决定了封送行为。代码是写给框架看的,这几个模式得刻在脑子里。
第一类是值传递。C++ 侧是int a,C# 侧就是int a,内存按值拷贝进栈,结束后不写回。适合纯入参。
第二类是引用传递。C++ 侧是int* pCount且函数内部只读,C# 侧可以写成ref int count;如果函数内部要写这块内存作为输出,就用out int count。ref和out的封送本质是一回事,都是传地址,区别只在 C# 侧的初始化要求。用ref/out的好处是 C# 编译器帮你管理临时变量,不需要unsafe。
第三类是指针传递。C++ 侧是void**或者指向结构体的多层指针,C# 侧就得用IntPtr甚至IntPtr*。这类参数在 C++ DLL 里一般是「输出句柄」「输出数组首地址」的意思,封送时经常要配合Marshal.AllocHGlobal先分配一块非托管内存,再把地址传给 DLL。
IntPtr pListSize = Marshal.AllocHGlobal(sizeof(int)); Marshal.WriteInt32(pListSize, 0); int ret = MX_GetDeviceList(buffer, pListSize); int listSize = Marshal.ReadInt32(pListSize); Marshal.FreeHGlobal(pListSize);这里Marshal.AllocHGlobal分配的是非托管堆上的内存,不受 .NET 垃圾回收管理,用完必须手动释放,否则每次调用泄漏几十字节,一个长跑的上位机程序跑一晚上就爆内存。这也是参数包含指针时最容易忽略的一条。
3. 核心代码拆解:一个指针参数从进入到返回的完整链路
3.1 整型指针参数:最简单也最容易被忽略的坑
先拿MX_GetDeviceList开刀。它的签名是int __stdcall MX_GetDeviceList(char* pDeviceList, int* pListSize),两个参数都是指针:第一个是输出缓冲区,第二个是缓冲区大小的指针,同时肩负输入和输出的责任。
把字符串输出改成整型指针的方式来看。我们先写一版 C# 声明:
[DllImport("MX_Camera.dll", EntryPoint = "MX_GetDeviceList", CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Ansi)] private static extern int MX_GetDeviceList(byte[] pDeviceList, ref int pListSize);调用时,C# 侧的byte[]会自动固定住内存地址,把数组首元素地址传给 C++ 侧;ref int会传int变量的地址。C++ 函数里如果先把缓冲区需要的字节数写到pListSize指向的变量里,再往里填充字符串,这个模式就自然工作。
但这里有个细节:C++ 侧可能先问一次大小,再让你正式调。也就是pListSize既是输入也是输出。第一次调用传入 0,函数返回需要的字节数;第二次调用传入算好的大小,函数真正写入数据。这种「两段式调用」在 API 里很常见,C# 侧如果只声明成out就会翻车,因为out参数不允许带初始值。
int bufferSize = 0; MX_GetDeviceList(null, ref bufferSize); // 第一次调用,拿大小 byte[] buffer = new byte[bufferSize]; MX_GetDeviceList(buffer, ref bufferSize); // 第二次调用,拿数据 string deviceList = Encoding.ASCII.GetString(buffer, 0, bufferSize);第一次传入null作为缓冲区指针在 C 语言里是合法的,函数内部可以只写pListSize而不碰pDeviceList。C# 侧的 DllImport 参数声明成byte[],传null不会报错,实际封送过去就是空指针。这一点是很多同学第一次拿到这种接口时容易懵的地方:为什么声明成了数组还能传null?
3.2 结构体指针:从struct*到IntPtr的封装之路
相机一帧图像的数据自然要用结构体来承载:宽、高、像素格式、数据指针、时间戳。C++ 侧MX_FRAME_INFO是这样的:
typedef struct _MX_FRAME_INFO { int width; int height; int pixelFormat; unsigned char* data; int dataSize; unsigned long long timestamp; } MX_FRAME_INFO;如果只是普通结构体,C# 侧定义一个对应结构,DllImport 参数写成ref MX_FRAME_INFO frameInfo就完事。但data是一个动态指针,指向一块由 DLL 内部管理的图像数据,这种结构体拿到 C# 侧就必须用IntPtr来承接,再按需把data里的内容拷贝到托管数组里。
C# 侧结构体定义:
[StructLayout(LayoutKind.Sequential)] public struct MX_FRAME_INFO { public int width; public int height; public int pixelFormat; public IntPtr data; // 指向图像数据的非托管指针 public int dataSize; public ulong timestamp; }注意必须加[StructLayout(LayoutKind.Sequential)],让字段按顺序连续排列,和 C++ 的布局对上。默认LayoutKind.Auto时,CLR 会按自己的喜好调整字段顺序,导致 C++ 侧读到错误的偏移量,表现就是 width 和 height 对调、data 指针指向乱码区域。
调用MX_GetFrame时直接声明参数为ref MX_FRAME_INFO:
[DllImport("MX_Camera.dll", EntryPoint = "MX_GetFrame", CallingConvention = CallingConvention.StdCall)] private static extern int MX_GetFrame(IntPtr hDevice, ref MX_FRAME_INFO frameInfo);拿到结构体后,把图像数据从非托管内存拷到托管byte[]里:
MX_FRAME_INFO frameInfo = new MX_FRAME_INFO(); int ret = MX_GetFrame(hDevice, ref frameInfo); if (ret == 0 && frameInfo.data != IntPtr.Zero) { byte[] imageData = new byte[frameInfo.dataSize]; Marshal.Copy(frameInfo.data, imageData, 0, frameInfo.dataSize); // imageData 现在就是完整的帧数据,可以交给 OpenCV 或图像控件渲染 }Marshal.Copy把IntPtr指向的连续内存拷贝到托管数组,是这里最核心的一步。这块内存归 C++ DLL 内部管理,C# 侧不能释放,也不应该长时间保存引用,最好拷贝完就忘掉。
3.3 输出句柄的双重指针void**:C# 怎么接
MX_OpenDevice(int deviceIndex, void** hDevice)是典型的双重指针。C++ 侧这么用:函数内部创建一个设备对象,把地址写到hDevice指向的变量里,调用方就拿到了一个不透明的句柄。C# 侧没有可以直接映射void**的类型,最常见的是声明成out IntPtr:
[DllImport("MX_Camera.dll", EntryPoint = "MX_OpenDevice", CallingConvention = CallingConvention.StdCall)] private static extern int MX_OpenDevice(int deviceIndex, out IntPtr hDevice); IntPtr hDevice; int ret = MX_OpenDevice(0, out hDevice);看到out IntPtr,连初始化都不需要,C# 编译器创建临时变量传地址,C++ 侧把设备句柄直接写进去。后续所有需要设备句柄的函数,比如MX_SetCallback和MX_GetFrame,第一参数都传这个IntPtr。
这套写法的前提是:C++ DLL 必须用「句柄归 DLL 管理、调用方只保存不操作」的设计。C# 侧任何时候都不要尝试去读写hDevice指向的内存,它是 DLL 内部对象的地址,拿到手只是为了在后续调用中「凭证式」地传回去。
3.4 回调函数指针:委托封送这关怎么过
MX_SetCallback(void* hDevice, MX_FrameCallback pCallback)里MX_FrameCallback是 C++ 的函数指针,C# 侧用委托来对应:
[UnmanagedFunctionPointer(CallingConvention.StdCall)] public delegate void MX_FrameCallback(ref MX_FRAME_INFO frameInfo); [DllImport("MX_Camera.dll", EntryPoint = "MX_SetCallback", CallingConvention = CallingConvention.StdCall)] private static extern int MX_SetCallback(IntPtr hDevice, MX_FrameCallback callback);调用时有一个致命细节:委托必须被托管变量引用住,不能传一个临时创建的委托对象。C++ DLL 把函数指针保存起来,等图像来了之后异步调用;如果 C# 侧的委托对象已经被 GC 回收,回调发生时 CLR 可能已经把它当作垃圾处理掉,调用地址变成悬空指针,程序直接崩溃或者产生不可预期的数据。这种崩溃还不是必现的,看你运气,属于玄学类 bug。
private MX_FrameCallback _frameCallback; // 类字段,作为回调的强引用 public void StartCapture(IntPtr hDevice) { _frameCallback = new MX_FrameCallback(OnFrameReceived); MX_SetCallback(hDevice, _frameCallback); } private void OnFrameReceived(ref MX_FRAME_INFO frameInfo) { // 注意:这里在非托管线程上执行,不能直接操作 UI 控件 byte[] imageData = new byte[frameInfo.dataSize]; Marshal.Copy(frameInfo.data, imageData, 0, frameInfo.dataSize); // 用线程安全的方式把图像数据发布到 UI 线程 }委托实例挂在类的私有字段上,从设置回调到停止采集的整个生命周期里都不会被回收。Marshal.Copy在回调里做拷贝也是刻意的——回调执行在线程池线程上,生命周期短,把数据拷到托管堆之后,原始内存交给 DLL 管理,互不干扰。如果你的图像数据量很大(比如 1200 万像素的 sensor),这里是性能瓶颈,可以考虑用 buffer pool 复用数组,不过那是架构优化的问题了。
4. 避坑指南:指针参数调用里的四类常见翻车现场
4.1 调用约定不一致:程序启动就崩,毫无征兆
现象:DllImport 声明了__cdecl的函数,C++ 侧实际导出是__stdcall。第一次调用能进去,但函数返回时栈被破坏,程序在几分钟后随机崩溃,甚至入口函数刚执行完就报0xC0000005访问违规。
原因:两种调用约定的核心差异在于谁负责清理栈。__cdecl由调用方清理,__stdcall由被调方清理。两边想法不一致,栈指针就乱了,后续任何局部变量都可能踩到错误的内存位置。
解决:拿到 C++ DLL 头文件第一件事,确认每个函数前是__stdcall、WINAPI还是__cdecl。DllImport 声明时统一显式写CallingConvention。如果没有头文件,可以用反汇编工具静态看导出函数入口处的ret指令后面有没有n参数:ret 16是__stdcall,裸ret是__cdecl。
4.2 字符串编码不匹配:中文设备名变成乱码
现象:C++ DLL 里char*存的是 UTF-8 编码的设备名称,C# 侧声明CharSet = CharSet.Unicode,于是设备名显示成「浄?」,数字和字母正常,中文全乱。
原因:CharSet影响字符串参数的封送方式。Ansi按系统当前代码页转换(简体中文系统就是 GBK),Unicode按 UTF-16 转换。C++ DLL 内部自己编码是 UTF-8 的话,两边都不是一回事。
解决:先探明 DLL 实际编码,然后分情况处理。如果 C++ DLL 用的是 UTF-8,最稳的方式是不用 DllImport 自动转码,把参数声明成byte[]或者IntPtr,手动Encoding.UTF8.GetBytes再传;如果是 GBK,CharSet = CharSet.Ansi正好能对上;如果是 UTF-16(极少见的 Windows 原生 COM 风格接口),才用Unicode。
// 手动按 UTF-8 转换,避免 CharSet 猜测 byte[] nameBytes = Encoding.UTF8.GetBytes(deviceName + '\0'); // 调用 DLL 把 nameBytes 传进去注意字符串必须有结尾的\0,很多 C++ DLL 内部用strlen来定位字符串边界,漏掉终止符会让它读越界,返回的字符串后面带尾巴。
4.3 结构体内存对齐不一致:字段值读出来是天文数字
现象:C++ 结构体里的short字段读出来是负数或者大得离谱,double的值像随机数;但 C# 结构体打印出来的字段名和数量全对。
原因:C++ 编译器默认按自然边界对齐,结构体内部会插入 padding 字节。如果 C# 侧只声明字段不指定布局,LayoutKind.Sequential又是按 C# 默认对齐规则排,遇到包含bool、char、short这类字段时两侧布局不一致。
解决:用[StructLayout(LayoutKind.Sequential)]还不够的时候,上Pack参数和MarshalAs。C++ 侧如果用了#pragma pack(1)禁止对齐,C# 侧对应写[StructLayout(LayoutKind.Sequential, Pack = 1)]。如果只是个别字段特殊(比如bool在 C++ 里占 4 字节),用:
[MarshalAs(UnmanagedType.Bool)] public bool isEnabled;模拟实际踩过的一个例子:C++ 结构体是int + bool + int,默认对齐后结构体大小是 12 字节;C# 侧bool默认封送成 1 字节,结构体大小变成 8 字节。DLL 把 12 字节写入缓冲区,C# 结构体只解析了前 8 字节,最后那个int永远是垃圾值。排查方式很简单:分别打印两侧的sizeof,数值对不上就是对齐出问题了。
4.4 非托管内存泄漏:程序跑一晚后内存翻倍
现象:程序刚启动内存占用 80MB,连续运行 8 小时后到了 1.2GB,看起来像内存泄漏。进程句柄数没有异常,GC 堆也正常,但私有工作集持续增长。
原因:DLL 返回的指针所指向的内存(比如MX_GetFrame每次返回的data指针)如果是 DLL 内部 malloc 出来的,DLL 没有提供对应释放接口;或者 C# 侧用AllocHGlobal传给 DLL 的参数缓冲区,调用后没有FreeHGlobal。这种泄漏 GC 检测不到,必须在代码里做对称分配/释放。
解决:每次 DllImport 调用前,先列一个内存责任清单:这块内存是谁分配的?谁负责释放?释放函数叫什么名字?
IntPtr buffer = Marshal.AllocHGlobal(1024 * 1024); try { int ret = MX_GetDeviceList(buffer, ref size); } finally { Marshal.FreeHGlobal(buffer); }如果 DLL 侧内部返回的指针需要专门的释放函数,在 C# 侧再声明一个MX_FreeFrame(IntPtr data)来对称释放。我在做模拟项目 X 的接入时就踩过这个坑:最初只调MX_FreeFrame释放帧数据,忘了还分配了设备回调上下文,结果采集 3 小时之后进程开始抖动。从那以后我每次接入 SDK 都会先梳理一遍所有指针的内存归属,再动手写代码。
5. 二次开发:把指针参数调用封装成自己好用的 .NET 框架
5.1 设计一个安全的句柄包装类
直接散落的 DllImport 声明在项目变大之后非常难维护。我在接入模拟项目 X 时最终采取的做法是:把所有原生接口收敛到一个内部类,对外只暴露封好后的托管 API。
public class CameraDevice : IDisposable { private IntPtr _hDevice; private MX_FrameCallback _callback; private bool _disposed; public void Open(int index) { int ret = NativeMethods.MX_OpenDevice(index, out _hDevice); if (ret != 0) throw new InvalidOperationException($"打开失败: {ret}"); _callback = OnFrame; NativeMethods.MX_SetCallback(_hDevice, _callback); } public void Dispose() { if (_disposed) return; if (_hDevice != IntPtr.Zero) { NativeMethods.MX_StopCapture(_hDevice); NativeMethods.MX_CloseDevice(_hDevice); _hDevice = IntPtr.Zero; } _disposed = true; GC.SuppressFinalize(this); } private void OnFrame(ref MX_FRAME_INFO frameInfo) { // 把原生帧拷贝到托管事件参数,避免外部直接持有 IntPtr var args = new FrameReceivedEvent(frameInfo); FrameReceived?.BeginInvoke(args); } private static class NativeMethods { [DllImport("MX_Camera.dll", CallingConvention = CallingConvention.StdCall)] internal static extern int MX_OpenDevice(int index, out IntPtr hDevice); } }这套封装的价值在于:外部调用方完全看不到IntPtr和委托管理,只需要using一个CameraDevice对象。Dispose方法统一处理句柄释放和回调解绑,避免因为忘释放句柄导致第二次打开设备时被 DLL 拒绝。
5.2 用NativeBuffer做内存复用,缓解高频采集的 GC 压力
高频图像采集时(30fps 甚至 60fps),每次都新建byte[]然后交给 GC 回收,会产生大量临时对象,GC 被迫频繁触发,画面出现卡顿。常见的优化方向是预分配缓冲区,循环复用。
public class NativeBuffer { private IntPtr _buffer; private int _size; public NativeBuffer(int capacity) { _size = capacity; _buffer = Marshal.AllocHGlobal(capacity); } public IntPtr Pointer => _buffer; public byte[] ToManaged() { byte[] result = new byte[_size]; Marshal.Copy(_buffer, result, 0, _size); return result; } public void Resize(int newSize) { if (newSize <= _size) return; Marshal.FreeHGlobal(_buffer); _buffer = Marshal.AllocHGlobal(newSize); _size = newSize; } ~NativeBuffer() => Marshal.FreeHGlobal(_buffer); }在MX_GetFrame的调用循环里,声明一个NativeBuffer实例反复使用,只有当帧尺寸变大时才Resize。最终版本的采集线程代码大致长这样:
public void CaptureLoop() { var nativeBuffer = new NativeBuffer(1920 * 1080 * 4); while (!_stopRequested) { MX_FRAME_INFO frameInfo = new MX_FRAME_INFO(); NativeMethods.MX_GetFrame(_hDevice, ref frameInfo); if (frameInfo.dataSize > 0) { nativeBuffer.Resize(frameInfo.dataSize); Marshal.Copy(frameInfo.data, nativeBuffer.Pointer, 0, frameInfo.dataSize); // 用 nativeBuffer.Pointer 或拷贝后的托管数组做后续处理 } Thread.Sleep(1); } }这种做法的代价是代码复杂一些,但换来的是稳定的帧率曲线和更低的 GC 频率。做工业视觉项目的同学应该懂,帧率抖动有时候不是算法问题,而是托管 GC 在背后搞鬼。
5.3 怎么验证自己的二次开发成果
改完框架第一件事不是跑功能,而是跑内存稳定性测试。我一般会在 Release 配置下让采集循环连续跑一小时,同时监控进程工作集和提交大小。如果内存曲线是平稳或者小幅波动的,说明非托管内存没有泄漏;如果单调上升,大概率某个FreeHGlobal或者 DLL 提供的释放函数漏调了。
第二件事是错误码覆盖测试。把设备拔掉、超时、非法句柄、回调线程异常这些场景都触发一遍,确保封装层不会把原生错误码直接抛给上层业务。我在模拟项目 X 的接入后期,把错误码翻译成了统一的CameraException,带错误码和中文描述,排障的时候节省了大量时间。
5.4 一个收尾的检查清单
每次接入新 DLL,我都会先过一遍下面这个检查单。它们几乎是所有「C# 调 C++ DLL 带指针参数」问题的根源:调用约定、字符集、结构体布局、委托生命周期、内存归属。只要这五件事都答得上来,指针参数就不会再成为黑匣子。
检查单第一项是逐函数核对调用约定;第二项是确认CharSet是否和 DLL 内字符串编码一致;第三项是打印结构体两侧大小做对比;第四项是检查所有委托是否被强引用;第五项是把每个指针参数的分配函数和释放函数列出来,形成内存责任对照表。
这套检查清单救了我很多次。项目中后期,团队里有新同学加入时,我会直接把这五项作为代码评审的必过门槛。从那以后我每次写 DllImport 声明前都会强制走一遍这个清单,没走过清单的函数不允许提交到主分支。希望帮到你。
本文还有配套的精品资源,点击获取