资讯详情 C#调用C++ DLL:P/Invoke封送、调用约定与异常排查
📅 2026/11/2 10:28:04
手上有个 C# 写的上位机算法部分却只能用 C——这种活我接过不少。要么是硬件厂商只给了 C 的 SDK要么是性能敏感的图像处理、信号解算必须走原生代码要么是十几年前的老库里全是 C 实现重写成本高得离谱。这时候绕不开一件事把 C 编译成 DLL再让 C# 去调。听起来就是DllImport加个extern的事真上手你会发现坑是一层叠一层的函数名找不到、进函数就崩、传个结构体数据全乱、调试器根本不进 C 代码。这些问题不是靠搜索能一次解决的得先把整条调用链的原理搞清楚。这篇内容适合刚接触 P/Invoke 的 C# 开发者也适合那些写了多年 C 却总被托管侧同事追问你这个 DLL 怎么调不了的老手里面每一段都是实际项目里试出来的结论。1. 先想清楚这条调用链到底长什么样1.1 从托管到非托管中间隔了什么很多人在写[DllImport]的时候脑子里默认它是直接调用其实中间隔了一整套机制。CLR 在第一次碰到这个声明时会去加载目标 DLL在导出表里找符号然后按照声明的调用约定和参数类型一块一块地把托管内存里的数据翻译成非托管格式压到栈上或者塞进寄存器跳过去执行返回时再把结果翻译回来。这个翻译过程就是封送Marshaling它是有成本的也是有规则的。理解这一点很重要因为你后面遇到的绝大多数问题本质上都是我以为它会自动转结果它转的方式和我想的不一样。比如bool在 C# 里是 1 字节但 Windows 原生代码里习惯用的BOOL是 4 字节封送器默认按 4 字节处理你的 C 函数签名写的是 1 字节的bool两边就对不上了。再比如字符串C# 的string是 UTF-16C 的char*可能是 ANSI 也可能是 UTF-8封送器默认按 ANSI 处理中文就变成乱码。我一般会把这条链拆成四个环节来检查加载DLL 能不能找到、位数对不对、查符号导出名是不是完全匹配、封送参数类型和内存布局是否一致、执行调用约定是否匹配、异常是否被拦住。四个环节任何一环出问题表现都不一样学会区分就能少走很多弯路。1.2 三种导出形态的取舍C 侧想把功能暴露出去理论上不止一种做法我把自己用过的三种摆出来对比一下。方案做法优点缺点适用场景C 风格函数导出extern C__declspec(dllexport)简单、跨语言、跨平台通用、调试方便只能导函数不能直接导类绝大多数场景的首选导出 C 类直接 dllexport 类看起来面向对象C# 无法直接消费虚表布局和名字修饰完全不通基本不现实C/CLI 桥接写混合程序集包装原生代码可以写托管类用起来最舒服只能 Windows需要/clr开关项目配置复杂团队里有 C/CLI 经验时可选COM 组件实现 IDL 和 COM 接口跨语言能力强有成熟基础设施注册表依赖、部署麻烦、调试痛苦老系统集成结论很直接新项目一律走 C 风格函数导出。原因不是它先进而是它足够笨。笨的东西没有隐藏行为出问题容易定位。导出 C 类这条路很多人试过最后都卡在我能导出但 C# 那边接不住上。C 的类在二进制层面包含虚函数表指针、名字修饰、构造函数调用约定等一堆东西DllImport只认函数符号它不知道什么叫对象。至于 C/CLI它确实好用写个ref class就能被 C# 直接引用但它的限制也很明确只能在 Windows 上跑必须在项目属性里打开/clr编译出来的混合程序集在某些加载场景下会有额外麻烦。如果你手上是 .NET Framework 的老项目C/CLI 是个不错的选择如果是 .NET 6 以上的新项目虽然 Windows 上仍然支持但我更倾向于老老实实写 C 接口因为它的心智负担最小。1.3 调用约定与名字修饰九成的报错源头在这里这两个概念必须放在一起讲因为它们共同决定了你的函数在 DLL 里到底叫什么名字。名字修饰分两层。第一层是 C 编译器为了支持函数重载会把参数类型编进符号名里比如int Add(int,int)可能被修饰成?AddYAHHHZ这种鬼东西。extern C的作用就是关掉这一层修饰让符号名保持为Add。第二层修饰跟调用约定有关而且在 32 位和 64 位下表现完全不同32 位x86__cdecl会加下划线前缀变成_Add__stdcall会变成_Add8后面是参数占用的字节数。注意extern C管不了这一层。64 位x64微软的 x64 ABI 只保留一种调用约定__cdecl和__stdcall写上去编译器都接受但实际行为一样而且符号名不加任何前后缀就是干干净净的Add。这就是为什么同一份代码32 位编译能跑64 位编译就报EntryPointNotFoundException——反过来了通常是 32 位下名字对不上。C# 这边的默认值也容易踩坑。[DllImport]在不写CallingConvention时默认是Winapi在 Windows 平台上等价于StdCall。所以如果你的 C 函数是__cdecl这是 C/C 的默认值而 C# 声明里没写两边就错配了。在 x86 下调用约定不匹配的后果是栈不平衡调用方和被调用方对谁来清理栈的认知不同函数返回后栈指针偏移轻则后面几个变量的值莫名其妙变了重则直接访问违例。提示如果你不想在这上面反复纠结最省事的做法是 C 侧统一写__stdcallC# 侧统一写CallingConvention.StdCall两边对齐然后 32/64 位都能跑。唯一要注意的是 x86 下导出名会带N后缀用.def文件可以绕过去后面会讲。2. 接口设计把坑提前埋掉2.1 导出函数一律走 C 风格我给自己定了一条铁律凡是给托管代码调的导出函数一律写在extern C块里。这不是风格问题是能不能用的问题。// image_proc.h #pragma once #ifdef IMAGEPROC_EXPORTS #define IMAGEPROC_API __declspec(dllexport) #else #define IMAGEPROC_API __declspec(dllimport) #endif #define IMAGEPROC_CALL __stdcall extern C { IMAGEPROC_API int IMAGEPROC_CALL ProcInit(void); IMAGEPROC_API int IMAGEPROC_CALL ProcProcess( const unsigned char* src, int width, int height, unsigned char* dst, int* outLen); IMAGEPROC_API int IMAGEPROC_CALL ProcGetLastError(char* buf, int bufSize); }这段头文件里有几个细节值得说。IMAGEPROC_API这个宏是双面的编译 DLL 本身时定义IMAGEPROC_EXPORTS宏展开成dllexportC# 那边当然不用这个头文件但如果将来有别的 C 模块要静态链接进来dllimport分支就有用了。这是一个很常见的工程习惯不算多余。所有函数返回int作为错误码而不是用返回值传数据。原因是跨语言边界传递复杂返回值是最容易出问题的地方尤其是字符串和对象。用错误码 输出参数的模式C# 侧拿到的是明确的成功/失败信号数据通过调用方提供的缓冲区回填内存归属清晰出问题也容易定位。还有一个细节ProcGetLastError让调用方传缓冲区进来而不是返回内部静态指针。有人喜欢写const char* GetVersion()直接返回常量字符串这个是可以的因为常量字符串的生命周期是永久的C# 用string接、CharSet.Ansi封送它会拷贝一份没问题。但如果是动态拼接的错误信息返回内部static缓冲区就有线程安全问题了而且下次调用会覆盖上一次的内容用起来很别扭。2.2 用 .def 文件接管导出名单__declspec(dllexport)够用但它有个副作用导出名受调用约定影响。在 x86 下__stdcall的函数导出名会变成_ProcInit0C# 那边如果不写EntryPoint就找不到。解决办法有两个我推荐第二个。第一个办法是在 C# 的属性里显式指定[DllImport(imageproc, EntryPoint _ProcInit0, CallingConvention CallingConvention.StdCall)] public static extern int ProcInit();能用但很难看而且换个编译配置比如换成 x64后缀就没了代码得跟着改。第二个办法是在工程里加一个.def文件显式列出导出名LIBRARY ImageProc EXPORTS ProcInit ProcProcess ProcGetLastError然后在项目属性里把模块定义文件指向它。这样一来不管 32 位还是 64 位、不管什么调用约定导出名永远是你写的那个名字干干净净。这个做法还有一个附带好处你可以精确控制哪些符号被导出。大型 C 项目里dllexport到处撒容易把内部符号也暴露出去.def文件相当于一份白名单谁该出去一目了然。.def文件里的序号写法比如ProcInit 1可以进一步固定导出序号但这玩意一旦发布就不能改顺序否则老版本调用方全部失效。我一般不用序号只用名字可读性更好也不怕后期加函数。2.3 类型映射表与结构体对齐这是最容易出错、也最需要背下来的一块。C 和 C# 的类型系统不是一一对应的封送器有一套自己的默认规则你必须知道它默认怎么转才能判断什么时候需要手工干预。C 类型C# 声明说明intint都是 32 位有符号直接对应unsigned intuint注意别写成int负数会出问题long long/__int64longC# 的long是 64 位C 的long在 Windows 上也是 32 位别混float/doublefloat/double直接对应bool[MarshalAs(UnmanagedType.I1)] bool必须加特性否则按 4 字节封送char*stringCharSet.Ansi或IntPtr返回动态内存时只能用IntPtrwchar_t*stringCharSet.UnicodeWindows 下wchar_t是 2 字节 UTF-16void*IntPtr句柄、上下文指针都走这个struct Foo*ref Foo/out Foo/IntPtr按需选择函数指针委托 UnmanagedFunctionPointer见 3.4 节unsigned char*byte[]数组会被 pin 住注意生命周期关于bool我要多啰嗦两句因为这是新手翻车率最高的地方。C 的bool标准上是 1 字节Windows API 里的BOOL是int的别名、4 字节。C# 的bool在托管内存里是 1 字节但封送器默认把它按 4 字节的BOOL处理。如果你的 C 函数签名是bool Check(...)参数或返回值就会被错位读取。解决办法只有两个加[MarshalAs(UnmanagedType.I1)]明确告诉封送器这是 1 字节或者干脆在 C 侧把接口里的bool全换成int。我个人倾向后者接口层面少一点类型歧义跨语言的兼容性更好。结构体对齐是另一个隐形杀手。C 默认按成员中最大对齐值来排布C# 的LayoutKind.Sequential默认也是自然对齐多数情况下能对上。但如果你的 C 结构体前面有#pragma pack(1)或者#pragma pack(push, 4)那就必须告诉 C#[StructLayout(LayoutKind.Sequential, Pack 1)] public struct FrameInfo { public int Width; public int Height; public byte Channels; public long Timestamp; }Pack 1对应#pragma pack(1)。这个参数不写C# 会按自然对齐补空洞字段偏移全错。判断方法很简单在 C 侧用offsetof把每个成员的偏移打出来在 C# 侧用Marshal.OffsetOf对一遍对不上就是对齐问题。这个自检我建议在联调第一天就做比事后猜字段错位高效得多。2.4 字符串、数组、回调的传递约定字符串走三条路各有各的适用场景。第一条是调用方传缓冲区DLL 往里填。头文件里ProcGetLastError(char* buf, int bufSize)就是这种。C# 侧可以用StringBuilder接var sb new StringBuilder(256); NativeMethods.ProcGetLastError(sb, sb.Capacity); string err sb.ToString();注意StringBuilder必须指定容量默认容量是 16DLL 往里写超过 16 个字符就越界了。而且 DLL 里必须严格遵守bufSize写完要补\0否则ToString()会一直读到野内存。第二条是 DLL 返回常量字符串C# 用string接CharSet.Ansi或者CharSet.Unicode按实际编码选。这条路简单但只能用于生命周期是永久的字符串。第三条是 DLL 分配内存返回指针C# 用IntPtr接转完字符串后必须调用 DLL 提供的释放函数。绝对不能用Marshal.FreeHGlobal去释放 Cnew出来的内存两边用的堆不一样这么干必然崩溃。这条规则我见过太多人违反后面还会专门讲。数组的处理相对直接传指针 长度就行。C# 侧用byte[]P/Invoke 会自动把数组 pin 住固定住不让 GC 移动调用期间指针有效。但要注意这个指针在函数返回后就失效了C 侧不能把它保存到全局变量里下次再用。如果确实需要长期持有得在 C 侧拷贝一份。回调是最需要小心的。C# 传委托给 C封送器会生成一个函数指针但这个委托对象本身在托管堆上如果没有强引用GC 随时可能把它回收掉。一旦被回收C 那边再回调就是一针野指针。解决办法是把委托保存在一个静态字段里或者用GCHandle.Alloc固定住。这个细节比想象中重要因为这种崩溃是不定时的测试环境跑一天没事现场跑两周崩一次排查成本极高。2.5 内存归属谁申请谁释放这是整篇文章里我最想强调的一条原则内存在哪边申请的就由哪边释放绝不交叉。C 的new/malloc和 C# 的Marshal.AllocHGlobal用的是完全不同的分配器底层堆不一样。你在这边申请的内存拿到那边去释放等于拿着 A 家银行的存折去 B 家银行取钱行为未定义。表现出来就是各种诡异的堆损坏异常而且位置和时机都随机。工程上的做法是句柄模式DLL 内部维护对象对外只暴露一个不透明指针。extern C { IMAGEPROC_API void* IMAGEPROC_CALL ProcCreateContext(int mode); IMAGEPROC_API int IMAGEPROC_CALL ProcRun(void* ctx, const unsigned char* data, int len); IMAGEPROC_API void IMAGEPROC_CALL ProcDestroyContext(void* ctx); }C# 侧用IntPtr接这个句柄用完调ProcDestroyContext销毁。ProcCreateContext和ProcDestroyContext必须成对提供一个都不能少。我在代码评审时看到只有 Create 没有 Destroy 的接口基本可以直接打回去重写。这套模式的好处是C 侧可以自由地用 C 的对象、容器、智能指针只要把裸指针转换成void*交出去就行内部实现完全不受约束。C# 侧不需要知道里面是什么只当它是个不透明的编号。职责边界清晰谁也不用操心对方的内存管理。3. 动手实现从零到跑通第一条调用3.1 C 侧的工程配置与导出实现假设我们做一个模拟项目功能是图像灰度化加边缘检测对外提供初始化和处理两个接口。新建一个动态链接库(DLL)工程把上面的头文件加进去实现文件大概是这样// image_proc.cpp #include image_proc.h #include cstring #include vector #include string namespace { thread_local std::string g_lastError; void SetError(const char* msg) { g_lastError msg ? msg : ; } } extern C { int IMAGEPROC_CALL ProcInit(void) { try { // 这里做初始化预分配缓冲区、加载查找表等 SetError(); return 0; } catch (...) { SetError(init failed with unknown exception); return -1; } } int IMAGEPROC_CALL ProcProcess(const unsigned char* src, int width, int height, unsigned char* dst, int* outLen) { if (!src || !dst || !outLen || width 0 || height 0) { SetError(invalid argument); return -2; } try { const int pixelCount width * height; for (int i 0; i pixelCount; i) { // 简化的灰度化假设输入是 3 通道 const int off i * 3; dst[i] static_castunsigned char( 0.299 * src[off] 0.587 * src[off 1] 0.114 * src[off 2]); } *outLen pixelCount; SetError(); return 0; } catch (const std::exception e) { SetError(e.what()); return -3; } catch (...) { SetError(process failed with unknown exception); return -4; } } int IMAGEPROC_CALL ProcGetLastError(char* buf, int bufSize) { if (!buf || bufSize 0) return -2; const size_t len g_lastError.size(); const size_t copyLen (len static_castsize_t(bufSize - 1)) ? len : bufSize - 1; std::memcpy(buf, g_lastError.c_str(), copyLen); buf[copyLen] \0; return 0; } }这段代码有几个设计决定需要解释。每个导出函数都套 try/catch。这不是防御性编程的洁癖是硬要求。C 异常一旦穿过 DLL 边界进入 CLR行为是未定义的实际表现就是进程直接终止C# 侧的try/catch根本接不住。所以异常必须在 DLL 边界内消化掉转换成错误码返回。catch 的顺序也有讲究先捕具体的std::exception拿到what()再捕...兜底因为不是所有异常都继承自std::exception。错误信息用thread_local。用全局std::string在多线程场景下会被并发写坏。thread_local保证每个线程有自己的错误槽配合先调接口失败了再取错误信息的使用模式刚好。注意这是 C11 起的特性老编译器要用__declspec(thread)。参数校验放在 try 外面。空指针、非法尺寸这类问题不需要异常机制直接返回错误码更快也避免在 catch 里再出问题。工程配置上有两个地方要检查一是配置类型必须是 DLL二是运行库选项。如果你希望这个 DLL 拿到任何机器上都能跑、不需要用户装 VC 运行库就把运行库改成多线程(/MT)把运行库静态链接进去代价是 DLL 体积大几百 KB。如果目标环境已经统一部署了运行库用多线程 DLL(/MD)更省空间。这个选择在交付前一定要定下来临时改会很麻烦。3.2 C# 侧的 P/Invoke 声明C# 侧把原生方法集中放在一个internal static class里这是社区里比较通行的组织方式好处是调用点看到NativeMethods.ProcProcess(...)就知道这是原生调用不会和普通方法混淆。using System; using System.Runtime.InteropServices; using System.Text; internal static class NativeMethods { private const string DllName imageproc; [DllImport(DllName, CallingConvention CallingConvention.StdCall)] public static extern int ProcInit(); [DllImport(DllName, CallingConvention CallingConvention.StdCall)] public static extern int ProcProcess( byte[] src, int width, int height, byte[] dst, out int outLen); [DllImport(DllName, CallingConvention CallingConvention.StdCall, CharSet CharSet.Ansi)] public static extern int ProcGetLastError(StringBuilder buf, int bufSize); }DllName这里写的是imageproc不带.dll后缀。Windows 上两种写法都能用但省略后缀的写法在跨平台项目里更通用Linux 上是libimageproc.so封送器会自动补前后缀。如果你的项目只在 Windows 上跑写全imageproc.dll也没问题。CallingConvention.StdCall必须和 C 侧的__stdcall对上。这是硬性配对不能靠猜。调用侧大概长这样public static byte[] ToGray(byte[] rgb, int width, int height) { if (NativeMethods.ProcInit() ! 0) throw new InvalidOperationException(native init failed); var gray new byte[width * height]; int written; int ret NativeMethods.ProcProcess(rgb, width, height, gray, out written); if (ret ! 0) { var sb new StringBuilder(512); NativeMethods.ProcGetLastError(sb, sb.Capacity); throw new InvalidOperationException($process failed: {ret}, {sb}); } if (written ! gray.Length) Array.Resize(ref gray, written); return gray; }这里gray数组必须先按最大可能长度分配好再传进去因为 C 侧不能替你分配内存。这是调用方提供缓冲区模式的必然要求。如果你不确定输出多大就设计成两步先调一个ProcGetOutputSize拿长度再分配缓冲区再调处理函数。多一次调用换来确定性值得。3.3 结构体与数组的封送实战真实项目里很少只传基本类型结构体几乎是必用的。假设我们要把处理参数打包成一个结构体传进去#pragma pack(push, 4) struct ProcOptions { int mode; // 处理模式 int threshold; // 阈值 float scale; // 缩放系数 bool keepAlpha; // 是否保留透明通道 }; #pragma pack(pop) IMAGEPROC_API int IMAGEPROC_CALL ProcRunWithOptions( void* ctx, const ProcOptions* opt, unsigned char* out, int* outLen);对应 C# 侧[StructLayout(LayoutKind.Sequential, Pack 4)] public struct ProcOptions { public int Mode; public int Threshold; public float Scale; [MarshalAs(UnmanagedType.I1)] public bool KeepAlpha; } [DllImport(imageproc, CallingConvention CallingConvention.StdCall)] public static extern int ProcRunWithOptions( IntPtr ctx, ref ProcOptions opt, byte[] output, out int outLen);三个要点。第一Pack 4对应#pragma pack(push, 4)不写就会按自然对齐补空洞字段位置全乱。第二bool必须加I1特性原因前面说过。第三ref ProcOptions表示传指针如果 C 侧是const ProcOptions*用in ProcOptions语义更贴切C# 7.2 起支持能避免误改。如果结构体里含指针或者字符串麻烦程度会翻倍。比如char name[64]这种定长字符数组C# 侧要用[MarshalAs(UnmanagedType.ByValTStr, SizeConst 64)] public string Name;。如果是char*指针C# 里就得用IntPtr然后手工Marshal.PtrToStringAnsi转换并且要判断这个指针指向的内存由谁负责释放。这类结构体我一般能避就避接口设计时尽量拆成传字符串 传结构体两个参数比塞在结构体里干净得多。数组的封送有个性能细节值得提一句。byte[]作为参数时封送器默认会把数组 pin 住blittable 类型直接传指针没有拷贝开销。但如果你传的是string[]这种非 blittable 类型封送器会逐元素转换开销大得多而且默认只能传输入不能回填。所以数组参数尽量用byte[]、int[]、float[]这类可以直接传指针的类型。3.4 回调函数的上手与引用保持进度回调是实际项目里最常见的需求因为图像处理动辄几秒界面不能卡死。C 侧先定义函数指针类型typedef void (IMAGEPROC_CALL *ProgressCallback)(int percent, const char* message); IMAGEPROC_API int IMAGEPROC_CALL ProcRunAsync(void* ctx, ProgressCallback cb);C# 侧对应[UnmanagedFunctionPointer(CallingConvention.StdCall, CharSet CharSet.Ansi)] public delegate void ProgressCallback(int percent, string message); [DllImport(imageproc, CallingConvention CallingConvention.StdCall)] public static extern int ProcRunAsync(IntPtr ctx, ProgressCallback cb);UnmanagedFunctionPointer这个特性是必须的它告诉封送器这个委托要被转成原生函数指针调用约定是什么。不写的话默认值不一定和 C 侧一致32 位下会出栈问题。真正的坑在于引用保持。下面这段代码看起来没问题实际上是定时炸弹// 错误示范 public void Run() { NativeMethods.ProcRunAsync(_ctx, (p, m) UpdateProgress(p, m)); }匿名委托没有强引用方法返回后 GC 随时可能把它回收。C 那边还在跑某次回调时就跳到已经被回收的内存上进程直接消失什么日志都没有。正确做法是把委托存为字段private ProgressCallback _progressCallback; public void Run() { _progressCallback ?? new ProgressCallback(OnProgress); NativeMethods.ProcRunAsync(_ctx, _progressCallback); } private void OnProgress(int percent, string message) { try { UpdateProgress(percent, message); } catch (Exception ex) { Log.Error(ex, progress callback failed); } }??保证委托只创建一次字段持有强引用GC 不会回收它。回调方法内部必须整体 try/catch因为从托管回调抛出的异常如果穿过原生栈帧同样会导致进程终止而且这种终止通常连 Windows 错误报告都不弹。还有一个场景要注意如果 C 侧是多线程触发回调C# 的UpdateProgress里更新 UI 必须切回主线程用Dispatcher.BeginInvoke或者Control.BeginInvoke。直接在回调线程里改控件属性会抛跨线程访问异常而且这种异常同样会穿过原生边界后果一样严重。3.5 编译、部署与第一次联调第一次联调建议用一个最小的控制台程序不要一上来就集成到业务项目里。控制台的干扰因素少出问题容易定位。C 工程先编译配置选Release | x64。编译完到输出目录确认.dll和.lib都在.lib是静态链接用的纯DllImport场景用不上但说明导出成功了。用dumpbin /exports imageproc.dll看一眼导出表确认函数名干干净净没有后缀没有?开头的修饰名。这一步别省很多问题在这一步就能发现。然后在 C# 控制台项目里把平台目标也设成 x64把 DLL 拷到输出目录注意是和.exe同目录不是随便哪个目录。在Program.cs写一个最简单的调用加try/catch包住跑起来。如果第一次就报DllNotFoundException八成是位数不匹配或者 DLL 根本没拷过去。如果是EntryPointNotFoundException回去用dumpbin对照导出名。如果进去了但崩了那就是封送或者内存的问题用 4.1 节的排查方法定位。调试配置很简单但很多人不知道C# 项目属性 → 调试 → 勾选启用非托管代码调试。勾上之后C 侧的断点就能命中了可以像调试普通代码一样单步进入原生函数。这个开关是我带新人时必讲的一条因为不开的话你只能对着崩溃地址看汇编码效率差十倍。4. 报错现场常见异常与排查实录4.1 加载阶段的三类异常DllNotFoundException: Unable to load DLL xxx。这个异常的含义是 CLR 连文件都没找到或者找到了但加载失败依赖缺失也会报这个。排查顺序先确认文件在不在预期目录Path.GetDirectoryName(typeof(Program).Assembly.Location)打一下实际路径再确认位数C# 编译成 x64 就要用 x64 编译的 DLL混用必然失败最后查依赖用Dependency Walker或者Process Monitor看加载时缺了哪个 Dll。最常见的依赖缺失是 VC 运行库vcruntime140.dll、msvcp140.dll解决办法是在 C 项目里改成静态链接/MT或者把这几个运行时文件一起拷过去。BadImageFormatException: An attempt was made to load a program with an incorrect format。这个名字有点误导它跟镜像格式其实关系不大绝大多数情况就是位数不匹配。x64 进程去加载 x86 的 DLL或者反过来。注意一个坑如果你在 64 位系统上跑一个 AnyCPU 的 C# 程序它默认是以 64 位运行的这时候你拿一个 32 位编译的 DLL 就会报这个错。解决办法是两边统一或者在 C# 项目里显式锁定平台。EntryPointNotFoundException: Unable to find an entry point named xxx。文件加载成功了但导出表里找不到这个名字。原因就那么几个C 侧忘了写extern C导出名被 C 修饰成了?ProcInitYAHXZ之类32 位下__stdcall给名字加了_ProcInit0前后缀大小写不一致Windows 的导出表是大小写敏感的虽然加载器本身不敏感或者你调的是 C 类成员函数那玩意根本不会以普通函数形式导出。判断方法很直接dumpbin /exports把导出表打出来一个字一个字对照。我习惯在联调前先把导出表贴进项目文档谁改坏了马上就能发现。4.2 调用阶段的崩溃与返回值异常函数进去了但结果不对这类问题比加载失败更难查因为没有任何异常提示。栈损坏。典型表现是函数返回后局部变量莫名其妙变了或者调用下一条语句时崩在不相干的地方。原因基本只有一个调用约定不匹配。x86 下__cdecl和__stdcall对谁清栈的约定不同错配就是 ESP 偏移。查法是确认 C 头文件里的宏定义和 C# 声明里的CallingConvention严格对应一个字都不能差。x64 下这个问题基本不存在因为只有一个约定。数据错位。传进去的结构体C 侧读出来字段全是乱的值。这是对齐或者类型不匹配。用offsetof和Marshal.OffsetOf对偏移是定位这类问题最快的手段。另外要检查bool是不是漏了I1特性long有没有搞混C 的long是 32 位C# 的long是 64 位这个错配特别隐蔽因为在 x64 下 C 的long依然是 32 位但 C# 的long是 64 位两边一传后 4 个字节就是垃圾数据。内存泄漏。跑一段时间内存持续上涨多半是 Create 和 Destroy 没配对或者某个返回字符串的接口忘了释放。定位方法是给每个 Create/Delete 加计数日志跑一天看数字是否平衡。我一般会在 DLL 里加一个ProcGetLiveObjectCount调试接口联调期打开上线前关掉成本很低但很有用。进程直接消失没有异常。这是最难受的一种。几种典型成因C 异常穿过了 DLL 边界托管回调里抛异常穿过了原生栈帧访问了已经被释放的内存或已被 GC 回收的委托。这三种情况都不会走正常的异常处理流程直接终止进程。排查手段有限只能靠代码审查 日志。我的做法是在每个导出函数入口和出口各打一条日志用文件或者OutputDebugString进程消失时看最后一条日志停在哪能缩小到一个函数。这个方法笨但有效。4.3 问题速查表现象可能原因快速验证处理方式DllNotFoundException文件不在目录 / 位数不对 / 依赖缺失检查输出目录文件、用 Process Monitor 看加载补文件、统一位数、静态链接运行库BadImageFormatExceptionx86/x64 混用看 DLL 和进程的位数两边统一平台目标EntryPointNotFoundException名字修饰 / 大小写 / 未导出dumpbin /exports对照用.def文件固定导出名返回后局部变量异常调用约定不匹配检查CallingConvention与宏定义两边统一为StdCall结构体字段乱码对齐 /Pack不一致offsetof与Marshal.OffsetOf对比补Pack参数、补I1特性内存持续增长Create/Destroy 未配对加对象计数日志用using或try/finally保证释放进程无提示退出异常穿过边界 / 委托被回收边界日志 检查委托引用所有导出函数加 try/catch委托存字段中文变乱码编码不匹配传英文测试统一 UTF-8 或改用wchar_t*5. 工程化细节与个人踩坑体会5.1 版本、路径与部署DLL 的加载路径问题在实际部署时特别容易出。默认情况下Windows 会按一套固定的顺序搜索当前工作目录排得很靠前。这意味着如果用户从别的目录启动你的程序工作目录可能不是 exe 所在目录DLL 就找不到了。稳妥的做法有两种一是保证 DLL 和 exe 同目录并且在代码里用绝对路径加载二是启动时调用SetDllDirectory把 DLL 目录加进搜索路径。绝对路径加载的写法是[DllImport(C:\full\path\imageproc.dll)]但这样写死了路径部署时很别扭。我一般用一个静态构造函数加上NativeLibrary.SetDllImportResolver.NET Core 3.0 起支持在运行时决定加载哪个路径兼顾了灵活性和可控性。版本管理是另一个容易被忽略的点。DLL 一旦发出去接口就不能随便改了。新增函数没问题但改函数签名、改结构体字段顺序、改调用约定都会让老版本调用方直接崩。我的做法是接口里带一个版本号初始化时校验ProcInit(int expectVersion)版本不匹配就返回明确的错误码而不是让它在后面某个奇怪的地方崩掉。这个成本很低但能省掉大量现场排查。命名上也有个小技巧DLL 文件名带上位数或者架构标识比如imageproc_x64.dll和imageproc_x86.dll放在同一个目录下C# 侧根据IntPtr.Size动态选择。这样同一份安装包可以同时支持 32 位和 64 位客户端不用出两个版本。5.2 异常隔离与日志我在每个项目里都会强调一件事DLL 边界必须是一道防火墙。所有异常在边界内消化出去只有错误码。C 侧的实现方式是每个导出函数都套 try/catch这一点前面已经写过。C# 侧也一样所有回调方法都要 try/catch不能让异常往回穿。有人觉得这是过度防御但你要知道穿过原生边界的异常不会给你堆栈信息不会弹错误对话框就是进程消失。在客户现场出现这种情况你连从哪查都不知道。错误码的设计也值得花点心思。简单的 0 成功、非 0 失败够用但如果错误种类多最好定义一套枚举并且让 DLL 提供的ProcGetLastError返回人类可读的描述。这样 C# 侧打日志的时候能直接带上原因比只看到一个-3强太多。日志方面C 侧不要用printf或者coutDLL 没有控制台输出会丢失。用OutputDebugString配合 DebugView 工具看或者写文件。生产环境建议把日志级别做成可配置的默认只记错误调试时打开详细日志。日志文件路径最好由调用方传进来而不是 DLL 自己决定写哪因为 DLL 对文件系统的权限不一定和主程序一致。5.3 我踩过的几个坑说几个具体的都是花了时间才搞明白的。第一个是char*返回值。早年写了个GetVersion()返回内部静态字符串C# 侧用string接一直好好的。后来有人改成多线程调用偶尔出现版本号变成半截或者乱码。原因是静态缓冲区被并发写坏了。后来改成调用方传缓冲区问题消失。这个教训让我后来所有字符串接口都走传 buffer size的模式虽然调用方麻烦一点但确定性高。第二个是委托被回收。一个进度回调的项目测试环境跑了三天没事现场跑了两周崩了一次。日志里什么都没有就是进程没了。查了很久才想到是匿名委托没有强引用。改成字段持有之后彻底解决。这个坑我现在写在团队规范第一条任何传给原生的委托必须有字段或者 GCHandle 持有引用。第三个是bool封送。一个配置结构体里有个bool enabledC# 侧写得好好的传到 C 侧读出来永远是true。原因就是 4 字节封送把后面的字段也吃进去了读到的值其实是相邻字段的低字节。加上[MarshalAs(UnmanagedType.I1)]就好了。这个错很难从现象反推因为字段值看起来有值只是值不对。第四个是静态链接运行库。有个项目交付到客户机器上DllNotFoundException查了半天发现客户机器没装 VC 运行库。后来把 C 项目改成/MT重新编译问题解决代价是 DLL 从 200KB 涨到 1.2MB。我觉得这个代价值得尤其是交付给非技术用户的产品。最后一个跟调试有关。有次 C 侧断点死活不命中查了半天发现是 C# 项目没勾启用非托管代码调试。这个开关在一个很深的属性页里不熟悉的话根本想不到。现在我的做法是在项目模板里就把它打开省得每次都忘。这套东西跑通之后其实后面就是重复劳动了定义接口、写.def、写DllImport、加 try/catch、联调验证。真正需要动脑的地方在前面的设计阶段——想清楚哪些数据要跨边界、内存归谁管、错误怎么传。这几个问题定下来剩下的都是照着模板填。