简介:面向.NET初学者的C# DLL封装与调用示例资源,适合需要把通用功能抽成类库、在多个项目中反复引用的开发者。包内以类库工程与调用端程序成对呈现,完整覆盖从创建类库、公开接口、编译生成DLL,到添加引用、导入命名空间、实例化并调用方法的过程,并包含Assembly.LoadFrom延迟加载等实用点。整个RAR压缩包共38个文件,以cs源码、sln/csproj工程文件、dll/exe编译产物、pdb调试符号为主,体积仅53KB,结构清晰,打开解决方案即可对照学习。已有3574人学习下载,借助类库工程与WinForm调用端的代码对照,可直观理解DLL封装边界、项目引用关系以及运行时加载机制,适合作为日常组件化开发的入门参考。
1. C# DLL封装:你以为的导出,和实际调用的边界
做跨语言集成的时候,C# DLL封装总给人一种“很成熟、不用学的错觉”。真上手才会发现,托管世界和非托管世界中间隔着一条看不见的缝:你导出的函数名可能被悄悄改了名,结构体传过去内存布局整个错位,回调函数没几下就被垃圾回收了。这套东西不是写个类、加个特性就能跑的。这篇笔记是我拆解过的几个真实对接场景攒出来的,覆盖了托管导出、静态调用、动态加载三种主流玩法,把参数怎么设、封送怎么写、32/64位怎么对齐这些细节一条条给你过一遍。适合要把C#算法库封装给C/C++程序用,或者反过来要调一个老C库的开发者,新手能照着走通全流程,熟手可以直接跳到第5章的翻车清单对号入座。
2. 托管导出:把C#代码变成C风格接口
2.1 为什么要把C#导出成非托管接口
常见的需求是把C#写的算法逻辑(图像处理、加密解密、数值计算)封装成DLL交给C++调用。直接引C#程序集对C++那边太不友好,跨版本运行时不说,依赖关系也理不清。更干净的做法是在C#代码里标注导出的C风格函数,让C++像调普通C函数一样调它,不用管托管运行时的存在。技术上这依赖运行时宿主(runtime host)把托管程序集加载进来,但对外暴露的就是一组extern "C"接口,C++开发者的心智负担最小。
2.2 用DllExport给C#方法挂导出标记
最常用的方案是NuGet上搜DllExport包,装上之后给静态方法加[DllExport]特性。这里有个前提:目标框架建议用.NET Framework 4.x,配合x86或x64的编译选项。别的实现比如绕开UnmanagedExports自己写宿主,但对于绝大多数场景,这个包就是最短路径。
using RGiesecke.DllExport; using System.Runtime.InteropServices; public class NativeFacade { [DllExport("Add", CallingConvention = CallingConvention.StdCall)] public static double Add(double a, double b) { return a + b; } [DllExport("GetVersion", CallingConvention = CallingConvention.StdCall)] public static int GetVersion() { return 2; } }这段代码把两个静态方法标记成非托管导出。DllExport的第一个参数是导出后的函数名,如果不写,默认用的是方法名。CallingConvention建议显式声明,C++项目那边一般默认用__stdcall,如果C++代码里定义函数指针用的是__cdecl,两边对不上,调用时栈会被破坏,现场表现是“第二次调用必崩”。
导出之后C++那边这样声明:
typedef double(__stdcall *pAdd)(double a, double b); typedef int(__stdcall *pGetVersion)(); // 加载dll后取函数地址 pAdd add = (pAdd)GetProcAddress(hModule, "Add");2.3 限制参数类型,别让复杂对象过界
托管导出最忌讳直接传复杂对象。有人试图把Dictionary<string, int>作为导出参数,托管宿主和调用方对同一块内存的理解根本不在一个维度,结果就是内存损坏。能安全过界的类型只有几种:基础数值类型、字符串指针(IntPtr或string)、结构体(且布局必须可控)、委托(回调函数)。我一般会在DLL内部把C#对象的序列化逻辑包好,导出函数只接受JSON字符串或结构体指针,这样接口稳定,也方便后续修改内部实现。
对于字符串返回值,我习惯让调用方传入一个StringBuilder缓冲区,而不是直接返回string。因为非托管调用方很难知道该分配多大内存,返回托管堆里的字符串指针又容易在GC移动对象后失效。
3. 静态调用DllImport:类型映射与参数封送
3.1 DllImport的三个核心参数
C#调用非托管DLL,九成场景用DllImport就够了。真正影响正确性的参数是EntryPoint、CharSet、CallingConvention。EntryPoint决定CLR从DLL导出表里找哪个函数名;CharSet决定字符串参数按ANSI还是Unicode编码传递;CallingConvention必须和DLL实现保持一致,用错了栈平衡就乱。
[DllImport("legacy_core.dll", EntryPoint = "process_frame", CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)] public static extern int ProcessFrame(string inputPath, StringBuilder outputBuffer, int bufferSize);这里CharSet.Ansi表示把string转成ANSI字节序列传入。如果C库这边接收的是UTF-8,就得在调用前自己把string转成byte[],CharSet提供的控制粒度有限。StringBuilder用来接收C端写入的字符串,CLR会自动处理双向封送。
3.2 默认的整型映射表
C#调用C/C++ DLL最需要背的一张表是不同类型的大小对照。C#的int是32位定长,C++的long在Windows 64位下是32位,但在Linux下是64位。Windows平台上一对一映射时,long long对应C#的long(64位),unsigned char对应byte。
| C/C++ 类型 | C# 类型 | 说明 |
|---|---|---|
| int / unsigned int | int / uint | 均为32位,通用 |
| long (Windows) | int | Windows下long是32位,容易误写成C# long |
| long long | long | 64位整型 |
| char* / const char* | string / StringBuilder | 取决于读写方向 |
| float*(输出数组) | float[] | 配合MarshalAs或预设大小 |
| void* | IntPtr | 不透明指针 |
最容易翻车的是bool。C++的bool是1字节,C#的bool默认封送为4字节的Win32 BOOL,不加[MarshalAs(UnmanagedType.I1)],跨边界传的就是错的东西。
3.3 结构体传参:布局和大小先对齐
结构体比基础类型麻烦,关键是让两端的内存布局一致。C#里要显式加StructLayout(LayoutKind.Sequential),必要时加Pack控制对齐值。Pack的值必须和C++编译选项里的#pragma pack一致,否则字段偏移错位。
[StructLayout(LayoutKind.Sequential, Pack = 1)] public struct FrameHeader { public uint Width; public uint Height; public byte PixelFormat; public byte Reserved; } [DllImport("video_codec.dll", CallingConvention = CallingConvention.Cdecl)] public static extern int DecodeFrame(byte[] compressedData, int dataLen, ref FrameHeader header, byte[] outputPixels);ref关键字让结构体按引用传递,C端拿到的就是指针。如果C端要求传结构体数组,可以用[In, Out]修饰数组参数,或者把数组首地址转成IntPtr再传。使用Pack = 1时要注意性能,紧凑布局省内存但可能在非对齐地址上访问变量,C++那边读起来会慢一点点,但这种结构体做协议头时通常值得。
4. 动态加载:LoadLibrary与函数指针的完整套路
4.1 什么场景必须用动态加载
静态DllImport在程序集加载时就会解析DLL,找不到则直接抛DllNotFoundException,程序起不来。所以插件式架构、DLL路径在运行时才确定、或者面对同一接口不同版本DLL切换时,一定要用动态加载。动态加载的完整链路是LoadLibrary→GetProcAddress→ 构造委托 → 调用 →FreeLibrary。
4.2 封装一个动态调用工具类
public class NativeInvoker { private IntPtr moduleHandle; public bool LoadModule(string dllPath) { moduleHandle = LoadLibrary(dllPath); return moduleHandle != IntPtr.Zero; } public TDelegate GetFunction<TDelegate>(string funcName) where TDelegate : class { IntPtr funcPtr = GetProcAddress(moduleHandle, funcName); return Marshal.GetDelegateForFunctionPointer(funcPtr, typeof(TDelegate)) as TDelegate; } [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Ansi)] static extern IntPtr LoadLibrary(string lpFileName); [DllImport("kernel32.dll", SetLastError = true)] static extern IntPtr GetProcAddress(IntPtr hModule, string procName); }GetDelegateForFunctionPointer是整条链路的核心。它会根据你传入的委托类型,把非托管函数指针包装成可安全调用的托管委托。委托类型的签名定义一定要准确,参数个数、类型、调用约定都错了,调用时大概率触发AccessViolationException或者返回垃圾数据。
4.3 函数指针和委托的边界
动态调用里最容易忽略的是函数指针的生命周期。如果委托变量先被GC回收,再执行调用,程序会直接崩在非托管代码里。所以要么把委托保持在类字段上,要么在调用完成后优先确保没有执行中的回调再释放。
另一个细节是GetProcAddress的字符集。它默认按ANSI查找导出名,如果DLL是用.def文件导出的非装饰名,需要区分GetProcAddress和GetProcAddressW。在Windows平台上处理纯C风格导出函数,用ANSI版就够了。如果需要区分函数名重载或版本号,可以用GetProcAddress后比较函数地址。
实际项目中,我一般这样排查:先用第6章里的dumpbin导出函数清单,确认真实名字不是带修饰的C++名,再考虑用def文件或extern "C"导出,最后才是写委托调用。顺序反了,会浪费很长时间在函数找不到这种低级问题上。
5. 防坑清单:DLL封装调用最容易翻车的六个现场
5.1 BadImageFormatException:32/64位不一致
现象:调用LoadLibrary或DllImport时抛BadImageFormatException,提示不是有效的Win32应用程序。
原因:最常见的不是DLL文件损坏,而是主程序进程位数和DLL位数不匹配。DLL是x64,但C#项目编译成了x86(或反之)。对于AnyCPU编译的项目,在64位系统上会以64位进程运行,老C库常常只提供x86版本,一调用就炸。
解决:确认DLL位数的方式很简单,用dumpbin /headers xxx.dll看FILE HEADER VALUES里的machine字段:14C是x86,8664是x64。然后对齐C#项目的Platform target。如果DLL是32位但C++那边执意要跑64位进程,那就只能找DLL的64位版本,跨位数在Windows上没法凑合。
5.2 字符串返回值乱码
现象:C函数返回的char*在C#里读出来是乱码,或者只有第一个字符正常。
原因:CharSet设置错误。C库保存的字符串是UTF-8字节序列,但C#按ANSI解析;反过来C库存ANSI,C#按Unicode解析也会乱。还有一种隐蔽情况:C库返回的是局部栈变量指针,函数结束内存就被后续调用覆盖。
解决:先用十六进制查看返回值的内存字节,确定编码类型,再决定用什么编码解析。接收侧尽量避免让C库返回字符串指针,改成调用方传入byte[]缓冲区,写完再按字节流解析,这样编码问题完全由C#侧控制。
5.3 结构体数据错位,字段值全是错的
现象:结构体传过去后,C端读到的第一个字段对,后面字段全错,或者整体偏移几个字节。
原因:C#侧没有设置StructLayout.Pack,默认对齐值和C++的#pragma pack不一致。另外C#的long和C++的long long混淆也会导致结构体整体变大。
解决:明确两端结构体的实际字节数。C#侧写一个Marshal.SizeOf<T>(),打印出来和C++的sizeof(StructName)对比。对不上就从Pack、字段类型、数组长度三个方向检查。
5.4 回调函数第一次正常,第二次崩溃
现象:C端通过C#传过去的委托回调数据,第一次执行没事,第二次调用就抛SEHException或者直接进程崩溃。
原因:委托对象被GC回收了。回调本质是让非托管代码持有托管委托指针,C#侧如果没有强引用,CLR在回收时会把那个函数指针变成野指针,C++再调就访问非法内存。
解决:把委托保存在静态字段或者类字段里,确保回调期间生命周期有效。同时用GCHandle.Alloc保持对象固定,完成后显式Free。这一步是血泪经验,我最早在项目X里调试回调崩溃,查了很多次才确认是引用被回收。
5.5 DllNotFoundException 或者文件找不到
现象:DllImport启动时找不到DLL,直接抛异常。DLL明明放在程序目录下,依然报错。
原因:DLL不只有目标模块,它还有其他依赖链。比如A.dll依赖B.dll,只复制A.dll过来没用。搜索顺序是:程序集目录 → 系统目录 → PATH环境变量。放在程序集目录里也会因为缺少VC++运行时而失败。
解决:先用dumpbin /dependents查看目标DLL的依赖列表,把缺失的运行库补上。另一个办法是在DllImport上指定SetLastError = true,配合Marshal.GetLastWin32Error()拿具体错误码再查。
5.6 内存泄漏:忘了释放非托管资源
现象:循环调用DLL里的分配函数,把返回的IntPtr丢掉,进程内存持续上涨。
原因:有些导出函数内部用了malloc或new,C#侧拿到指针但从不释放。GC只能管托管堆,对非托管指针无能为力。
解决:在设计接口阶段就约定“谁分配谁释放”。如果没有对应的释放函数,宁可把分配和释放都封装成单独的导出函数成对调用。我自己会在C#侧包一层Facade类,把分配和释放写成using块,保证异常路径也能走到释放逻辑。
6. 三步验证DLL封装可靠性:从导出表到并发冒烟
6.1 用dumpbin确认导出函数名
封装完成后的第一步永远是核对导出表。手动敲入函数名失败,很可能就是名字和声明不符。用Visual Studio自带的开发者命令行执行:
dumpbin /exports NativeFacade.dll重点看name列。如果看到函数名后面多了@8(位数)或?(C++修饰符号),说明导出方式不是纯C风格。出现这种修饰名时,要么改.def文件,要么检查是否用了extern "C"。我在项目里经常遇到Add@8这种名字,就是因为调用约定导致参数栈大小被编码进导出名。
6.2 冒烟测试用例设计
导出表没问题也不代表调用正确。写一个小控制台程序,每个导出函数做三组测试:正常参数、边界参数、异常参数。特别关注函数是否会非法访问内存:
static void SmokeTest() { var invoker = new NativeInvoker(); if (!invoker.LoadModule("NativeFacade.dll")) { Console.WriteLine("加载失败"); return; } var add = invoker.GetFunction<AddDelegate>("Add"); Console.WriteLine("Add(2, 3) = " + add(2, 3)); Console.WriteLine("Add(0, 0) = " + add(0, 0)); // 边界参数:注意这是故意的,观察是否会炸 Console.WriteLine("Add(-1, 5) = " + add(-1, 5)); }Add(-1, 5)这类边界用例看起来基础,但能提前暴露调用约定错误。如果调用约定不匹配,通常第一个正常用例能过,第二个或第三个开始栈破损,表现是返回值莫名其妙。
6.3 并发调用测试让隐藏问题现形
很多DLL封装问题只在并发场景暴露,比如全局静态缓冲区、非线程安全的编码状态。我在测试时会让8个Task同时调用同一个导出函数,每个Task循环执行1000次,然后校验结果一致性。如果某个环节用了共享缓冲区,测试结果立刻能看到部分返回值异常。
从那以后我每次封装DLL,都强制走一遍导出表检查、三组冒烟、并发测试这个流程,不再凭眼力和运气。特别是给老的C库做C#包装层时,这套流程帮我省下的排查时间不止一星半点。希望帮到你。
本文还有配套的精品资源,点击获取