c# 如何调用c++的dll?托管与非托管互操作完整指南

来源:草根站长作者:松本一香头衔:网络博主
导读:本期聚焦于松本一香创作的《c# 如何调用c++的dll?托管与非托管互操作完整指南》,敬请观看详情。C#项目里想复用已有的C++动态链接库,却总是遇到入口点找不到、参数乱码、内存崩溃这些报错?本文围绕C#与C++互操作这一核心场景,系统讲解DllImport托管调用、回调函数注册、结构体内存对齐等关键技术点,帮你弄清char*、wchar_t*与string的对应关系,掌握__stdcall与__cdecl调用约定的区别,学会用Marshal类处理非托管内存。文末还对比了DllImport、COM封装、C++/CLI包装三种方案的适用场景,并给出常见崩溃问题的排查思路,照着做就能让托管代码稳定调用C++库。

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

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++的混合开发基本就畅通无阻了。

C#调用DLLC++互操作PInvoke修改时间:2026-09-13 09:26:33

免责声明:已尽一切努力确保本网站所含信息的准确性。网站作品多为原创整理与精心创作,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们进行处理Email:chomcom@qq.com。
引用或转载本作品时,请注明当前出处:https://www.ipipp.com/html/20260913/55911.html,基于非商业用途的前提下,欢迎转载或二创本作品。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。