C++生态里沉淀了大量高性能的算法库和底层组件,比如图像处理引擎、加密模块、硬件驱动接口等。当项目主体用C#开发时,直接复用这些现成的C++动态链接库,往往比重写一遍划算得多。但在实际操作中,托管代码与非托管代码之间隔着一道边界,类型怎么对应、内存谁来释放、调用约定选哪种,任何一个环节出错都可能导致运行时崩溃。本文把C#调用C++ DLL的完整流程和踩坑点讲清楚。

一、最基础的方式:DllImport平台调用
平台调用(Platform Invoke,简称PInvoke)是.NET提供的标准机制,通过DllImport特性把非托管DLL中的导出函数映射成C#的静态方法。这种方式不需要对C++代码做任何修改,是最常用的方案。
先看C++这边。假设有一个简单的加法函数和一个字符串处理函数,要在DLL中导出,写法如下:
// MyNativeLib.cpp
extern "C" __declspec(dllexport) int Add(int a, int b)
{
return a + b;
}
// 返回字符串时,返回值用 const char*
extern "C" __declspec(dllexport) const char* GetVersion()
{
return "v1.0.2";
}这里有两个关键细节不能忽略。第一是extern "C",它告诉编译器按C方式导出函数名。如果不加,C++编译器会对函数名做名称修饰(Name Mangling),导出的名字可能变成?Add@@YAHHH@Z这种形式,C#侧按原名去找就会报"找不到入口点"。第二是__declspec(dllexport),它负责把函数写入DLL的导出表。如果DLL源码不方便改动,也可以用.def文件来声明导出,效果相同。
C#侧的声明同样简单,引入System.Runtime.InteropServices命名空间后,用DllImport标注即可:
using System;
using System.Runtime.InteropServices;
class Program
{
[DllImport("MyNativeLib.dll", CallingConvention = CallingConvention.Cdecl)]
public static extern int Add(int a, int b);
[DllImport("MyNativeLib.dll", CallingConvention = CallingConvention.Cdecl)]
public static extern IntPtr GetVersion();
static void Main()
{
int result = Add(3, 5);
Console.WriteLine($"3 + 5 = {result}");
// IntPtr 需要用 Marshal 转成托管字符串
string version = Marshal.PtrToStringAnsi(GetVersion());
Console.WriteLine($"版本号: {version}");
}
}注意DLL文件的存放位置。编译后需要把MyNativeLib.dll放到C#程序的输出目录(bin\Debug或bin\Release)下,可以在项目属性里设置"复制到输出目录",也可以手动拷贝。程序运行时按当前目录、System32目录、PATH环境变量的顺序查找DLL,放错位置会抛出DllNotFoundException。
二、调用约定与字符串封送:最容易翻车的两个点
1. CallingConvention必须匹配
调用约定决定了参数如何压栈、栈由谁清理。C++默认是__cdecl,而Win32 API惯用__stdcall。如果C#侧声明的是StdCall,C++侧实际是Cdecl,参数出栈的责任方就对不上,轻则返回值错乱,重则直接破坏栈导致程序崩溃。务必两边保持一致,建议在C++侧显式写明__cdecl或__stdcall,C#侧用CallingConvention属性明确指定,不要依赖默认值。
2. 字符串的跨边界传递
字符编码问题是互操作的重灾区。C++的char*对应单字节字符串,C#侧应该用[MarshalAs(UnmanagedType.LPStr)] string;如果C++用wchar_t*(宽字符,Windows上是UTF-16),C#侧用string即可,因为.NET字符串本身就是UTF-16。两者搞混了,中文必然变成乱码。
// C++: void SetName(const char* name); 单字节
[DllImport("MyNativeLib.dll", CallingConvention = CallingConvention.Cdecl)]
public static extern void SetName([MarshalAs(UnmanagedType.LPStr)] string name);
// C++: void SetTitle(const wchar_t* title); 宽字符
[DllImport("MyNativeLib.dll", CallingConvention = CallingConvention.Cdecl)]
public static extern void SetTitle([MarshalAs(UnmanagedType.LPWStr)] string title);还有一个高频坑:C++函数返回的char*如果指向的是函数内部new出来的缓冲区,而你在C#侧读完后没有释放,就是内存泄漏;反过来,如果C++侧返回的是栈上临时变量地址,那读取到的就是垃圾数据。跨边界传字符串更稳妥的做法是让调用方分配缓冲区,把指针和长度传进去,由被调方填充内容,内存的分配和释放就都留在同一侧。
3. 结构体的内存对齐
传结构体时,两边的字段顺序、类型大小、对齐方式必须完全一致。C++默认按最大成员对齐(通常8字节),C#结构体可以用[StructLayout(LayoutKind.Sequential, Pack = 8)]来精确控制。如果C++侧用了#pragma pack(1)按1字节对齐,C#侧就必须写Pack = 1,否则字段偏移错位,读出来的数据全乱。排查这类问题时,可以在两边都打印sizeof对比,尺寸不一致基本就是对齐出了问题。
[StructLayout(LayoutKind.Sequential, Pack = 1)]
public struct PointInfo
{
public int X;
public int Y;
public byte Flag;
}
// C++: #pragma pack(1) struct PointInfo { int x; int y; unsigned char flag; };三、进阶场景:回调函数与三种方案对比
很多C++库采用事件通知模式,需要调用方注册一个回调函数。这时的思路是:在C#侧定义一个委托,用UnmanagedFunctionPointer标注其调用约定,然后把它作为参数传给C++侧以函数指针形式接收的接口。注意这个委托的引用必须保存在托管侧的字段里,如果只是局部变量,垃圾回收器可能把它回收掉,导致C++回调时踩到无效地址。
// C++: typedef void(*Callback)(int progress);
// void RegisterCallback(Callback cb);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public delegate void ProgressCallback(int progress);
[DllImport("MyNativeLib.dll", CallingConvention = CallingConvention.Cdecl)]
public static extern void RegisterCallback(ProgressCallback cb);
// 用字段持有委托引用,防止被GC回收
static ProgressCallback s_callback = OnProgress;
static void OnProgress(int progress)
{
Console.WriteLine($"进度: {progress}%");
}对于回调中高频触发的场景(比如每秒上千次),建议在回调方法里只做数据入队,把业务处理放到另一个线程,避免阻塞非托管线程引发重入问题。
最后对比一下三种主流方案的适用场景。第一种就是本文主讲的DllImport,适合接口以函数为单位、数量不多的场合,零侵入、上手快,但结构体复杂时封送代码写起来很繁琐。第二种是C++/CLI包装层,用一个C++/CLI项目做中间翻译,把C++类包装成托管类,C#直接引用这个包装程序集,适合需要面向对象地封装整个C++类库、接口复杂度高的项目。第三种是COM封装,把C++库做成COM组件供C#引用,适合跨多种语言复用、且团队熟悉COM机制的场景,缺点是注册部署麻烦,.NET Core之后对COM的支持也增加了平台限制。
调用过程中遇到崩溃时,可以按这个顺序排查:先用工具(比如Dependencies)确认DLL的导出函数名与C#声明一致;再核对调用约定和字符编码;然后检查结构体对齐和大小;最后审查内存释放责任是否清晰。配置"启用非托管代码调试"后,崩溃时能直接定位到C++侧的代码行,效率会高很多。掌握这些要点之后,C#与C++的混合开发基本就畅通无阻了。