资讯动态

C# DLL封装与调用全指南:从托管引用到原生互操作

发布时间:2026/10/23 6:11:13 来源:尧图企业网站定制
简介一套面向.NET开发者的C# DLL封装与调用实例工程适合正在学习类库设计、程序集复用或项目模块化的人员。压缩包内含38个文件整体约53KB包含8个cs源码文件以及dll、exe、pdb、sln、csproj等工程与调试文件覆盖源码、编译输出和项目配置结构紧凑、便于直接打开学习。资源以DllTest与TestDll两个项目组成一端演示如何编写公共类并编译生成DLL另一端演示添加引用、导入命名空间、实例化并调用方法直观展示完整的封装与调用闭环同时可在Visual Studio中对照工程配置、生成目录和调试信息理解DLL在.NET框架中的组织方式。已有3574人学习下载适合作为入门到进阶的参考示例尤其适合需要快速上手DLL封装、跨项目共享代码的C#开发者。1. C# DLL封装与调用看起来是引用一下翻车全在细节里很多人第一次做C# DLL封装觉得无非是新建类库项目、编译出一个.dll文件、再被别的项目Add Reference。这个流程本身确实简单但真实场景里几乎没人只走这一条路。要么你的DLL要被C或Python这类非托管侧调用要么你需要调别人用C写好的原生DLL要么你撞上DllNotFoundException和BadImageFormatException轮番轰炸。我在某跨平台系统里就被这两个异常卡了将近两天最后定位到是x86/x64位数不一致和依赖链没带全两个问题叠加在一起。下面把C# DLL封装与调用完整拆一遍托管DLL的编译边界与三种调用方式、把C# DLL导出成非托管接口给C用、C#调原生DLL的参数映射与编码细节以及加载失败类问题的排查顺序。新手可以按步骤复现被加载问题折磨过的老手可以直接跳到第5章的排查清单对号入座。2. 托管DLL封装类库项目的边界与三种调用方式2.1 类库项目与可执行项目的编译差异先明确概念C#里的托管DLL本质是类库项目编译出来的程序集文件。它和EXE的差别不只是扩展名而是没有程序入口点Main方法输出文件可以被其他程序集引用。在csproj里这个差异只由一个节点控制Project SdkMicrosoft.NET.Sdk PropertyGroup !-- 目标框架决定DLL能被谁引用 -- TargetFrameworknet8.0/TargetFramework !-- Library输出dllExe输出exe -- OutputTypeLibrary/OutputType AssemblyNameDemoLib/AssemblyName RootNamespaceDemoLib/RootNamespace /PropertyGroup /ProjectOutputType为Library时dotnet build会在bin/Debug/net8.0下生成DemoLib.dll把OutputType改成Exe则生成DemoLib.exe。这里最容易翻车的是TargetFrameworknet8.0的DLL只能被目标框架为net8.0或更高版本的项目引用。如果调用方是.NET Framework 4.7.2的老项目类库就必须把目标框架降到netstandard2.0或net472否则编译期直接报不兼容。类库目标框架的选择可以参考这张表类库TargetFramework能引用它的调用方典型场景netstandard2.0.NET Framework 4.6.1、.NET Core 3.0、.NET 5需要兼容多版本的老项目net472.NET Framework 4.7.2老框架直接引用DLLnet8.0.NET 8新项目可用较新的API我一般会顺手打开GenerateDocumentationFile生成的XML注释文件在调试时能看到完整签名省去反编译的麻烦。另外注意AssemblyName和项目名可以不同但建议保持一致否则后面反射加载时按“命名空间.类名”取类型会绕晕。还有一个容易漏的点类库项目里如果引用了System.Windows.Forms这类桌面程序集目标框架就不能是netstandard2.0这在封装UI组件库时尤其要提前确认。2.2 编译期项目引用与文件引用同一解决方案内调用自己的DLL首选项目引用!-- 指向类库的csproj编译期会生成依赖关系 -- ItemGroup ProjectReference Include..\DemoLib\DemoLib.csproj / /ItemGroupusing DemoLib; var calc new Calculator(); Console.WriteLine(calc.Add(2, 3));项目引用的优势是编译期强类型检查、可以F12跳转源码、调试时能直接跟进DLL内部。它的依赖项会跟着主项目输出部署时DLL自动复制到bin目录基本不用手动管。缺点是把源码结构耦合进来了给别人发的是整个源码工程不太适合对外分发。对外发DLL时改用文件引用csproj里写成ItemGroup Reference IncludeDemoLib !-- HintPath指向DLL实际位置 -- HintPath..\libs\DemoLib.dll/HintPath !-- Private决定是否复制到输出目录 -- Privatetrue/Private /Reference /ItemGroup注意Private节点它决定DLL是否复制到输出目录默认是true如果设成false本地能编译但运行时大概率DllNotFoundException。这是文件引用最常见的坑我在模拟项目X里排查过好几次都是这个节点被顺手改掉导致的。还有一个细节HintPath在换机器或换目录后容易失效如果报找不到引用先看HintPath指向的路径存不存在别急着删引用重新加。2.3 运行时反射加载解耦的代价第三方只给你一个.dll文件、不带pdb和源码时编译期引用用不了只能反射加载// 按路径加载程序集 var asm Assembly.LoadFrom(DemoLib.dll); // GetType要传完整的“命名空间.类名” var type asm.GetType(DemoLib.Calculator); var instance Activator.CreateInstance(type); var method type.GetMethod(Add); var result method.Invoke(instance, new object[] { 2, 3 }); Console.WriteLine(result);LoadFrom按指定路径加载程序集GetType必须传完整的“命名空间.类名”。这里有个血泪经验GetType返回null时先确认DLL里命名空间是不是你写的那样别拿文件名当类名猜。另外Invoke的反射调用性能比直接调用低不少插件类、低频加载场景合适核心热路径别用。反射方案适合插件系统这类“运行时才知道有哪些DLL”的需求。它还有个隐藏问题DemoLib.dll如果依赖别的程序集LoadFrom之后调用方法时CLR会去默认探测路径找依赖项找不到就抛FileNotFoundException。我在插件系统里经常遇到解决办法是配合AppDomain.AssemblyResolve事件手动指定依赖路径第6章专门讲。先把三种调用方式的边界理清项目引用强耦合但最省心文件引用适合对外分发反射适合插件化场景。3. 把C# DLL导出成非托管接口DllExport与调用约定3.1 为什么托管DLL不能直接给C用C#编译出的DLL是托管程序集里面是IL中间语言和元数据C用LoadLibrary能加载但GetProcAddress拿不到你写的函数地址因为函数名经过名称修饰且调用需要CLR环境。要让C、Delphi或Python的ctypes直接调用C#逻辑常见三条路方案维护成本适用场景C/CLI包一层高要单独工程仅Windows、有C工程能力COM组件很高注册与版本管理繁琐老系统强依赖COMDllExport导出低加特性即可跨语言快速调用最通用DllExport的原理是在编译后处理阶段把打了特性的方法生成对应的导出表让GetProcAddress能按名字找到函数。这样C# DLL就同时拥有了托管和非托管两张脸C#程序可以当普通类库引用C可以当原生DLL加载。我当初对接某跨平台系统的C上位机就是靠这个方案把C#算法库直接塞给C侧用的。3.2 导出方法与调用约定先通过包管理器安装DllExport相关库然后在静态方法上打特性using System.Runtime.InteropServices; public static class NativeExport { // 导出成原生函数addC侧按这个名字找 [DllExport(add, CallingConvention CallingConvention.Cdecl)] public static int Add(int a, int b) { return a b; } }编译配置可以记成固定流程一项目平台从AnyCPU改成明确的x64或x86二安装DllExport相关库三在需要导出的静态方法上打[DllExport]四构建后立刻用dumpbin /exports DemoLib.dll核对导出表里有没有add这个符号。前三步很多教程都会写第四步是我一直保留的习惯导出表对不上就往下查而不是反复改代码重编。调用约定这里必须统一C侧声明成cdeclC#侧CallingConvention就写Cdecl反过来如果用stdcall两边都要stdcall。混用的话程序不会编译报错但运行时会栈不平衡轻则返回垃圾值重则直接崩溃。C侧更推荐cdecl参数由调用方清理兼容性最好。3.3 C侧调用完整步骤#include windows.h #include iostream typedef int (*AddFunc)(int, int); int main() { HMODULE hLib LoadLibraryA(DemoLib.dll); if (!hLib) { std::cerr load failed, code: GetLastError() std::endl; return -1; } // 从导出表按名字取函数地址 auto add reinterpret_castAddFunc(GetProcAddress(hLib, add)); if (!add) { std::cerr symbol not found std::endl; FreeLibrary(hLib); return -1; } std::cout 3 4 add(3, 4) std::endl; FreeLibrary(hLib); return 0; }LoadLibraryA是ANSI版本API路径里有中文时建议直接用宽字符版本LoadLibraryW。GetProcAddress找不到符号时先回dumpbin确认导出名DllExport如果没指定名字默认导出的是方法名但有些配置会生成带版本后缀的名字对不上就白折腾。这个方案有一个限制导出的C#方法必须是static因为原生调用没有对象上下文。你要操作的对象状态要么通过参数传入要么自己在DLL内部维护静态状态。我在某跨平台系统里就是把缓存对象做成静态字段导出函数全部绕它转发C侧拿到的是一组无状态函数。Python侧用ctypes接同样的DLL更简单CDLL加载后设置argtypes和restype即可调用约定的坑和C完全一样。4. C#调原生DLLDllImport参数映射与编码细节4.1 参数类型映射与声明调用方向反过来C#要调C/C写好的DLL时用P/Invoke的DllImport。类型映射是第一道坎Windows下C的long是32位C#的long是64位照抄会直接读错内存C/C类型C# DllImport签名说明intint32位整型最常用longWindowsintWindows下long是32位别用C#的longlong longlong这才是真正的64位double / floatdouble / float宽度对应const char*string配合CharSet指定编码char* 输出缓冲区StringBuilder需要预设容量void* / 句柄IntPtr不要用int存指针结构体指针ref结构体 / out结构体结构体要配StructLayout声明示例// 文件名不带路径不带.dll后缀CLR按默认顺序搜索 [DllImport(native.dll, CallingConvention CallingConvention.Cdecl, CharSet CharSet.Ansi)] private static extern int native_add(int a, int b);DllImport的第一个参数是DLL文件名不带路径、不带.dll后缀是惯例CLR会按默认搜索顺序找。CallingConvention必须和原生侧一致属性默认是Winapi实际是stdcall而C/C里默认是cdecl不写死这里早晚踩坑。如果导出名和方法名不一致用EntryPoint显式指定// 导出名是add_c方法名保持C#风格 [DllImport(native.dll, EntryPoint add_c, CallingConvention CallingConvention.Cdecl)] private static extern int Add(int a, int b);EntryPoint还有个隐藏用法同一个方法可以声明多个不同签名的DllImport用EntryPoint指向同一个导出函数做可选参数的适配省得在C#侧重载绕圈子。4.2 字符串与结构体传参字符串是最容易出中文乱码的地方。原生DLL里如果是char*CharSet就写Ansi如果是wchar_t*就写Unicode。两边编码不一致短字符串看着像乱码长字符串直接内存越界// StringBuilder用作输出缓冲区必须预设容量 [DllImport(native.dll, CharSet CharSet.Unicode)] private static extern void get_name(StringBuilder buffer, int bufferSize); var sb new StringBuilder(256); get_name(sb, sb.Capacity); Console.WriteLine(sb.ToString());StringBuilder传参时一定要预设足够容量原生侧会按bufferSize往里面写容量给小了轻则字符串被截断重则原生侧越界写崩进程。这个方法比string更安全因为string封送时是只读的不能当输出缓冲区用。还有一种更稳妥的做法是直接返回IntPtr由调用方用Marshal.PtrToStringAnsi或PtrToStringUnicode自行转换编码问题被限制在调用方一处。结构体传参要先把布局对齐说清楚// Sequential按声明顺序连续排列最稳的选择 [StructLayout(LayoutKind.Sequential)] public struct Point { public int X; public int Y; } [DllImport(native.dll, CallingConvention CallingConvention.Cdecl)] private static extern int point_distance(ref Point p1, ref Point p2);LayoutKind.Sequential按声明顺序连续排列是最常见最稳的选择。如果原生结构体有#pragma pack特殊对齐C#侧要补Pack参数。结构体里如果有char数组、嵌套结构体或联合体封送规则很复杂我一般改成IntPtr加Marshal.PtrToStructure手动处理绕开隐式封送的玄学。4.3 回调函数与生命周期原生DLL主动回调C#时用委托做函数指针// UnmanagedFunctionPointer明确调用约定 [UnmanagedFunctionPointer(CallingConvention.Cdecl)] public delegate void ProgressCallback(int percent); [DllImport(native.dll, CallingConvention CallingConvention.Cdecl)] private static extern void start_work(ProgressCallback callback); // 委托必须存字段防止GC回收 private ProgressCallback _callback; _callback p Console.WriteLine(${p}%); start_work(_callback);关键在于回调委托对象必须在native.dll调用期间一直存活。CLR的GC不知道原生侧还持有这个函数指针如果委托被回收回调时拿到的就是野指针程序在没有任何异常的情况下崩溃。我把回调委托存成类字段保证生命周期覆盖整个调用过程而不是用完就丢。这种崩溃看不出堆栈事后在回调里加日志是最快的定位方式。如果回调是从非托管线程发起的还要注意回调里别碰UI线程跨线程操作控件会抛InvalidOperationException正确做法是用同步上下文把回调内容切回UI线程这个坑在封装进度通知类DLL时几乎必踩。5. DLL封装调用避坑加载失败、位数与依赖地狱5.1 DllNotFoundExceptionDLL明明在输出目录还是找不到现象程序启动正常一旦调用DllImport声明的函数就抛DllNotFoundException异常信息里的加载路径指向系统目录而不是你的输出目录。原因CLR搜索DLL的顺序是先看进程目录再看系统目录和PATH。DLL在输出目录但没被复制到进程目录时找不到如果是32位进程系统目录搜索会被重定向到SysWOW64你的x64 DLL根本放不进去。解决先用任务管理器确认进程实际位数再把DLL复制到exe同目录用绝对路径测一次能加载就说明是搜索路径问题最后查csproj里Reference节点的Private属性以及项目属性的CopyLocal。运行时才解压出来的DLL配合AppDomain.AssemblyResolve手动指定完整路径见第6章。5.2 BadImageFormatException本机好好的换台机器就崩现象开发机上跑得好好的发布到服务器或另一台电脑启动即抛BadImageFormatException进程直接起不来。原因开发机装了SDKAnyCPU的DLL会按当前进程位数JIT解释问题被掩盖目标机器上进程是x64但DLL是x86编译的或者反过来。DllImport声明本身不校验位数实际调用那一刻才爆。解决项目平台从AnyCPU改成明确的x64或x86主项目和所有类库保持一致。涉及原生DLL的项目我现在的统一约定是显式指定PlatformTarget不赌AnyCPU因为AnyCPU的DLL被x86进程引用时看起来正常一旦混入一个明确x86的DLL就崩排查起来比直接报错痛苦得多。5.3 依赖链没带全原生DLL内部还依赖别的DLL现象主DLL能加载但调用某个导出函数时程序直接崩溃或者抛EntryPointNotFoundException单独测主DLL没有任何问题。原因很多原生DLL不是孤立的它依赖VC运行库、第三方SDK等。你只部署了主DLL它内部的依赖找不齐加载时不会报错调用时才炸。解决用Dependencies工具打开主DLL看依赖树把缺失项全部补到输出目录。VC运行库依赖一般装对应版本运行库就能解决。发布前在干净环境不用开发机跑一遍冒烟测试是最稳的验证方式这个习惯帮我挡掉好几次线上事故。5.4 运行时版本错配Release下行为诡异现象Debug模式一切正常Release编译后偶发崩溃、乱码或者函数返回异常值而且不稳定有时候跑几分钟才出现。原因Release默认没有调试器托管JIT优化更激进原生互操作里的未定义行为更容易暴露更常见的是目标框架和部署机运行时不匹配比如net472的DLL跑在只有.NET Core的机器上。解决确认所有项目TargetFramework统一部署环境安装对应运行时。Release的互操作测试要专门做一轮不能只在Debug下测完就发。遇到偶发崩溃先在Release下关掉优化Optimizefalse对比一次能快速区分是代码问题还是优化暴露的问题。5.5 EntryPoint找不到导出名和声明名对不上现象DllImport编译通过运行时抛EntryPointNotFoundException提示找不到某个方法名但dumpbin里明明有类似名字。原因C/C编译时默认有名称修饰函数名可能变成_add或?addYAHHHZ或者DllExport导出时指定了别名。DllImport默认用你写的方法名去找对不上就失败。解决用dumpbin /exports看实际的导出名然后在DllImport里用EntryPoint指定精确名字方法名可以保持C#风格。C侧导出时如果用了extern C能避免名称修饰这是源头上的解法。6. 进阶技巧用AssemblyResolve收口加载路径快速定位DLL缺失6.1 AppDomain.AssemblyResolve兜底托管程序集依赖找不到时CLR会触发AssemblyResolve事件。很多项目把第三方DLL放到子目录libs里靠这个事件统一收口// 放在Main开头进程启动时注册一次 AppDomain.CurrentDomain.AssemblyResolve (sender, args) { // 去掉版本、Culture等后缀只留程序集名 var name new AssemblyName(args.Name).Name; var candidates new[] { Path.Combine(AppContext.BaseDirectory, libs, name .dll), Path.Combine(AppContext.BaseDirectory, name .dll) }; var path candidates.FirstOrDefault(File.Exists); return path ! null ? Assembly.LoadFrom(path) : null; };这段代码在Main开头执行一次。new AssemblyName(args.Name).Name去掉版本、Culture等后缀只留程序集名搜索路径先用子目录libs再用主目录兜底。返回null时CLR继续默认探测不会掩盖真正的缺失。这个事件是一把双刃剑它能解决加载路径问题但也可能掩盖版本冲突。DLL版本对不上时事件里最好加版本过滤比如检查args.Name里的版本号只有匹配才LoadFrom否则返回null。我在某图像处理Demo里就吃过亏事件写得过于宽松结果运行时加载了旧版本DLL行为全变。6.2 发布前必查三样发布前我会固定走一遍这三个检查一是用corflags查托管DLL的位数标志确认ILONLY或32BITREQUIRED符合预期二是用Dependencies看原生DLL的依赖树缺一个都不发三是写一个最小调用demo覆盖最常用的几个导出函数在干净环境跑一遍冒烟测试。这套检查流程可以直接抄进发布脚本配合第5章的排查清单加载类问题基本能在十分钟内定位。从那以后我每次做DLL封装或对接别人DLL都强制走一遍“对位数、对依赖、对调用约定”这三件事再复杂的问题也能快速收敛。希望帮到你少走我踩过的这些弯路。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑