
简介这是一个面向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 侧导出AddNumbersC# 侧用DllImport加载一个int函数跑通两门语言在 .NET Framework 4.8 下的互操作。适合第一次接触跨语言调用的开发也适合被 x64 位数不一致、EntryPoint 找不到这类异常卡住的人对照排查。压缩包里的.sln和源码可以直接用 VS2019 打开。2. C 侧 DLL 导出从空项目到可调用的 AddNumbersC 动态库在这里是“被调方”C# 只关心它导出了什么函数。工程里真正影响跨语言调用的是导出符号定义、调用约定和编译配置这三件事。2.1 工程模板用“动态链接库”还是“Win32 控制台应用”VS2019 新建项目时模板列表里可以直接选“动态链接库(DLL)”而不是 Win32 控制台应用再改。直接选 DLL 模板的好处是自带dllmain.cpp、pch.h、framework.h并且默认开启预编译头正好对应压缩包里的文件结构。老教程里用 Win32 控制台应用向导在“应用程序类型”里勾选“DLL”、再勾选“导出符号”效果完全一样但要多走一步向导。两者最终生成的工程配置都是“配置类型 动态库(.dll)”。在AAMED_DLL_DEMO1.vcxproj里关键属性是这样的属性项推荐值 / 说明配置属性 - 常规 - 配置类型动态库(.dll)配置属性 - 常规 - 平台工具集v142对应 VS2019C/C - 预编译头使用(/Yu)链接器 - 常规 - 输出文件$(OutDir)$(TargetName)$(TargetExt)如果不想用预编译头可以把pch.cpp和pch.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后导出表里的符号会变成类似?AddNumbersYAHHHZ的字符串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 编译为 DLLx64 下跑通一次在 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如果改成__stdcall32 位下导出名会变成_AddNumbers864 位下不受影响。因此 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\Debug或bin\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)Cbool是 1 字节C#bool也是 1 字节const char* GetVersion()string GetVersion()默认为 ANSI 字符串结构体指针out/ref结构体两侧都按顺序布局结构体字段一个容易翻车的例子是把 C 的unsigned char*映射成 C# 的byte[]这时需要配合Marshal.Copy或fixed来做内存搬运不能直接赋值。如果是结构体指针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.dll、VCRUNTIME140.dll、ucrtbase.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|x64C 项目也必须是 Debug|x64否则符号加载不上断点不会命中。用 Release 调混合代码也可以但往往要额外配置符号路径不如 Debug 来得省事。5.2 动态加载LoadLibrary GetProcAddressDllImport是静态绑定函数入口在首次调用时查找路径不灵活。如果 DLL 路径不固定或者想在缺失时给出友好提示可以直接调用kernel32的LoadLibrary和GetProcAddress[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再改成动态加载出问题时更容易定位。本文还有配套的精品资源点击获取