
简介在Visual Studio 2019环境下通过C#调用C动态链接库实现跨语言交互是许多桌面开发者的常见需求。这份资源面向有基础C#和C经验的开发者以AAMED_DLL_DEMO1为完整示例演示了从创建C DLL、导出AddNumbers函数到在C#工程中利用DllImport特性完成P/Invoke调用的全流程。资源共54个文件包含cpp/h源文件、dll/lib生成产物以及pdb调试符号、obj中间文件和tlog构建日志有助于对照检查每个编译、链接与调试环节压缩包整体约26.24MB结构清晰。已有267人学习下载适合希望理解跨语言调用细节、解决数据类型与调用约定匹配问题的开发人员。通过该案例读者能掌握C动态库的编制要点和C#侧P/Invoke的写法为后续集成高性能C模块打下基础。1. 为什么做C#集成要先手工编一个C动态库在很多C#上位机、视觉检测和工业控制项目里界面、通信、数据库用C#写图像算法、PLC通讯、硬件驱动用C写这是最常见的分工方式。.NET Framework 4.8本身不能直接消费C源码标准做法是把C部分编译成一个动态库DLL再让C#通过P/Invoke调用。这套流程如果前期库设计得不好后续C#端的调用、排错和发布都会非常痛苦。这里聚焦第一部分在VS2019里把这个C动态库正确、干净地编出来同时保证导出的接口形式能被C#侧稳定引用。这个库的编制质量直接决定后续集成案例里调用是否顺畅、跨平台配置是否踩坑。2. VS2019创建C动态库项目从空项目到第一个导出2.1 新建项目的选择动态链接库(DLL)还是空项目VS2019中新建C项目时会有“动态链接库(DLL)”和“空项目”两个常见入口。两者并不冲突区别在于模板预置了哪些东西。选择“动态链接库(DLL)”时VS会自动生成dllmain.cpp、framework.h、pch.h并且把项目配置类型设为动态库(.dll)同时定义_WINDLL和_USRDLL宏。这对第一次写DLL的人比较友好能避免手动配置时漏掉的链接器输出。选择“空项目”则所有配置从零开始适合团队有统一代码规范、不希望出现模板残留的情况。我一般会选择“动态链接库(DLL)”模板然后清理掉模板里多余的导出声明只保留dllmain.cpp。dllmain.dll不是必须编写但保留它可以让你在进程加载、线程附加、进程卸载时获得通知比如初始化日志句柄或释放全局资源。后续在C#中集成时DLL_PROCESS_DETACH里做资源释放能减少不少内存泄漏隐患。模版创建完成后需要确认几个关键配置。右键项目进入“属性”在“配置属性 - 常规 - 配置类型”里必须看到动态库(.dll)在“高级 - 目标文件扩展名”里应为.dll。如果使用的是Release x64配置还要留意“C/C - 预处理器 - 预处理器定义”里是否包含WIN32或_WINDOWS。这套配置决定编译器最终产出的是DLL而不是静态库也决定链接器是否生成对应的导入库.lib而.lib是后面写C测试程序和C# P/Invoke时的重要参照。2.2 最小导出实现与 dllmain.cpp先写一个最简单但五脏俱全的C动态库导出两个整数运算函数。头文件负责声明导出源文件负责实现。这里的关键是使用__declspec(dllexport)标记导出函数并使用extern C避免C名称修饰这样导出符号名就是普通函数名方便C#直接绑定。// MathLib.h #pragma once #ifdef __cplusplus extern C { #endif __declspec(dllexport) int MathLib_Add(int a, int b); __declspec(dllexport) int MathLib_Multiply(int a, int b); #ifdef __cplusplus } #endif// MathLib.cpp #include MathLib.h int MathLib_Add(int a, int b) { return a b; } int MathLib_Multiply(int a, int b) { return a * b; }在.cpp的同目录下VS2019会生成一个dllmain.cpp默认内容包含DllMain入口函数。项目刚创建时你也可以直接删掉它但保留通常更稳妥。常见写法如下// dllmain.cpp #include windows.h BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved) { switch (ul_reason_for_call) { case DLL_PROCESS_ATTACH: // 进程加载DLL时执行一次 break; case DLL_THREAD_ATTACH: case DLL_THREAD_DETACH: break; case DLL_PROCESS_DETACH: // 进程卸载DLL时执行 break; } return TRUE; }DllMain的第一个参数hModule是当前DLL的模块句柄后续如果需要加载同目录资源文件会用到。第二个参数是调用原因其中DLL_PROCESS_ATTACH和DLL_PROCESS_DETACH最常被重写。注意DllMain里不能调用LoadLibrary或等待线程结束否则容易造成死锁这是Windows加载器层面的约束。编译这个项目后输出目录下会出现三个文件MathLib.dll、MathLib.lib、MathLib.exp。.lib是导入库链接器不直接使用.dll而是通过.lib里的符号信息完成链接.exp是导出文件后续没有特殊需求可以忽略。2.3 用 __declspec(dllexport) 还是 .def 文件__declspec(dllexport)写在函数定义或声明前编译器会自动把该函数加入导出表。它使用简单适合导出数量少、命名规则简单的场景。.def文件则是在模块定义文件里显式声明导出符号并且能控制导出序号和最终导出名适合需要导出C类或希望隐藏部分符号的老项目。两者可以同时使用但__declspec(dllexport)生成的符号名会受到调用约定影响比如__stdcall导出会变成_MathLib_Add8这种样式。如果你希望导出名始终是MathLib_Add用.def文件更可控。做法是在VS2019中右键项目添加MathLib.def内容如下LIBRARY MathLib EXPORTS MathLib_Add MathLib_Multiply然后在“链接器 - 输入 - 模块定义文件”中填写MathLib.def同时去掉头文件里的__declspec(dllexport)保证同一函数只通过一种方式导出。.def文件里的LIBRARY名称最好与生成文件名一致否则Windows加载时会按模块名查找可能影响GetModuleHandle之类的调用。用.def的一个隐藏优势是导出的函数序号固定。C#端DllImport默认按函数名查找但如果有些老式C库没有导出名只有序号C#侧就要借助EntryPoint指定导出序号这时.def的序数控制能力就成了唯一选择。3. 把导出接口设计成C#能P/Invoke的形状3.1 extern C 与 C 名称改编如果不加extern CC编译器会按函数参数类型和命名空间生成修饰名比如?MathLib_AddYAHHHZ。这样写C内部代码没问题但C#的DllImport拿到的是函数名MathLib_Add链接到DLL里根本找不到这个符号运行时会抛EntryPointNotFoundException。检查导出符号最直接的方式是用VS2019自带的dumpbin。打开“开发者命令提示符 for VS 2019”执行dumpbin /exports MathLib.dll输出里能看到导出函数的修饰名和序号。如果你看见?MathLib_AddYAHHHZ说明extern C没有生效如果看见MathLib_Add说明导出名已经是C风格了。C#端对应的声明如下[DllImport(MathLib.dll, CallingConvention CallingConvention.Cdecl)] public static extern int MathLib_Add(int a, int b);注意DllImport第一个参数指定DLL文件名运行时会在程序目录、系统目录、PATH环境变量目录中查找。VS2019生成的DLL如果没复制到C#程序的bin目录下就会报DllNotFoundException这个问题在集成时出现频率极高。3.2 调用约定__cdecl、__stdcall 对 C# 的影响调用约定决定了参数压栈顺序、栈由谁清理以及符号命名方式。C#的DllImport如果不能与C侧匹配轻则值读取不对重则直接导致程序崩溃。下表是两种常见约定的差异调用约定栈清理方导出名示例C#中的CallingConvention__cdecl调用方MathLib_AddCdecl__stdcall被调用方_MathLib_Add8StdCall在C函数声明中不特别写明时默认是__cdecl。但C#端的DllImport默认CallingConvention.Winapi在32位下会映射为StdCall。如果你的C库没加任何修饰而C#端仍然用默认设置32位Debug下最容易出现栈不平衡程序可能等到连续调用数次后才崩溃。最安全的写法是在C侧明确标注调用约定并在C#端严格对应extern C __declspec(dllexport) int __stdcall MathLib_Add(int a, int b);[DllImport(MathLib.dll, CallingConvention CallingConvention.StdCall)] public static extern int MathLib_Add(int a, int b);如果使用__declspec(dllexport)和__stdcall导出名会被修饰为_MathLib_Add8。这时可以先执行dumpbin /exports看真实导出名再在DllImport的EntryPoint字段里原样填写或者在C#端用EntryPoint MathLib_Add并关闭BestFitMapping。最推荐的方式是直接用.def文件固定导出名这样无论调用约定怎么变符号名始终是干净的。3.3 导出C类还是导出C接口不少C开发者习惯直接class __declspec(dllexport)导出一个类但这样的库在C#中很难直接用。C#没有this指针概念也没有虚函数表布局的标准描述跨语言传递C对象非常脆弱。就算你用Marshal.GetFunctionPointerForDelegate配合__thiscall绕过去也无法应付继承、虚函数、重载等特性维护成本极高。更稳的做法是导出纯C接口把对象包装成不透明句柄void*。C#端只保存句柄所有操作通过自由函数完成。这样既不暴露C类的内存布局也能让C#侧代码变得非常直觉。下面是一个典型封装extern C { __declspec(dllexport) void* Calculator_Create(); __declspec(dllexport) void Calculator_Destroy(void* handle); __declspec(dllexport) int Calculator_Add(void* handle, int a, int b); }// 内部实现 #include memory class Calculator { public: int Add(int a, int b) { return a b; } }; void* Calculator_Create() { return new Calculator(); } void Calculator_Destroy(void* handle) { delete static_castCalculator*(handle); } int Calculator_Add(void* handle, int a, int b) { auto* calc static_castCalculator*(handle); return calc-Add(a, b); }C#端只需要把句柄声明为IntPtr把导出函数声明为static extern即可。这里的要点是句柄只能由C侧创建和销毁C#侧不能对它做任何运算每次调用前先判空防止传入野指针导致进程崩溃。这种“C接口 句柄”的模式在很多商用SDK里都能看到例如工业相机、运动控制卡、视觉算法库也都适合嵌入到C#上位机程序里。3.4 结构体与内存布局的注意事项当导出函数需要传递结构体时C和C#必须对内存布局达成一致。C编译器默认按成员对齐C#侧需要显式指定LayoutKind.Sequential并且保证字段类型一一对应。下面是一个常见例子typedef struct _Point2D { int x; int y; double score; } Point2D; __declspec(dllexport) double Point2D_Score(Point2D* p);[StructLayout(LayoutKind.Sequential)] public struct Point2D { public int x; public int y; public double score; } [DllImport(MathLib.dll)] public static extern double Point2D_Score(ref Point2D p);C侧接收指针时C#用ref修饰结构体即可。如果结构体里含boolC应使用BOOL即int因为C#的bool占用1字节而Win32的BOOL是4字节。如果结构体里含字符串最稳妥的方式是使用char*并配合Encoding.ASCII或者直接使用const wchar_t*对应C#的string。注意不要轻易在C动态库中返回未初始化的结构体数组C#侧Marshal.StructureToPtr很难判断哪些内存由调用方释放。4. 构建配置与运行时环境x86/x64、MT/MD 与依赖4.1 平台目标为什么 C# AnyCPU 会踩坑.NET Framework 4.8的C#项目默认平台目标是AnyCPU在64位Windows上会以64位进程运行。如果C动态库是32位Release编译的C#调用时会产生位数不匹配异常错误往往是BadImageFormatException。反过来也一样C#程序被设为x86却加载了x64的DLL同样会失败。这个问题在VS2019里特别常见因为C#项目配置和C项目配置默认没有联动。我常用的做法是把C#项目平台目标从AnyCPU改为x64或根据目标机器选择x86。把C项目配置管理器里的活动解决方案平台改为与C#项目一致并编译对应的x64或Win32输出。每次生成后把DLL复制到C#的bin\x64\Release或bin\Release目录避免生成周期交叉导致加载旧文件。VS2019的配置管理器入口在“生成 - 配置管理器”这里可以分别为C#和C项目选择平台。注意C项目里x86平台名显示为Win32但它生成的DLL确实是32位不能和C#的x64混用。4.2 运行时库/MT、/MD 的选择与部署C项目的“C/C - 代码生成 - 运行库”有四个选项其中两个最关键选项含义部署要求多线程(/MT)静态链接运行时DLL不依赖VCRUNTIME单文件复制但体积大多线程DLL(/MD)动态链接运行时DLL依赖VC运行库目标机器需装VC Redistributable如果你用/MT编译DLL则生成的动态库不依赖vcruntime140.dll和msvcp140.dll在目标电脑上只需要复制MathLib.dll即可。但这会导致一个进程里同时存在多份静态运行时如果C#程序自己用了C混合模式可能遇到内存分配和释放不在同一运行时中的问题例如C#释放C分配的内存量会出错。常规选择是/MD然后在发布时带上“Microsoft Visual C Redistributable”也就是常说的VC运行库合集。许多软件打包工具都有这个组件也可以在上位机安装程序里加入vc_redist.x64.exe静默安装。对于只分发少量工具的场景也可以把vcruntime140.dll、vcruntime140_1.dll、msvcp140.dll放到程序根目录。从维护角度讲装Redistributable是更干净的方式。在VS2019里Debug配置默认是/MDdRelease配置默认是/MD。不要把Debug版的DLL发给现场因为Debug版动态库依赖调试运行时外面电脑通常没有。这里还要注意使用/MT时导出的函数名和调用约定不变但生成日志里会多出libcmt.lib等链接输入不要以为是外部依赖。4.3 用 dumpbin 和 Dependencies 检查DLL依赖DLL能不能在目标机器上跑取决于它的依赖项是否存在。使用VS2019开发者命令行执行dumpbin /dependents MathLib.dll输出大致如下Image has the following dependencies: KERNEL32.dll VCRUNTIME140.dll api-ms-win-crt-runtime-l1-1-0.dll第一行KERNEL32.dll是Windows系统库不用担心。VCRUNTIME140.dll是VC运行库的一部分必须在目标机器上存在。api-ms-win-crt-*属于UCRTWindows 10以上系统自带Windows 7需要安装更新或补丁包。如果你看到MSVCP140D.dll说明当前调试出了Debug版本发布前必须切到Release重新编译。dumpbin的优点是随VS2019自带缺点是输出信息比较原始。开源工具Dependencies原名Dependency Walker的现代替代能可视化显示依赖树和缺失项适合定位嵌套依赖问题。当C#项目引用C动态库时还有一个常见坑DLL的搜索顺序是“程序所在目录 - 系统目录 - PATH目录”。C#开发环境里通常把DLL放在bin\Debug或bin\Release但如果C项目的输出目录没指向这里VS2019运行时就会报找不到DLL。解决办法是在C项目属性中添加生成后事件copy /Y $(OutDir)MathLib.dll $(SolutionDir)CSharpHost\bin\$(Configuration)\这里$(OutDir)是C项目输出目录$(SolutionDir)是解决方案目录$(Configuration)是当前编译配置。这样每次编译C动态库后DLL会直接进入C#的调试目录省去手动复制的步骤。如果是x64平台路径可能要加上$(PlatformTarget)比如bin\x64\Debug。5. 动手前的验证用C测试程序确认DLL可用5.1 一个最小测试用例动态库编好后先不要急着写C#代码。新建一个C控制台应用项目引用刚才生成的.lib验证导出函数是否能正确链接和调用。用VS2019新建“控制台应用”然后粘贴以下代码#include cstdio #include MathLib.h int main() { int sum MathLib_Add(3, 4); int prod MathLib_Multiply(3, 4); printf(Add: %d\n, sum); printf(Multiply: %d\n, prod); return 0; }在项目属性“链接器 - 输入 - 附加依赖项”中添加MathLib.lib并把MathLib.lib和MathLib.dll复制到测试程序的输出目录。如果链接正确运行后看到Add: 7和Multiply: 12说明DLL导出、函数签名、调用约定都没有问题。这一步能在纯C环境里排除平台和运行时干扰。用C#快速验证也是可以的。新建一个C#控制台项目把DLL放到bin\Debug目录再用DllImport声明同样的函数using System; using System.Runtime.InteropServices; class Program { [DllImport(MathLib.dll, CallingConvention CallingConvention.Cdecl)] public static extern int MathLib_Add(int a, int b); static void Main() { Console.WriteLine(MathLib_Add(3, 4)); } }输出7的话整个P/Invoke链路就算通了。注意C#项目平台目标要与C DLL一致否则会出现BadImageFormatException。5.2 常见编译错误与导出符号检查C测试程序最常见的是LNK2019错误信息会提示无法解析的外部符号。排查步骤是执行dumpbin /exports MathLib.dll确认导出名到底叫MathLib_Add还是?MathLib_AddYAHHHZ。如果是后者说明头文件里的extern C被条件编译宏排除或者调用方没有包含同一个头文件。另一个高频问题是.lib没有正确链接。 VS2019中LNK1104无法打开文件MathLib.lib时要检查“附加依赖项”里的路径和库文件名以及测试程序的“平台”是不是x64。x64的DLL必须对应x64的导入库。最后运行测试程序前用dumpbin /dependents确认目标机器上缺失的运行库把DLL复制到新环境的Program Files目录后也要检查是否有权限读目录或被杀毒软件拦截。这套验证走通后C动态库的编制这个地基才算是真正站稳后续在C#里封装高级接口时问题定位会轻松很多。本文还有配套的精品资源点击获取