从 C# 调用原生 DLL:C++/CLI 包装器 vs P/Invoke
· 小村 豪 · C++/CLI, C#, Windows 开发, 原生集成
想从 C# 中使用 Windows 的现有资产或既有 DLL,这样的需求相当常见。 如果对方是像 Win32 API 那样直接的 C 接口,P/Invoke 就足够了。
不过,在实务中出现的往往是更「有个性」的 DLL。
有 C++ 类,有一套所有权的规矩,也会抛异常,std::wstring 和 std::vector 也是常客。
如果在这里只靠 P/Invoke 硬撑,边界面通常会渐渐变得吃力。
本文将说明在这种时候,用 C++/CLI 插入一层薄包装 会让哪些事情变轻松。 这并不是说 P/Invoke 不好,而是想表达 P/Invoke 已经够用的场景,和 C++/CLI 能发挥作用的场景是不同的。
另外,本文中出现的代码片段,也以一整套可构建的示例(原生 C++ 库、C API 桥接层、C++/CLI 包装器,以及 P/Invoke 版和 C++/CLI 版的 C# 消费代码)的形式发布在 GitHub 上。
cpp-cli-wrapper-for-native-dlls - komurasoft-blog-samples (GitHub)
目录
- 先说结论(一句话)
- P/Invoke 已经够用的情况
- P/Invoke 突然变得吃力的边界
- 插入 C++/CLI 包装器的架构
- C++/CLI 能让哪些事情变轻松
- 代码片段
- 即便如此,也不适合选择 C++/CLI 的情况
- 总结
- 参考资料
1. 先说结论(一句话)
- 如果对方是一组 C 函数,P/Invoke 更直接
- 如果对方是 C++ 库,插入一层 C++/CLI 包装器会更便于维护
- 尤其是涉及 类、所有权、字符串、数组、异常、回调 时,不要让 C# 一侧硬撑
说到底,就是 不要把原生 DLL 的种种限制直接带入 C#。 原生侧的种种限制由 C++ 一侧承接,只把面向 .NET 展示的部分整理好。 这种分工一旦顺利运作,代码和调试都会变得相当从容。
2. P/Invoke 已经够用的情况
如果用 P/Invoke 就能解决,那是最简单的方式。没必要勉强引入 C++/CLI。
P/Invoke 适合的场景,比如下面这些。
- 以
extern "C"公开的扁平函数 API - 参数和返回值只涉及整数、指针、简单结构体之类
- 字符串约定明确,缓冲区的职责也很简单
- 资源管理像
Create/Destroy那样容易理解 - C# 一侧可以直接了当地写出
SafeHandle和StructLayout
只要整理到这种程度,在 C# 一侧声明后直接使用就足够了,感觉上接近调用 Windows API,实现也更容易阅读。
3. P/Invoke 突然变得吃力的边界
问题出现在对方并非「单纯的 C API」的时候。 从这里开始,情况会突然变得不同。
3.1. 当开始面对 C++ 类时
当原生 DLL 是以 C++ 类为中心设计时,本来是想直接调用类的方法,但 P/Invoke 能直接面对的只有 DLL 导出的函数。 也就是说,最终仍需要在某处准备一层 落地为 C 形式函数的层。
到这个阶段,实际所做的事情几乎就是「写一层包装」。
既然如此,与在 C# 一侧大量堆砌 IntPtr 和释放函数相比,把包装归拢到 C++ 一侧会更自然。
3.2. 当所有权与生命周期管理不易看清时
在 C++ 中,通常会有这些问题:
- 是由调用方来释放吗
- 返回的指针是「借用」的吗
- 是
const&还是所有权转移 - 内部是否做了缓存,因而对生命周期有某种前提假设
如果用 C# 中基于 IntPtr 的方式来表达这些,即使一开始能跑起来,事后再回头阅读时也会相当痛苦。
一旦开始出现「这个指针到底该由谁在什么时候释放」的问题,边界面很快就会变得混浊不清。
3.3. 当出现 std::wstring、std::vector、回调、异常时
从这里开始,P/Invoke 就进入了「并非写不出来,但写起来并不舒服」的领域。
- 想让 C# 直接表达
std::wstring - 想返回
std::vector<T> - 想通过回调接收原生处理的进度
- 失败时会抛出 C++ 异常
这类需求越多,C# 一侧的 MarshalAs、手动缓冲区、固定长度数组、委托生命周期管理、错误码解读等负担就会越多。
当然,只要肯下功夫,还是能写出来的。 只是,痛苦之处在于 这份努力所投入的地方并非本质所在。 本来真正想做的是业务逻辑或 UI,而不是在边界面上进行格斗。
3.4. 当不想让 C++ 的种种限制泄漏到 C# 时
原生 DLL 一侧的 API 并不一定原样适合 C#。
例如在原生一侧,即便设计是这样的:
- 把多个方法调用组合成一次处理
- 用返回值和 out 参数来传递错误
- 初始化顺序存在前提假设
- 线程安全性存在限制
在很多情况下,仍希望能向 C# 一侧展示更直接易用的 API。 作为转换这一层的角色,C++/CLI 相当合适。
4. 插入 C++/CLI 包装器的架构
架构本身很简单。
flowchart LR
Cs[C# 应用] -->|面向 .NET 的 API| Wrapper[C++/CLI 包装器 DLL]
Wrapper -->|直接处理原生头文件与类型| Native[原生 C++ DLL]
让 C# 只能看到 符合 .NET 风格的 API,把下面这些事情封闭在 C++/CLI 一侧:
- 字符串转换
- 数组或向量的转换
- 异常的转换
- 所有权的整理
- 错误码的解读
- 如有需要,还包括线程边界或回调的吸收处理
重要的是,不要让 C++/CLI 项目本身变得过于庞大。 它的职责始终只是「翻译」与「整形」。 一旦开始把业务逻辑也塞进这一层,这一层就会反过来变成主角。
5. C++/CLI 能让哪些事情变轻松
5.1. 可以把 C++ 的类型原样当作 C++ 类型处理
这一点相当重要。 在 C++/CLI 一侧可以直接包含原生头文件,原样使用 C++ 的类型。
也就是说,不需要在 C# 一侧勉强「重现 C++ 的世界」。
无论是 std::wstring 还是 std::vector,都可以先以 C++ 类型的形式接住,再以需要的形式传递给 .NET 一侧。
5.2. 可以把 API 整形为面向 .NET 的形式
可以让 C# 一侧看到熟悉的形式来提供 API:
stringbyte[]List<T>IDisposable- 异常
这个差异看起来并不起眼,但会大幅改变使用方的负担。 尤其是在团队开发中,即便是不了解原生细节的成员,也能更容易上手,这一点很有效。
5.3. 更容易整理异常与错误的责任划分
如果原生一侧混杂着异常和错误码,让 C# 一侧原样接收会很难处理。 可以在 C++/CLI 一侧统一进行:
- 把异常转换为 .NET 的异常
- 把错误码转换为有意义的异常或结果类型
- 补充日志所需的上下文
只要在边界处提前翻译成「有意义的失败」,调用方就会清爽很多。
5.4. 可以让 C# 一侧看不到 ABI 的波动
C++ 的类和方法,并不像 C 函数那样拥有简单的 ABI。 一旦 C# 开始直接了解这些情况,导出函数和封送处理的种种限制就会暴露出来。
只要插入 C++/CLI 包装器,就可以做到 把 C++ 的种种限制封闭在 C++ 一侧,只让 C# 看到稳定的一面。 这种分离在库更新时同样有效。
5.5. 更容易实现分阶段迁移
一下子把现有的原生 DLL 全部重写,代价很大。 如果使用 C++/CLI 包装器,可以先只对需要的 API 做一层薄薄的包装,从 C# 一侧的新界面或新工作流开始使用,从而更容易实现分阶段迁移。
在既要活用 Windows 现有资产,又要把周边逐步转向 .NET 的场景中,这种方式相当合拍。
6. 代码片段
这里给出的并非「原样可运行的完整示例」,只是能理解边界面大致样貌的片段。
6.1. 原生 DLL 一侧的 API 示意
// NativeLib.hpp
#pragma once
#include <string>
#include <vector>
namespace NativeLib
{
struct AnalyzeOptions
{
int threshold;
std::wstring modelPath;
};
struct AnalyzeResult
{
bool ok;
std::wstring message;
std::vector<int> scores;
};
class Analyzer
{
public:
explicit Analyzer(const std::wstring& licensePath);
AnalyzeResult Analyze(const std::wstring& imagePath, const AnalyzeOptions& options);
};
}
这个 API,作为原生 C++ 来说很普通。 但要让 C# 直接触碰它,还是相当有难度的。
6.2. 如果想用 P/Invoke 来做,会是这样
首先,为了能从 C# 直接调用,需要在某处落地为 C 形式的函数。 比如,需要另外准备这样的桥接函数。
// 落地为 C API 的桥接示意
extern "C"
{
__declspec(dllexport) void* Analyzer_Create(const wchar_t* licensePath);
__declspec(dllexport) void Analyzer_Destroy(void* handle);
__declspec(dllexport) int Analyzer_Analyze(
void* handle,
const wchar_t* imagePath,
const AnalyzeOptionsNative* options,
AnalyzeResultNative* result);
}
C# 一侧,大致会是这种感觉。
internal sealed class SafeAnalyzerHandle : SafeHandle
{
private SafeAnalyzerHandle() : base(IntPtr.Zero, ownsHandle: true) { }
public override bool IsInvalid => handle == IntPtr.Zero;
protected override bool ReleaseHandle()
{
NativeMethods.Analyzer_Destroy(handle);
return true;
}
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
internal struct AnalyzeOptionsNative
{
public int Threshold;
public IntPtr ModelPath;
}
internal static class NativeMethods
{
[DllImport("NativeBridge.dll", CharSet = CharSet.Unicode)]
internal static extern SafeAnalyzerHandle Analyzer_Create(string licensePath);
[DllImport("NativeBridge.dll", CharSet = CharSet.Unicode)]
internal static extern void Analyzer_Destroy(IntPtr handle);
[DllImport("NativeBridge.dll", CharSet = CharSet.Unicode)]
internal static extern int Analyzer_Analyze(
SafeAnalyzerHandle handle,
string imagePath,
ref AnalyzeOptionsNative options,
out AnalyzeResultNative result);
}
如果到这里就能解决当然好,但实际上还会进一步出现这些问题:
- 如何返回可变长度的数据
- 字符串缓冲区由谁释放
- 错误详情放在哪里
- 如何保障回调的生命周期
也就是说,很多时候本以为选择的是 P/Invoke,实质上却已经在开始设计一套 C 兼容 API。
6.3. 如果用 C++/CLI 包装器,可以这样写
在 C++/CLI 一侧接住原生的种种限制,整理出要展示给 C# 的 API。
// AnalyzerWrapper.h
#pragma once
#include "NativeLib.hpp"
using namespace System;
using namespace System::Collections::Generic;
public ref class AnalysisOptions
{
public:
property int Threshold;
property String^ ModelPath;
};
public ref class AnalysisResult
{
public:
property bool Ok;
property String^ Message;
property List<int>^ Scores;
};
public ref class AnalyzerWrapper : IDisposable
{
public:
AnalyzerWrapper(String^ licensePath);
~AnalyzerWrapper();
!AnalyzerWrapper();
AnalysisResult^ Analyze(String^ imagePath, AnalysisOptions^ options);
private:
NativeLib::Analyzer* _native;
};
// AnalyzerWrapper.cpp
#include "AnalyzerWrapper.h"
#include <msclr/marshal_cppstd.h>
using msclr::interop::marshal_as;
AnalyzerWrapper::AnalyzerWrapper(String^ licensePath)
{
_native = new NativeLib::Analyzer(marshal_as<std::wstring>(licensePath));
}
AnalyzerWrapper::~AnalyzerWrapper()
{
this->!AnalyzerWrapper();
}
AnalyzerWrapper::!AnalyzerWrapper()
{
delete _native;
_native = nullptr;
}
AnalysisResult^ AnalyzerWrapper::Analyze(String^ imagePath, AnalysisOptions^ options)
{
NativeLib::AnalyzeOptions nativeOptions{};
nativeOptions.threshold = options->Threshold;
nativeOptions.modelPath = marshal_as<std::wstring>(options->ModelPath);
try
{
auto nativeResult = _native->Analyze(
marshal_as<std::wstring>(imagePath),
nativeOptions);
auto managed = gcnew AnalysisResult();
managed->Ok = nativeResult.ok;
managed->Message = gcnew String(nativeResult.message.c_str());
managed->Scores = gcnew List<int>();
for (int score : nativeResult.scores)
{
managed->Scores->Add(score);
}
return managed;
}
catch (const std::exception& ex)
{
throw gcnew InvalidOperationException(gcnew String(ex.what()));
}
}
C# 一侧就会变得相当直接。
using var analyzer = new AnalyzerWrapper(@"C:\license.dat");
var result = analyzer.Analyze(
@"C:\input.png",
new AnalysisOptions
{
Threshold = 80,
ModelPath = @"C:\model.bin"
});
if (!result.Ok)
{
Console.WriteLine(result.Message);
}
C# 能看到的,只有 string、List<int> 和 IDisposable。
IntPtr、释放函数、原生字符串缓冲区之类的问题完全看不到。
这一点意义重大。
7. 即便如此,也不适合选择 C++/CLI 的情况
当然,C++/CLI 并非万能。 也存在不适合选择它的场景。
- 对方从一开始就公开了干净的 C API
- 这种情况下 P/Invoke 更直接。
- 需要跨平台
- C++/CLI 以 Windows 为前提。
- 边界面很小,类型也很简单
- 增加一个包装 DLL 的成本有时反而更大。
- 对 AOT 或分发方面的限制要求相当严格
- 最好先看清整体架构的要求。
总之,判断标准就是「相对于原生 DLL 的复杂度,在哪里进行转换最自然」。 简单就用 P/Invoke,复杂就用 C++/CLI。 按照这个区分,大体都能顺利进行。
8. 总结
作为从 C# 使用原生 DLL 的方法,P/Invoke 至今仍是王道。 不过,这仅限于 对方作为 C API 表现得足够直接的时候。
如果原生一侧是作为 C++ 库设计的,
那么与在 C# 一侧堆砌 IntPtr 和封送属性苦苦支撑相比,用 C++/CLI 制作一层薄包装往往更能保持边界面的整洁。
尤其是当涉及到:
- 基于类的 API
- 所有权的前提假设
std::wstring或std::vector- 异常转换
- 回调
- 分阶段迁移
时,C++/CLI 是相当现实的选择。
要做的事情并不华丽。 但「在哪里整理边界」这件事,会切实影响到后续的可维护性。 当想要同时活用 Windows 现有资产与 .NET 时,C++/CLI 依然相当实用。
9. 参考资料
- 本文的示例代码一整套(原生 C++ 库、C++/CLI 包装器、C# 消费端) - komurasoft-blog-samples (GitHub)
- Mixed (Native and Managed) Assemblies - Microsoft Learn
- .NET programming with C++/CLI - Microsoft Learn
- Migrate C++/CLI projects to .NET - Microsoft Learn
- Using C++ Interop (Implicit PInvoke) - Microsoft Learn
- Platform Invoke (P/Invoke) - Microsoft Learn
- Overview of Marshaling in C++/CLI - Microsoft Learn
- marshal_as - Microsoft Learn
- 关于 Interop (C++) 性能的注意事项 - Microsoft Learn
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
防止 Windows 应用程序重复启动 ── 命名 Mutex 与重复执行时的窗口前置
整理业务型 Windows 应用程序的常见需求——「不让同一个应用程序启动两次」——如何用命名 Mutex 来实现。涵盖 Global\ 与 Local\ 命名空间差异在 RDP 环境中的陷阱、拥有线程限制与 AbandonedMutexException、在 SetFor...
委托开发 Windows 应用程序前该梳理的事项
在委托外包开发 Windows 应用程序之前,梳理现有软件改造、设备联动、COM/ActiveX、发布与更新、维护等需要注意的要点。
Windows Forms、WPF、WinUI 该怎么选 - 新建项目、存量资产、发布、UI 表现力判断表
从存量资产的规模、界面是表单为主还是需要表现力、现代 Windows UI 是否是产品刚需、发布与运维怎么落地这四个角度,整理 WinForms、WPF、WinUI 该怎么选的判断表,并提醒只想用 Windows App SDK 不必全面迁移到 WinUI。
共享内存的陷阱与实务最佳实践
整理在实务中使用共享内存时的陷阱,以及涵盖同步、可见性、生命周期、ABI、权限等降低事故率的设计方法。
将 .NET Framework 迁移到 .NET 之前该确认的事 - 动手前就决定成败的实战检查清单
整理将 .NET Framework 业务应用程序迁移到现代 .NET 之前必须先盘点的论点。涵盖落地版本、Windows 专用前提的取舍、不再支持的 API、共享库切分方式、第三方组件、运维与 CI/CD,帮助在动手前厘清范围并降低迁移风险。
常见问题
汇总了咨询这一主题时常见的问题。
- P/Invoke 与 C++/CLI 包装器该如何区分使用?
- 如果对方是以 extern "C" 公开的一组扁平的 C 函数,P/Invoke 是最直接、最简单的选择。如果对方是以 C++ 类为中心设计的库,涉及所有权、字符串、数组、异常、回调等问题,那么用 C++/CLI 插入一层薄包装会更便于维护。判断标准是「相对于原生 DLL 的复杂度,在哪里进行转换最自然」——简单就用 P/Invoke,复杂就用 C++/CLI,这样区分基本都能顺利进行。
- 插入 C++/CLI 包装器之后,会轻松在哪些方面?
- 在 C++/CLI 一侧可以直接包含原生头文件,把 std::wstring、std::vector 等类型作为 C++ 类型原样处理,因此不需要在 C# 一侧重现 C++ 的世界。可以只让 C# 看到 string、byte[]、List<T>、IDisposable、异常等符合 .NET 风格的 API,而把 IntPtr、释放函数、封送处理这些细节隐藏起来。可以在边界处把 C++ 异常或错误码转换为 .NET 异常,也更便于在保留现有资产的基础上进行分阶段迁移。
- 只靠 P/Invoke 推进会在什么时候变得痛苦?
- 当原生 DLL 是以 C++ 类为中心设计的,P/Invoke 能直接调用的只有 DLL 导出的函数,因此最终还是需要在某处准备一层落地到 C 形式函数的层,实质上等于开始设计一套 C 兼容 API。而且,如果还要返回 std::wstring 或 std::vector、通过回调接收进度、抛出 C++ 异常等需求不断增加,C# 一侧就会堆积起 MarshalAs、手动缓冲区、委托生命周期管理等负担。用基于 IntPtr 的方式表达所有权与生命周期的前提,事后再回头阅读时会相当痛苦。
- 有哪些情况下不选择 C++/CLI 更好?
- 有。如果对方从一开始就公开了干净的 C API,那么使用 P/Invoke 会更直接。另外,C++/CLI 以 Windows 为前提,如果需要跨平台就无法使用。当边界面很小、类型也很简单时,增加一个包装 DLL 的成本可能反而更大;如果对 AOT 或分发方面的限制要求很严格,也应该先确认整体架构的要求。
作者简介
本文作者的个人简介页面。
Go Komura
小村软件有限公司 代表
以 Windows 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。