1. 为什么VS2017打包的C DLL在C#里调用总出“找不到入口点”或“无法加载模块”这个问题我带过三届某高校嵌入式方向的毕业设计几乎每届都有至少两个学生卡在同一个地方用VS2017写好了一个功能完整的C算法库比如图像灰度化、FFT频谱计算、或者自定义协议解析编译成DLL后在C# WinForm项目里用DllImport一调就崩——不是报System.DllNotFoundException: 找不到指定的模块就是System.EntryPointNotFoundException: 无法在DLL中找到名为xxx的入口点。更让人抓狂的是用Dependency Walker打开DLL明明导出函数都清清楚楚列在那里用C#的LoadLibrary手动加载返回句柄却是0。这时候很多人第一反应是“是不是路径错了”于是把DLL拖进C#项目的bin目录、Debug目录、甚至系统system32里反复试结果还是失败。根本原因不在路径而在于VS2017默认生成的C DLL根本没按C#能识别的方式导出函数。C有名字修饰Name Mangling机制编译器会把int Add(int a, int b)自动改写成类似?AddYAHHHZ这种人类不可读的符号名这是为了支持函数重载、类成员函数等C特性。但C#的DllImport是纯C风格调用约定它只认原始的、未经修饰的函数名。你用DllImport(MyMath.dll) public static extern int Add(int a, int b);C# runtime会在DLL的导出表里找叫Add的符号可实际导出的是?AddYAHHHZ——自然找不到。这就像你给快递员一张写着“张三”的收件单但他手里的包裹上贴的是“身份证号11010119900307251X”他当然没法投递。另一个常被忽略的陷阱是调用约定不匹配。C默认用__cdecl而C#DllImport默认假设是StdCall。两者在函数调用结束后清理栈的方式完全不同__cdecl由调用方C#清理StdCall由被调用方C DLL清理。一旦错配栈指针就会错位轻则返回值错误重则直接引发AccessViolationException——程序崩溃得毫无征兆。我见过最典型的一个案例某同学的DLL里有个返回std::string的函数他在C#里声明为string运行时偶尔正常偶尔在GC回收时崩掉查了三天才发现是调用约定和字符串内存管理双重踩坑。还有一类隐性问题来自平台架构不一致。VS2017新建C项目默认目标平台是x64而很多C#项目尤其老项目模板默认是Any CPU。当C#项目在64位系统上以64位进程运行时它只能加载64位DLL反之32位进程只能加载32位DLL。如果C#项目属性里Platform Target设成了x86而你的C DLL是x64编译的DllImport连DLL文件都加载不进来直接抛DllNotFoundException。这个错误信息极具误导性因为它没告诉你“是位数不匹配”只说“找不到模块”导致很多人去检查文件路径、权限、依赖项却忽略了最基础的架构对齐。最后C DLL如果依赖了其他动态库比如OpenCV的opencv_core340.dll、或者自己写的Utils.dll而这些依赖项没有随主DLL一起部署到C#项目的输出目录也会触发同样的DllNotFoundException。Windows加载器只会搜索当前进程目录、系统目录和PATH环境变量它不会递归查找DLL的依赖树。所以一个“独立”的C DLL在C#里往往并不独立——它背后可能拖着一长串依赖链。提示判断是否为架构问题最快速的方法是打开C#项目的“属性 → 生成 → 目标平台”确认其与C项目的“配置管理器 → 活动解决方案平台”完全一致同为x64或同为x86。不要信Any CPU在涉及原生DLL的场景下它几乎总是问题的根源。2. 从零开始VS2017 C DLL工程的正确创建与导出配置在VS2017里创建一个能被C#干净调用的C DLL绝不是建个新项目、写几行代码、点一下生成那么简单。整个过程需要精确控制编译器、链接器和源码三个层面的行为。下面是我经过数十次实测验证的标准流程每一步都有明确的技术依据。2.1 创建DLL项目并关闭C特性的干扰启动VS2017选择“文件 → 新建 → 项目”在模板中找到“Win32 → Win32项目”。注意不要选“动态链接库(.dll)”那个快捷模板它会自动生成一堆你不需要的ATL/COM框架代码。点击“下一步”在向导中勾选“DLL”和“空项目”取消勾选“预编译头”和“安全开发生命周期(SDL)检查”。这一步很关键预编译头如stdafx.h会引入windows.h等头文件而windows.h里定义的宏如WIN32_LEAN_AND_MEAN可能与后续导出逻辑冲突SDL检查则会强制启用/GS栈保护虽然安全但在某些老旧C#运行时环境下可能引发兼容性问题。项目创建完成后右键项目名 → “属性”进入配置属性页。首先定位到“常规 → 配置类型”确认其值为“动态库(.dll)”。然后切换到“C/C → 语言”将“符合标准一致性”设为“否(/permissive-)”。这个选项在VS2017中默认是“是”它会启用更严格的C17标准检查但会导致一些传统的C风格导出语法如extern C被误判为错误。接着在“C/C → 预处理器 → 预处理器定义”中添加MYMATH_EXPORTS名称可自定义但需与后续代码一致。这个宏的作用是让头文件能区分“DLL内部编译”和“外部调用”两种场景是实现条件编译导出的关键。2.2 头文件设计用extern C和__declspec(dllexport)双重锁定导出真正的技术核心藏在头文件里。新建一个MyMath.h内容如下// MyMath.h #pragma once #ifdef MYMATH_EXPORTS #define MYMATH_API __declspec(dllexport) #else #define MYMATH_API __declspec(dllimport) #endif // 声明导出函数必须用extern C禁用C名字修饰 extern C { // 使用__stdcall调用约定与C#默认保持一致 MYMATH_API int __stdcall Add(int a, int b); MYMATH_API int __stdcall Subtract(int a, int b); MYMATH_API double __stdcall CalculatePi(int iterations); // 导出一个接受C#字符串的函数注意字符编码 MYMATH_API void __stdcall ProcessString(const char* input, char* output, int outputSize); }这段代码有四个必须死记硬背的要点extern C是铁律它告诉C编译器“接下来的函数声明请用C语言的方式处理不要做任何名字修饰”。没有它Add函数导出的永远是?AddYAHHHZC#永远找不到。__declspec(dllexport)仅在DLL内部生效通过MYMATH_EXPORTS宏控制。当这个宏被定义即编译DLL项目时MYMATH_API展开为__declspec(dllexport)指示链接器将函数加入导出表当这个宏未被定义即C#项目包含此头文件时MYMATH_API展开为__declspec(dllimport)提示C#编译器该函数来自外部DLL。这是一种标准的、跨平台的DLL接口封装模式。显式声明__stdcall这是与C#DllImport默认调用约定对齐的唯一方式。如果你省略它C编译器会用默认的__cdecl而C#会用StdCall去匹配必然栈失衡。所有导出函数都必须加上一个都不能少。字符串参数必须用const char*C#的string在P/Invoke中默认按LPStrANSI字符串封送。如果你用std::string或wchar_t*会直接导致内存访问违规。output参数用char*而非std::string是因为DLL和EXE的堆内存管理器是独立的不能跨模块传递C对象。2.3 源文件实现避免C对象、异常和全局状态新建MyMath.cpp实现头文件声明的函数// MyMath.cpp #include MyMath.h #include cmath #include cstring // 用于strcpy_s // 实现Add函数 int __stdcall Add(int a, int b) { return a b; } // 实现Subtract函数 int __stdcall Subtract(int a, int b) { return a - b; } // 实现CalculatePi用莱布尼茨级数 double __stdcall CalculatePi(int iterations) { double pi 0.0; for (int i 0; i iterations; i) { double term (i % 2 0 ? 1.0 : -1.0) / (2 * i 1); pi term; } return pi * 4.0; } // 实现ProcessString安全地处理C#传入的字符串 void __stdcall ProcessString(const char* input, char* output, int outputSize) { if (input nullptr || output nullptr || outputSize 0) { if (output ! nullptr outputSize 0) { output[0] \0; } return; } // 计算输入长度确保不越界 size_t len strlen(input); size_t copyLen (len static_castsize_t(outputSize - 1)) ? len : static_castsize_t(outputSize - 1); // 使用安全的strcpy_s strcpy_s(output, outputSize, input); // 追加处理标记 strcat_s(output, outputSize, _PROCESSED); }这里有几个血泪教训总结绝对禁止在导出函数里抛C异常try/catch可以有但throw不行。Windows结构化异常SEH和C异常机制不互通一旦DLL里throwC#进程会直接终止没有任何捕获机会。所有错误都必须用返回值或输出参数如bool来传达。避免使用STL容器作为参数或返回值std::vector,std::map等对象的内存布局和分配器在不同编译器版本间不兼容。即使同一VS2017Debug和Release版的std::string内部结构也可能不同。坚持用原始C类型int,double,char*,void*。全局变量要极度谨慎如果DLL里有一个全局std::vectorint g_cache;当多个C#线程同时调用DLL函数时这个向量会被并发读写引发数据竞争。要么加锁增加复杂度要么彻底不用全局状态让所有状态都通过函数参数传递。strcpy_s和strcat_s是必须的它们是微软的安全增强版字符串函数能有效防止缓冲区溢出。outputSize参数就是为此而生——C#调用时必须预先分配足够大的StringBuilder并将容量传入这是跨语言内存安全的基石。2.4 链接器设置导出符号的最终确认头文件和源文件写完编译还不能保证成功。右键项目 → “属性”切换到“链接器 → 高级 → 导出符号文件”这里留空即可。真正的导出控制在“链接器 → 输入 → 模块定义文件(.def)”中。新建一个MyMath.def文件内容极简LIBRARY MyMath EXPORTS Add 1 Subtract 2 CalculatePi 3 ProcessString 4.def文件是Windows DLL导出的“宪法”它比__declspec(dllexport)更底层、更可靠。1,2等序号定义了函数在导出表中的位置索引这能让调用方C#通过序号而非名字加载函数速度更快且完全规避名字修饰问题。虽然extern C已经解决了名字问题但加上.def是双保险尤其在大型项目中能避免因宏定义顺序错误导致的导出遗漏。最后检查“链接器 → 常规 → 附加库目录”确保它指向你的C#项目输出目录如$(SolutionDir)CSharpApp\bin\Debug\。这不是必须的但它能让VS2017在编译C DLL时就检查其依赖的其他库如第三方SDK是否存在提前暴露链接错误。3. C#端的精准对接DllImport的每一个参数都是关键开关C#调用DLL不是写个DllImport就完事了。DllImport属性本身就是一个功能完备的“配置中心”它的每一个参数都对应着底层Windows API调用的一个开关。配置错误轻则功能异常重则程序崩溃。下面我逐个拆解那些被大多数人忽略的细节。3.1DllImport属性的完整语法与必填项在C#项目中新建一个NativeMethods.cs类所有P/Invoke声明都放在这里using System; using System.Runtime.InteropServices; internal static class NativeMethods { // 这是调用C DLL的完整、标准写法 [DllImport(MyMath.dll, CallingConvention CallingConvention.StdCall, // 必须与C的__stdcall一致 CharSet CharSet.Ansi, // 必须与C的char*一致 EntryPoint Add, // 显式指定入口点名防名字变化 ExactSpelling true, // 禁用大小写自动修正严格匹配 SetLastError false)] // 若C函数不调用SetLastError设为false public static extern int Add(int a, int b); [DllImport(MyMath.dll, CallingConvention CallingConvention.StdCall, CharSet CharSet.Ansi, EntryPoint Subtract, ExactSpelling true, SetLastError false)] public static extern int Subtract(int a, int b); [DllImport(MyMath.dll, CallingConvention CallingConvention.StdCall, CharSet CharSet.Ansi, EntryPoint CalculatePi, ExactSpelling true, SetLastError false)] public static extern double CalculatePi(int iterations); [DllImport(MyMath.dll, CallingConvention CallingConvention.StdCall, CharSet CharSet.Ansi, EntryPoint ProcessString, ExactSpelling true, SetLastError false)] public static extern void ProcessString( [MarshalAs(UnmanagedType.LPStr)] string input, // 明确告诉CLR如何封送string [MarshalAs(UnmanagedType.LPStr)] StringBuilder output, // 输出必须用StringBuilder int outputSize); }这段代码里CallingConvention CallingConvention.StdCall是生死线前面已详述。CharSet CharSet.Ansi同样致命它告诉CLR所有string参数都按单字节ANSI编码即char*处理。如果你的C函数用的是wchar_t*这里就必须改成CharSet.Unicode否则中文会变成乱码。EntryPoint Add是防御性编程——即使未来C端函数名重构为MyAdd只要这里不改调用依然指向旧的Add函数保证了二进制兼容性。ExactSpelling true则关闭了CLR的“智能匹配”比如把Add匹配成add或ADD强制精确匹配避免因大小写疏忽导致的EntryPointNotFoundException。3.2 字符串封送string与StringBuilder的本质区别C#中string是不可变的immutable一旦创建其内存地址和内容就固定了。当你把一个string传给C DLLCLR会为其分配一块临时的、非托管内存将字符串内容拷贝过去然后把这块内存的指针传给C。C函数执行完毕后这块临时内存会被立即释放。所以你永远不能在C DLL里试图修改一个string参数的内容因为那块内存已经不属于你了。而StringBuilder是可变的mutable它背后是一块由CLR管理的、可增长的非托管内存缓冲区。当你把StringBuilder传给DLLCLR传过去的是这块缓冲区的指针C函数可以安全地往里面写数据只要不超过outputSize指定的上限。这就是为什么ProcessString的C#声明里output参数必须是StringBuilder且必须用[MarshalAs(UnmanagedType.LPStr)]显式标注。在C#调用代码中正确的用法是// 错误示范用string接收输出永远得不到结果 string result ; NativeMethods.ProcessString(Hello, result, 100); // result还是空的 // 正确示范用StringBuilder并预先设定容量 var sb new StringBuilder(100); // 容量100对应C的outputSize NativeMethods.ProcessString(Hello, sb, sb.Capacity); Console.WriteLine(sb.ToString()); // 输出 Hello_PROCESSEDsb.Capacity必须与C函数签名里的outputSize参数完全一致。如果C里写的是char* output, int outputSize那么sb.Capacity就是outputSize的值。这是跨语言内存安全的契约违反它会导致缓冲区溢出Buffer Overflow是严重的安全漏洞。3.3 错误处理与调试如何让崩溃变得可诊断P/Invoke调用失败时错误信息往往模糊。DllNotFoundException可能是路径错、位数错、依赖缺失EntryPointNotFoundException可能是名字错、调用约定错、导出没生效。要快速定位必须建立一套调试组合拳。第一步用dumpbin命令行工具验证DLL导出。打开VS2017的“开发者命令提示符”cd到你的DLL目录执行dumpbin /exports MyMath.dll你会看到清晰的导出列表ordinal hint RVA name 1 0 00001000 Add 2 1 00001010 Subtract 3 2 00001020 CalculatePi 4 3 00001030 ProcessString如果这里看不到Add说明C端导出配置全错立刻回头检查.def文件和extern C。第二步在C#中启用本机代码调试。右键C#项目 → “属性 → 调试”勾选“启用本机代码调试”。这样当DLL里发生访问违规时VS会直接停在C源代码的出错行而不是弹出一个无意义的“程序已停止工作”。第三步用SetLastError机制捕获底层错误。如果C函数内部调用了Windows API如CreateFile、RegOpenKey并在失败时调用了SetLastError(ERROR_ACCESS_DENIED)那么在C#端你需要在DllImport里设置SetLastError true并在调用后立即用Marshal.GetLastWin32Error()获取错误码[DllImport(MyMath.dll, SetLastError true)] public static extern bool OpenConfigFile(string path); bool success OpenConfigFile(C:\config.txt); if (!success) { int error Marshal.GetLastWin32Error(); Console.WriteLine($Windows Error Code: {error}); // 根据error码查MSDN如5ACCESS_DENIED, 2FILE_NOT_FOUND }注意SetLastError true会带来微小的性能开销仅在确实需要捕获Windows API错误时才开启。对于纯计算型函数如Add应设为false。4. 实战排错从“无法加载模块”到“完美运行”的完整排查链路理论讲完现在进入最硬核的部分当你的C#项目编译通过但一运行就崩你该如何像一个经验丰富的系统工程师一样一步步抽丝剥茧找到那个隐藏的bug下面是我整理的一套标准化排查流程覆盖了95%以上的常见故障。4.1 第一层确认DLL物理存在与架构匹配这是最基础、也最容易被忽视的一步。打开C#项目的输出目录通常是bin\Debug\或bin\Release\确认MyMath.dll文件确实在那里。如果不在检查C项目的“生成事件 → 后生成事件”里是否有自动拷贝命令copy $(TargetDir)MyMath.dll $(ProjectDir)..\CSharpApp\bin\$(ConfigurationName)\如果没有就手动拷贝或者在C项目属性里设置“常规 → 输出目录”为C#项目的输出目录。然后用corflags工具检查架构。在开发者命令提示符中执行corflags CSharpApp.exe查看输出中的PE字段如果是PE32表示32位PE32表示64位。再用dumpbin /headers MyMath.dll | findstr machine检查DLL的机器类型8664是x64014C是x86。两者必须完全一致。我曾遇到一个案例C#项目属性里Platform Target设为x64但C项目在“配置管理器”里不小心选了Win32即x86dumpbin显示DLL是014C而corflags显示EXE是PE32结果就是永恒的DllNotFoundException。修复方法只有一个统一设为x64或x86。4.2 第二层验证DLL依赖项是否齐全一个孤立的DLL在C#里几乎不存在。用Dependencies新版Dependency Walker官网https://github.com/lucasg/Dependencies打开MyMath.dll它会以树状图展示所有直接和间接依赖。重点关注红色高亮的DLL——那些标着“NOT FOUND”的就是缺失的依赖项。最常见的缺失项是VCRUNTIME140.dll和MSVCP140.dll这是VS2017的C运行时库。解决方案有两个静态链接运行时在C项目属性 → “C/C → 代码生成 → 运行时库”将/MD动态链接改为/MT静态链接。这样所有运行时代码都会被打包进DLL体积变大但不再依赖外部DLL。部署运行时将vcruntime140.dll和msvcp140.dll从C:\Program Files (x86)\Microsoft Visual Studio\2017\Community\VC\Redist\MSVC\下找到对应版本复制到C#项目的输出目录。这是微软官方推荐的分发方式。另一个高频缺失项是api-ms-win-crt-*.dll这是Windows通用CRT。如果目标机器是Windows 7必须安装KB2999226补丁或者在C项目里将“配置属性 → 常规 → Windows SDK版本”降级到8.1它不依赖这些新CRT。4.3 第三层检查导出函数名与调用约定如果DLL和依赖都OK但Add函数还是报EntryPointNotFoundException问题一定出在导出名或调用约定上。再次运行dumpbin /exports MyMath.dll确认导出表里确实是Add而不是?AddYAHHHZ。如果不是回到C头文件检查extern C是否被注释掉了或者是否误写了extern C。如果导出名正确那就检查调用约定。在C#的DllImport里临时将CallingConvention从StdCall改成Cdecl重新编译运行。如果错误消失说明C端漏写了__stdcall。反之如果C端写了__stdcall而C#端用了Cdecl程序可能不崩溃但返回值会是随机垃圾数据因为栈没有被正确清理。4.4 第四层内存与字符串调试的终极手段当一切看似都对但ProcessString函数返回的字符串却是乱码或截断问题就出在字符串封送上。此时你需要一个“内存透视镜”。在C的ProcessString函数开头插入一段调试输出#include iostream #include fstream void __stdcall ProcessString(const char* input, char* output, int outputSize) { // 将输入字符串的ASCII码写入日志文件确认C#传入的内容 std::ofstream log(c:\\temp\\dll_log.txt, std::ios::app); log Input length: strlen(input) , First char ASCII: (int)input[0] \n; log.close(); // ... 其余实现 }同时在C#调用前用Encoding.Default.GetBytes(Hello)打印出字节数组确认C#发送的确实是ANSI编码。如果C#端是UTF-8而C端期望ANSI第一个字节就可能对不上。最后用Visual Studio的“调试 → 窗口 → 内存”功能在C#调用ProcessString前后分别查看sb对象的内存地址用unsafe代码获取确认C写入的地址与C#预期的地址完全一致。这是验证封送机制是否工作的黄金标准。提示在生产环境中绝对不要用std::ofstream写日志它会引入新的DLL依赖msvcp140.dll。应该用Windows API的OutputDebugStringA它只依赖kernel32.dll而kernel32.dll是Windows系统自带的永不缺失。5. 进阶实践让C DLL在C#中像原生类一样自然使用完成了基础调用下一步是提升开发体验。没有人愿意在C#里写几十个DllImport声明。我们可以用面向对象的方式把DLL封装成一个干净、易用的.NET类库让调用者感觉不到背后有原生代码。5.1 创建托管包装器Managed Wrapper新建一个C#类库项目命名为MyMathWrapper。在这个项目里我们不直接暴露DllImport而是创建一个MathProcessor类using System; using System.Runtime.InteropServices; using System.Text; public class MathProcessor : IDisposable { private bool _disposed false; // 私有静态类封装所有P/Invoke private static class Native { const string DllName MyMath.dll; [DllImport(DllName, CallingConvention CallingConvention.StdCall, CharSet CharSet.Ansi, ExactSpelling true)] public static extern int Add(int a, int b); [DllImport(DllName, CallingConvention CallingConvention.StdCall, CharSet CharSet.Ansi, ExactSpelling true)] public static extern int Subtract(int a, int b); [DllImport(DllName, CallingConvention CallingConvention.StdCall, CharSet CharSet.Ansi, ExactSpelling true)] public static extern double CalculatePi(int iterations); [DllImport(DllName, CallingConvention CallingConvention.StdCall, CharSet CharSet.Ansi, ExactSpelling true)] public static extern void ProcessString( [MarshalAs(UnmanagedType.LPStr)] string input, [MarshalAs(UnmanagedType.LPStr)] StringBuilder output, int outputSize); } // 公共方法提供.NET风格的API public int Add(int a, int b) Native.Add(a, b); public int Subtract(int a, int b) Native.Subtract(a, b); public double CalculatePi(int iterations) { if (iterations 0) throw new ArgumentException(Iterations must be positive.); return Native.CalculatePi(iterations); } public string ProcessString(string input) { if (string.IsNullOrEmpty(input)) return string.Empty; // 为输出分配足够空间这里用256作为安全上限 var sb new StringBuilder(256); Native.ProcessString(input, sb, sb.Capacity); return sb.ToString(); } // 实现IDisposable确保资源可释放虽然本例中无非托管资源 public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } protected virtual void Dispose(bool disposing) { if (!_disposed) { if (disposing) { // 释放托管资源 } // 释放非托管资源如果有 _disposed true; } } }这个包装器的价值在于统一错误处理CalculatePi方法在C#端做了参数校验抛出ArgumentException比让C端返回错误码更符合.NET习惯。隐藏封送细节调用者只需传string无需关心StringBuilder和容量。可扩展性强未来如果C DLL增加了Multiply函数只需在Native类里加一个DllImport再在MathProcessor里加一个public方法上层代码完全不用改。5.2 自动化部署让DLL随NuGet包一起分发对于团队协作或开源项目手动拷贝DLL是灾难。最佳实践是将C DLL和C#包装器打包成一个NuGet包。在MyMathWrapper项目根目录创建一个nuspec文件?xml version1.0? package metadata idMyMath.Native/id version1.0.0/version titleMyMath Native Library/title authorsYour Name/authors descriptionA C DLL for math operations, wrapped for .NET./description /metadata files !-- 将x64和x86的DLL分别放入对应目录 -- file src..\MyMath\x64\Release\MyMath.dll targetruntimes\win-x64\native\MyMath.dll / file src..\MyMath\Win32\Release\MyMath.dll targetruntimes\win-x86\native\MyMath.dll / !-- 包含C#包装器的dll -- file srcbin\Release\MyMathWrapper.dll targetlib\net472\ / /files /package然后用nuget pack MyMath.Native.nuspec生成.nupkg文件。发布到私有NuGet服务器后其他开发者只需在C#项目中执行Install-Package MyMath.NativeNuGet会自动根据项目的目标平台x64/x86选择对应的MyMath.dll并将其复制到输出目录整个过程全自动、零配置。5.3 性能优化减少P/Invoke调用开销的三种策略P/Invoke调用比纯托管调用慢10-100倍因为每次调用都要跨越托管/非托管边界进行栈转换、参数封送、异常转换。对于高频调用如每帧调用的图像处理函数必须优化。策略一批量处理Batch Processing不要为每个像素调用一次Add而是设计一个ProcessArray函数一次性传入整块内存// C端新增 extern C { MYMATH_API void __stdcall ProcessArray(const int* input, int* output, int length); }// C#端调用 [DllImport(MyMath.dll)] public static extern void ProcessArray( [In] IntPtr input, // 用IntPtr指向托管数组首地址 [Out] IntPtr output, int length); // 调用时 int[] inputArray new int[1000]; int[] outputArray new int[1000]; GCHandle inputHandle GCHandle.Alloc(inputArray, GCHandleType.Pinned); GCHandle outputHandle GCHandle.Alloc(outputArray, GCHandleType.Pinned); try { ProcessArray(inputHandle.AddrOfPinnedObject(), outputHandle.AddrOfPinnedObject(), inputArray.Length); } finally { inputHandle.Free(); outputHandle.Free(); }策略二内存池复用Memory Pooling对于频繁创建/销毁的StringBuilder用ArrayPoolchar管理private static readonly ArrayPoolchar _stringBuilderPool ArrayPoolchar.Create(256, 100); public string ProcessString(string input) { char[] buffer _stringBuilderPool.Rent(256); try { var sb new StringBuilder(buffer, 0, buffer.Length); Native.ProcessString(input, sb, sb.Capacity); return sb.ToString(); } finally { _stringBuilderPool.Return(buffer); } }策略三JIT内联提示JIT Inlining Hint在C#包装器的简单方法上添加[MethodImpl(MethodImplOptions.AggressiveInlining)]提示JIT编译器将Add这样的简单函数内联消除调用开销[MethodImpl(MethodImplOptions.AggressiveInlining)] public int Add(int a, int b) Native.Add(a, b);这三种策略足以将P/Invoke的性能损耗降到可忽略水平。记住优化的前提是先用dotTrace或PerfView确认P/Invoke确实是性能瓶颈而不是盲目优化。我在某图像处理Demo中应用了这三种策略将每秒处理帧率从12fps提升到了45fps效果立