C#调用C++ DLL实战:VS2019下P/Invoke互操作与部署调试指南
2026/9/13 15:13:30 网站建设 项目流程

简介:这是一个面向C#与C++跨语言集成场景的完整入门案例,以Visual Studio 2019和.NET Framework 4.8为基础环境,重点讲解C++动态链接库的编制与调用流程。资源从新建Win32动态库项目开始,逐步演示如何导出可供C#调用的函数,并在C#控制台应用中通过平台调用特性完成方法声明与调用,内容连贯,适合需要在业务系统中嵌入C++算法或高性能模块的开发者。压缩包内含54个文件,整体体积约26.24兆字节,除了C++源代码与头文件、Visual Studio解决方案和工程文件外,还保留了编译生成的动态库、导入库、调试符号、目标文件以及构建日志等中间产物,便于读者对照每个环节进行重新编译和问题排查。目前已有267人学习或下载。通过案例可以掌握C++函数导出关键写法、外部C链接约定、C#端平台调用签名及调用约定选择等核心内容,同时关注数据类型对应和内存管理差异,为后续承接更复杂的跨语言互操作开发提供了可直接复用的工程模板。

1. C# 和 C++ 动态库:为什么非要在 VS2019 里绕这一圈

做 C# 上位机的开发,迟早会遇到一类需求:界面和流程用 C# 写,核心算法、工控卡通讯却是多年沉淀下来的 C++ 代码。把这些 C++ 代码重写成 C# 成本太高,也不现实;最经济的做法是把它编译成 DLL,让 C# 通过 P/Invoke 调。AAMED_DLL_DEMO1就是一条最小可运行链路:C++ 侧导出AddNumbers,C# 侧用DllImport加载一个int函数,跑通两门语言在 .NET Framework 4.8 下的互操作。适合第一次接触跨语言调用的开发,也适合被 x64 位数不一致、EntryPoint 找不到这类异常卡住的人对照排查。压缩包里的.sln和源码可以直接用 VS2019 打开。

2. C++ 侧 DLL 导出:从空项目到可调用的 AddNumbers

C++ 动态库在这里是“被调方”,C# 只关心它导出了什么函数。工程里真正影响跨语言调用的,是导出符号定义、调用约定和编译配置这三件事。

2.1 工程模板:用“动态链接库”还是“Win32 控制台应用”

VS2019 新建项目时,模板列表里可以直接选“动态链接库(DLL)”,而不是 Win32 控制台应用再改。直接选 DLL 模板的好处是自带dllmain.cpppch.hframework.h,并且默认开启预编译头,正好对应压缩包里的文件结构。老教程里用 Win32 控制台应用向导,在“应用程序类型”里勾选“DLL”、再勾选“导出符号”,效果完全一样,但要多走一步向导。两者最终生成的工程配置都是“配置类型 = 动态库(.dll)”。

AAMED_DLL_DEMO1.vcxproj里,关键属性是这样的:

属性项推荐值 / 说明
配置属性 -> 常规 -> 配置类型动态库(.dll)
配置属性 -> 常规 -> 平台工具集v142(对应 VS2019)
C/C++ -> 预编译头使用(/Yu)
链接器 -> 常规 -> 输出文件$(OutDir)$(TargetName)$(TargetExt)

如果不想用预编译头,可以把pch.cpppch.h从工程里排除,再把项目属性里的“预编译头”改成“不使用”,导出符号功能不受影响。但模板默认带着,建议保留,否则以后引入 Windows 头文件时还要额外处理。dllmain.cpp是 DLL 入口,默认实现DllMain,只负责进程、线程附加和分离时的回调,一般不需要改。

2.2 导出函数三要素:extern "C"、__declspec(dllexport)、参数对齐

打开ExportedFunctions.cpp,最简洁的导出实现是:

// ExportedFunctions.cpp #include "pch.h" #include "ExportedFunctions.h" extern "C" __declspec(dllexport) int AddNumbers(int a, int b) { return a + b; }

对应的头文件ExportedFunctions.h

// ExportedFunctions.h #pragma once #ifdef __cplusplus extern "C" { #endif __declspec(dllexport) int AddNumbers(int a, int b); #ifdef __cplusplus } #endif

这里extern "C"是关键。C++ 编译器默认会对函数名做 name mangling(名字改编),去掉extern "C"后,导出表里的符号会变成类似?AddNumbers@@YAHHH@Z的字符串,C# 侧DllImport直接写"AddNumbers"就会抛EntryPointNotFoundException。加上extern "C"后,导出名就是源码里的AddNumbers,两边对得上。__declspec(dllexport)告诉链接器“这个函数要进导出表”,编译时还会自动生成AAMED_DLL_DEMO1.lib。这个.lib是给 C++ 使用者隐式链接用的,C# 端用不到,但生成出来留着不碍事。

参数和返回类型尽量用两边定义一致的类型。int a, int b在 C++ 和 C# 里都是 32 位有符号整数,直接对应不会变形。一旦换成长整型就要小心:Windows 上 C++ 的long是 32 位,C# 的long是 64 位,两边各自认为“没错”,但栈上实际字节数不同,运行结果会非常奇怪。所以后面把AddNumbers替换成真实业务函数时,先逐项核对头文件里的类型宽度,再写 C# 侧签名。

2.3 编译为 DLL:x64 下跑通一次

在 VS2019 工具栏把解决方案配置切到 Debug,平台切到 x64,然后“生成 -> 重新生成解决方案”。输出窗口会显示x64\Debug\AAMED_DLL_DEMO1.dll生成完毕,旁边还有.lib.pdb.pdb是调试符号文件,调试 C++ 源码时要用,发布时不需要带上。

生成阶段最常见的错误是LNK2019:无法解析的外部符号。原因通常是头文件声明和 cpp 定义不一致,比如ExportedFunctions.h里写了extern "C" __declspec(dllexport) int AddNumbers(int a, int b),而ExportedFunctions.cpp里漏了extern "C",或者参数个数对不上,导致导出实现匹配不上声明。此时重新生成后,可以打开“视图 -> 其他窗口 -> 模块”确认 DLL 已加载,再到“VS 2019 开发人员命令提示符”里执行:

dumpbin /exports x64\Debug\AAMED_DLL_DEMO1.dll

/exports参数会列出导出表。能看到AddNumbers说明导出这一步过了;如果看到一串带?的乱码符号,就是extern "C"没有生效。dumpbin必须在 VS 开发人员命令提示符里运行,普通 CMD 往往没有这个命令。

提示:extern "C"只影响符号名,不影响调用约定。C++ 默认的 cdecl 调用约定下,导出名仍然是AddNumbers;如果改成__stdcall,32 位下导出名会变成_AddNumbers@8,64 位下不受影响。因此 C# 侧的CallingConvention必须和 C++ 侧保持一致,这一点在第 3 章会专门展开。

3. C# 侧 P/Invoke:把导出函数变成 C# 可调用的方法

C++ 动态库编好后,C# 端不是“引用 DLL”,而是通过DllImport声明一个运行时查找的外部方法。这个声明的正确性直接决定最终调用是否成功。

3.1 最小 DllImport 声明

新建 C# 控制台应用,目标框架选 .NET Framework 4.8,然后写入:

using System; using System.Runtime.InteropServices; namespace CSharpAppUsingDLL { internal static class NativeMethods { [DllImport("AAMED_DLL_DEMO1.dll", CallingConvention = CallingConvention.Cdecl)] internal static extern int AddNumbers(int a, int b); } internal class Program { private static void Main(string[] args) { int result = NativeMethods.AddNumbers(5, 7); Console.WriteLine($"The sum is: {result}"); Console.ReadLine(); } } }

DllImport上的CallingConvention.Cdecl对应 C++ 侧默认的__cdecl,含义是“由调用方负责清理堆栈”。如果 C++ 函数用了__stdcall,这里要改成CallingConvention.StdCall。调用约定配错通常不会直接异常,而是从堆栈里拿到一个随机值,问 题很难一眼看出来,所以排查时先检查这一项。

AAMED_DLL_DEMO1.dll是运行时查找的库名。CLR 会按照当前目录、系统目录、Windows 目录、PATH 环境变量的顺序搜索,最简单可靠的做法是把 DLL 复制到 C# 项目的bin\Debugbin\Release目录,和.exe放一起。注意复制到项目根目录但没设置“复制到输出目录”,运行时照样找不到。

3.2 数据类型映射表与调用约定

P/Invoke 的坑大多在类型对应和内存布局。下面这张表是我写代码时经常对照的:

C++ 导出函数签名C# 对应说明
int Add(int a, int b)int Add(int a, int b)32 位整数一一对应
double Divide(double a, double b)double Divide(double a, double b)IEEE 754 双精度,字节对齐一致
void GetName(char* buf)void GetName(StringBuilder buf)C# 侧需提前设置 StringBuilder 容量
bool SetState(int id, bool on)bool SetState(int id, bool on)C++bool是 1 字节,C#bool也是 1 字节
const char* GetVersion()string GetVersion()默认为 ANSI 字符串
结构体指针out/ref结构体两侧都按顺序布局结构体字段

一个容易翻车的例子是把 C++ 的unsigned char*映射成 C# 的byte[],这时需要配合Marshal.Copyfixed来做内存搬运,不能直接赋值。如果是结构体指针,C# 侧字段顺序必须和 C++ 一致,字段对齐不一致时还要加[StructLayout(LayoutKind.Sequential, Pack = 1)]

extern "C"导出的函数如果接收字符串参数,C# 侧默认按 C 风格字符串处理。C++ 函数是int Compute(const char* name),C# 就写int Compute(string name),默认CharSet是 Ansi。如果 C++ 用的是宽字符wchar_t*,则 C# 侧必须写CharSet = CharSet.Unicode,否则字符串会按单字节切分,严重时直接访问越界。

3.3 第一次调用就抛异常时看什么

AAMED_DLL_DEMO1.dll放进 C# 输出目录后直接按 F5,多数情况能正常打印The sum is: 12。如果没打印,异常类型基本能定位问题方向。

DllNotFoundException表示 DLL 没找到。先确认文件名拼写,再确认 DLL 是否真的在当前目录。最直接的办法是在Main里打印AppDomain.CurrentDomain.BaseDirectory,看输出目录是否就是 DLL 所在目录。EntryPointNotFoundException表示 DLL 找到了,但导出表里没有AddNumbers。回到 C++ 项目查两件事:是否漏了extern "C" __declspec(dllexport),是否把函数声明到了namespace里导致名字被改编。用上一章的dumpbin /exports复核导出符号即可。

BadImageFormatException表示 DLL 架构和当前进程不匹配。比如 C++ 生成的是x64\Debug\AAMED_DLL_DEMO1.dll,而 C# 项目勾选了“首选 32 位”,进程以 32 位运行,就会在加载时立刻抛这个异常。到 C# 项目属性 -> 生成 -> 平台目标,改成 x64,并取消“首选 32 位”。

提示:这类异常栈往往不会提示是哪一行 P/Invoke 出错,因为 CLR 在进入非托管代码之前就抛了。先确认位数、调用约定、导出名三项,就能解决掉九成的问题。

4. 把 DLL 和 C# 一起部署:x64/Release 与 VC++ 运行库

本地能跑通不代表目标机器能跑通。C++ 动态库的部署和纯 C# 程序不一样,除了文件拷贝,还牵扯运行库依赖和搜索路径。

4.1 让 DLL 自动复制到输出目录

手动复制 DLL 只够本地跑一次。真正交到别人手里,应该让编译过程自动把 DLL 带到 C# 输出目录。两种常见做法:一是在 C# 项目中以“添加现有项”方式引用 DLL,然后在属性里设置“复制到输出目录 = 如果较新则复制”;二是在 C# 项目的生成后事件里写复制命令。第二种对路径更可控,在“项目属性 -> 生成事件 -> 生成后事件命令行”里写:

copy /Y "$(SolutionDir)x64\Debug\AAMED_DLL_DEMO1.dll" "$(TargetDir)"

$(SolutionDir)是解决方案根目录,$(TargetDir)是当前 C# 项目输出目录。如果平台和配置会切换,可以直接写:

copy /Y "$(SolutionDir)$(Platform)\$(Configuration)\AAMED_DLL_DEMO1.dll" "$(TargetDir)"

注意顺序:先编译 C++ 项目,再编译 C# 项目,否则复制动作发生在 DLL 更新之前,拿到的是旧文件。把两个项目放在同一个解决方案里,并右键 C# 项目 -> 项目依赖项,勾选依赖AAMED_DLL_DEMO1,这样 VS2019 会自动保证先构建 C++ 项目。

4.2 目标机器缺少 VC++ 运行库

AAMED_DLL_DEMO1.dll依赖 C/C++ 运行库。开发机上因为装了完整工具链,运行没问题;换到干净的 Windows 10/11 工控机上,就可能弹出“无法启动此程序,因为计算机中丢失 VCRUNTIME140.dll”。这是部署 C++ 动态库最常见的问题,和 C# 端代码无关。

解决方案是在目标机器上安装 Microsoft Visual C++ Redistributable,安装包由微软提供,版本需要覆盖编译时用的 v142 工具集,也就是 Visual Studio 2019 对应的那套运行库。建议把 x86 和 x64 两个版本都装,因为同一台机器上的不同组件可能依赖不同架构的运行库。装完再运行 C# 程序,缺失运行库的问题通常就消失了。

如果目标机器完全不能联网,也不想装运行库,还可以把 C++ 项目改成静态链接运行库。在 C++ 项目属性 -> C/C++ -> 代码生成 -> 运行库里选择“多线程(/MT)”,重新生成 DLL 即可:

运行库选项链接方式部署影响
多线程(/MT)静态链接DLL 不再依赖 VCRUNTIME140.dll 等外部运行库,文件体积变大
多线程 DLL(/MD)动态链接目标机器需要安装对应的 VC++ Redistributable

静态链接的代价是 DLL 会大 200KB 左右,而且同一个进程里如果多个 DLL 都静态链接 CRT,各自的 CRT 状态互相独立,排查内存问题时需要多留个心眼。一般来说,优先保持/MD并安装运行库,只有目标机器环境过于封闭时才考虑/MT

4.3 排查 DLL 自身依赖:dumpbin 视角

当目标机器报缺失某个.dll时,先在自己的开发机上用 dumpbin 看看这个 DLL 到底依赖谁。打开“VS 2019 开发人员命令提示符”,切到 DLL 所在目录:

dumpbin /dependents x64\Release\AAMED_DLL_DEMO1.dll

输出会列出一串依赖 DLL。如果看到KERNEL32.dllVCRUNTIME140.dllucrtbase.dll,说明依赖的是系统 API 和通用 CRT,前者必然存在,后者靠 Redistributable 覆盖。如果还看到MSVCP140.dll,表示代码里用了 C++ 标准库,同样由 Redistributable 提供。

不要在开发机上看到这些系统 DLL 就认为目标机器也有。VCRUNTIME140.dll缺失,不代表文件被删了,而可能是目标机器安装的 Redistributable 版本过低,也可能是 32 位进程在寻找 64 位目录下的运行库。在目标机器上执行where VCRUNTIME140.dll能快速确认文件是否存在。

5. 两个上手就用得上的调试技巧:混合调试与动态加载

5.1 在 C# 断点里直接走进 C++ 源码

P/Invoke 是托管到非托管的一次穿越,默认情况下 C# 调试器把 C++ 调用当黑盒。想在return a + b;这一行停下来,需要把 C# 项目属性 -> 调试里的“启用本机代码调试”勾上,然后在NativeMethods.AddNumbers(5, 7)那行按 F11,就会进入ExportedFunctions.cpp的源码。前提是 C++ 项目生成了.pdb文件,就是 Debug 目录下那个AAMED_DLL_DEMO1.pdb。Release 配置默认也带.pdb,但发布时记得去掉,避免调试符号泄露内部实现。

混合调试时 C++ 和 C# 的配置要一致。C# 项目用 Debug|x64,C++ 项目也必须是 Debug|x64,否则符号加载不上,断点不会命中。用 Release 调混合代码也可以,但往往要额外配置符号路径,不如 Debug 来得省事。

5.2 动态加载:LoadLibrary + GetProcAddress

DllImport是静态绑定,函数入口在首次调用时查找,路径不灵活。如果 DLL 路径不固定,或者想在缺失时给出友好提示,可以直接调用kernel32LoadLibraryGetProcAddress

[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] static extern IntPtr LoadLibrary(string lpFileName); [DllImport("kernel32.dll", SetLastError = true)] static extern IntPtr GetProcAddress(IntPtr hModule, string lpProcName); [UnmanagedFunctionPointer(CallingConvention.Cdecl)] delegate int AddNumbersDelegate(int a, int b); IntPtr module = LoadLibrary(dllPath); IntPtr proc = GetProcAddress(module, "AddNumbers"); var add = (AddNumbersDelegate)Marshal.GetDelegateForFunctionPointer( proc, typeof(AddNumbersDelegate)); int result = add(5, 7);

这段代码的关键是GetProcAddress查找未改编的导出名,所以 C++ 侧extern "C"在这里同样决定成败。LoadLibrary返回IntPtr.Zero表示加载失败,用Marshal.GetLastWin32Error()拿错误码便于写日志。项目里函数多的话,建议 C++ 侧再导出一个GetFunctionTable,把函数指针一次性回传,避免为每个函数维护一条DllImport。但第一次做 C# / C++ 集成,还是先拿DllImport跑通AddNumbers,再改成动态加载,出问题时更容易定位。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询