从 C# 调用原生 DLL:C++/CLI 包装器 vs P/Invoke
· 更新日期: · 小村 豪 · C++/CLI, C#, Windows 开发, 原生集成
更新记录(1 条,最后更新 2026年09月03日)
本文的修改记录。已保存的更新前版本,可通过带有 DOI 的永久链接阅读。
- 本文此前是日文原文的节译,缺少大量章节、表格、Mermaid 图、图题、脚注与 FAQ。现已改写为日文原文的完整译文,技术主张与日文版一致,并补上了此前缺失的图与表,同时统一了全篇的术语译法。 查看更新前的版本 (DOI: 10.5281/zenodo.21615419)
- 首次发布
引用本文(DOI: 10.5281/zenodo.21615418)
本文保存于 Zenodo。以下同时提供始终指向最新版本的 DOI,以及固定于您正在阅读版本的 DOI。
小村 豪(2026)。《从 C# 调用原生 DLL:C++/CLI 包装器 vs P/Invoke》。小村软件有限公司。https://doi.org/10.5281/zenodo.21615418 https://comcomponent.com/zh-CN/blog/2026/03/07/000-cpp-cli-wrapper-for-native-dlls/
- DOI(最新版本)
- 10.5281/zenodo.21615418
- DOI(此版本)
- 10.5281/zenodo.22281919
想从 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)
目标读者与前提
本文面向 已经从 C# 调用过原生 DLL、写得出 DllImport 声明,但一遇到对方是 C++ 类库就卡住的开发者。没有 C++/CLI 的经验也没关系。反过来说,如果还没写过 P/Invoke,先读“从 C# 安全调用 Win32 API —— P/Invoke 实务指南”会更快。
设想的环境是 Windows 加 Visual Studio 2022,可以把 C++/CLI 包装器(.vcxproj)和 C# 项目放进同一个解决方案。目标框架用 .NET Framework 或 .NET 8 等都可以,但 .NET 一侧有其特有的限制,已经整理在第 7 章。
先要记住的术语
| 术语 | 含义 |
|---|---|
| P/Invoke(Platform Invoke) | 用 C# 的 DllImport / LibraryImport 特性声明原生 DLL 的导出函数并直接调用的机制 |
| 封送处理 | 在边界上把 .NET 的类型(string、数组等)与原生的表示(wchar_t*、裸指针等)相互转换 |
| ABI(Application Binary Interface,应用程序二进制接口) | 调用约定、参数传递方式、结构体的内存布局、名称修饰等,让编译好的二进制彼此能对接的一整套约定。C 函数的约定简单而稳定,但 C++ 类的名称修饰和 vtable 布局依赖编译器,不能从 C# 直接指望(5.4) |
SafeHandle |
包装原生句柄的 .NET 抽象类。为了防止句柄漏释放,以及“正在使用时被释放”的问题,用它代替裸持 IntPtr(6.2) |
StructLayout |
让 C# 结构体的内存布局与原生一侧对齐的特性。常见用法是用 LayoutKind.Sequential 按声明顺序排列,用 CharSet 指定字符串的处理方式(6.2) |
marshal_as |
C++/CLI 提供的转换辅助工具。像 marshal_as<std::wstring>(managedString) 那样,在 .NET 类型与原生类型之间相互转换。使用时需要引入 msclr/marshal_cppstd.h 等头文件(6.3) |
| 混合程序集 | 同时包含原生机器指令和 MSIL 的 DLL。C++/CLI 包装器就是这样的 DLL(第 7 章) |
目录
- 先说结论(一句话)
- P/Invoke 已经够用的情况
- P/Invoke 突然变得吃力的边界
- 插入 C++/CLI 包装器的架构
- C++/CLI 能让哪些事情变轻松
- 代码片段
- 即便如此,也不适合选择 C++/CLI 的情况
- 总结
- 参考资料
图中实线表示始终成立的关系,虚线表示带条件的关系(成立条件写在详情页各关系的说明中)。关系的完整列表(共 21 条,附依据与确信度)以及主要概念的定义,汇总在知识地图详情页(日文)。数据:JSON-LD / Turtle
1. 先说结论(一句话)
- 如果对方是一组 C 函数,P/Invoke 更直接
- 如果对方是 C++ 库,插入一层 C++/CLI 包装器会更便于维护
- 尤其是牵涉到 类、所有权、字符串、数组、异常、回调 时,不要让 C# 一侧硬撑
说到底,就是 不要把原生 DLL 的种种情况直接带进 C#。 原生一侧的情况由 C++ 承接,只把展示给 .NET 的那一面整理干净。 这种分工一旦顺利运作,写代码和调试都会从容很多。
flowchart TB
accTitle: 由对方 DLL 的形态决定的选型
accDescr: 表示本文的结论:如果对方是一组 C 函数,P/Invoke 更直接;如果对方是 C++ 库,插入一层 C++/CLI 包装器会更便于维护,以分支形式呈现。
q{"对方的 DLL 属于哪一种"}
q -->|"一组 C 函数"| pi["P/Invoke 更直接"]
q -->|"C++ 库"| cli["插入 C++/CLI 包装器"]
cli -.-> note["原生一侧的情况由 C++ 承接"]
图1:选型的结论。对方是一组 C 函数就用 P/Invoke,是 C++ 库就用 C++/CLI 包装器。
2. P/Invoke 已经够用的情况
如果用 P/Invoke 就能搞定,那是最简单的办法。没必要勉强引入 C++/CLI。
P/Invoke 适合的,比如下面这些情况。
- 已经是以
extern "C"公开的扁平函数 API - 参数和返回值用整数、指针、简单结构体等就够了
- 字符串的约定明确,缓冲区的职责也简单
- 资源管理像
Create/Destroy那样一目了然 - C# 一侧能顺手写出
SafeHandle和StructLayout
整理到这种程度,在 C# 一侧声明后直接用就够了,而且感觉接近调用 Windows API,实现也更好读。
flowchart TB
accTitle: 可以判定 P/Invoke 已经够用的条件
accDescr: 表示只要凑齐扁平的 C API、简单的参数与返回值、明确的字符串与资源约定这几个条件,在 C# 一侧声明后直接使用就够了。
c1["extern C 的扁平函数 API"] --> ok["P/Invoke 已经够用"]
c2["参数与返回值都简单"] --> ok
c3["字符串与资源的约定明确"] --> ok
ok --> use["在 C# 一侧声明后直接用"]
图2:对方的 API 整理到这种程度,就没必要勉强引入 C++/CLI。
3. P/Invoke 突然变得吃力的边界
问题出在对方并不是“单纯的 C API”的时候。 从这里开始,情况会陡然一变。
3.1. 当开始面对 C++ 类时
当原生 DLL 以 C++ 类为中心设计时,本来是想直接调用类的方法,但 P/Invoke 能直接对付的只有 DLL 的导出函数。 也就是说,最终仍需要在某处加一层 落到 C 形式函数的层。
到这一步,实际做的事情几乎就是“写一层包装”。
既然如此,与其在 C# 一侧长出一大堆 IntPtr 和释放函数,把包装挪到 C++ 一侧更自然。
flowchart TB
accTitle: 面对 C++ 类却选择 P/Invoke 的结果
accDescr: 表示即使想调用 C++ 类的方法,P/Invoke 能对付的也只有 DLL 的导出函数,于是需要一层落到 C 形式的层,实质上等于在写包装的过程。
want["想调用 C++ 类的方法"] --> limit["能调用的只有导出函数"]
limit --> bridge["需要一层落到 C 形式函数的层"]
bridge --> fact["做的事情几乎就是包装"]
fact -.-> better["那不如挪到 C++ 一侧更自然"]
图3:即使想用 P/Invoke 硬撑,只要对方是 C++ 类,最终还是要在某处写一层包装。
3.2. 当所有权与生命周期管理不易看清时
在 C++ 里,下面这些事情很常见:
- 是由调用方负责释放吗
- 返回的指针是借用的吗
- 是
const&还是所有权转移 - 内部有没有做缓存,因而对生命周期有前提
如果用 C# 的 IntPtr 来表达这些,一开始能跑,事后回头再读却相当痛苦。
一旦开始出现“这个指针到底该谁在什么时候释放”的问题,边界面很快就浑浊了。
flowchart TB
accTitle: 用 IntPtr 表达所有权前提导致的浑浊
accDescr: 表示把谁来释放、是借用还是所有权转移、生命周期是否有前提这些 C++ 一侧的情况用 C# 的 IntPtr 表达出来,事后回头会读不懂,边界面很快变浑浊。
q1["谁来释放"] --> ptr["用 C# 的 IntPtr 表达"]
q2["借用还是所有权转移"] --> ptr
q3["生命周期是否有前提"] --> ptr
ptr --> bad["一开始能跑,事后读不懂"]
bad --> muddy["边界面很快浑浊"]
图4:把所有权与生命周期的前提用基于 IntPtr 的方式带来带去,就会因为“谁在什么时候释放”的问题让边界面浑浊。
3.3. 当出现 std::wstring、std::vector、回调、异常时
从这一带开始,P/Invoke 进入了“不是写不出来,但写得并不舒服”的领域。
- 想在 C# 一侧原样表达
std::wstring - 想返回
std::vector<T> - 想用回调接收原生处理的进度
- 失败时会抛出 C++ 异常
这类因素一多,C# 一侧就会多出 MarshalAs、手动缓冲区、定长数组、委托生命周期管理、错误码解读等等。
当然,只要肯下功夫,还是写得出来的。 麻烦的是,下功夫的地方并不是本质所在。 本来想做的是业务逻辑或 UI,而不是在边界面上打格斗。
flowchart TB
accTitle: C++ 要素增加带来的 C# 一侧负担堆积
accDescr: 表示想返回 wstring 或 vector、想用回调接收进度、会抛出 C++ 异常这些因素越多,C# 一侧的 MarshalAs、手动缓冲区、委托生命周期管理就堆得越高。
e1["想返回 wstring 或 vector"] --> pile["C# 一侧的代码越堆越多"]
e2["想用回调接收进度"] --> pile
e3["失败时会抛出 C++ 异常"] --> pile
pile --> load["MarshalAs、手动缓冲区"]
pile --> load2["委托生命周期管理、错误解读"]
图5:每多一个 C++ 味道的要素,C# 一侧边界代码的负担就堆高一层。
3.4. 当不想把 C++ 的种种情况泄漏到 C# 时
原生 DLL 一侧的 API 未必原样就适合 C#。
比如原生一侧的设计是这样的:
- 把多个方法调用组合成一次处理
- 错误用返回值加 out 参数返回
- 初始化顺序有前提
- 线程安全性有限制
即便如此,很多时候仍希望给 C# 一侧看到更直接的 API。 作为做这层转换的角色,C++/CLI 相当合适。
flowchart TB
accTitle: 把原生一侧的情况转换后再展示给 C# 的层
accDescr: 表示初始化顺序、线程安全性限制等原生 API 的设计情况由 C++/CLI 转换层承接,再向 C# 一侧展示更直接的 API 这样的职责划分。
nat["原生 API 的设计情况"] -.-> ex["初始化顺序、线程限制等"]
nat --> conv["C++/CLI 的转换层"]
conv --> api["向 C# 展示直接的 API"]
图6:不把原生一侧的设计情况直接朝向 C#,而是把 C++/CLI 当作转换层插进去。
4. 插入 C++/CLI 包装器的架构
架构本身很简单。
flowchart LR
Cs[C# 应用] -->|面向 .NET 的 API| Wrapper[C++/CLI 包装器 DLL]
Wrapper -->|直接处理原生头文件与类型| Native[原生 C++ DLL]
图7:在 C# 应用与原生 C++ DLL 之间插入一层 C++/CLI 包装器 DLL 的架构。
让 C# 只看到 符合 .NET 风格的 API,把下面这些封闭在 C++/CLI 一侧:
- 字符串转换
- 数组或向量的转换
- 异常的转换
- 所有权的梳理
- 错误码的解读
- 必要时还包括线程边界或回调的吸收
重要的是 不要让 C++/CLI 项目本身长得太大。 它的职责始终只是“翻译”和“整形”。 一旦开始把业务逻辑也塞进去,这一层就会反客为主。
flowchart TB
accTitle: 封闭在 C++/CLI 包装器里的工作
accDescr: 表示把字符串和数组的转换、异常与错误码的转换、所有权的梳理这些翻译和整形的工作封闭在 C++/CLI 一侧,而不放入业务逻辑的职责界线。
w["C++/CLI 包装器的职责"] --> t1["字符串、数组的转换"]
w --> t2["异常、错误码的转换"]
w --> t3["所有权的梳理"]
w -.-> warn["不要放入业务逻辑"]
图8:包装器的职责限定在“翻译”和“整形”,不让它长得太大很重要。
5. C++/CLI 能让哪些事情变轻松
5.1. C++ 的类型可以当作 C++ 类型来处理
这一点相当重要。 在 C++/CLI 一侧可以包含原生头文件,原样使用 C++ 的类型。
也就是说,不必在 C# 一侧勉强“重现 C++ 的世界”。
无论 std::wstring 还是 std::vector,都可以先当成 C++ 类型接住,再按需要的形式传给 .NET 一侧。
flowchart TB
accTitle: 先接住 C++ 类型再传给 .NET 的流程
accDescr: 表示包含原生头文件后把 wstring 和 vector 当作 C++ 类型原样接住,转换成需要的形式再传给 .NET 一侧,从而不必在 C# 一侧重现 C++ 的世界。
nt["原生的 wstring 或 vector"] --> recv["在 C++/CLI 一侧当作 C++ 类型接住"]
recv --> conv["转换成需要的形式"]
conv --> net["传给 .NET 一侧"]
net -.-> nofake["不在 C# 里重现 C++ 的世界"]
图9:C++ 的类型先当作 C++ 类型接住,转换之后再传给 .NET 就好。
5.2. 可以把 API 整形成面向 .NET 的形式
可以用 C# 一侧眼熟的形式提供 API:
stringbyte[]List<T>IDisposable- 异常
这个差别看着不起眼,却会大幅改变使用方的负担。 尤其在团队开发中,不了解原生细节的成员也能上手,这一点很见效。
5.3. 更容易梳理异常与错误的职责
如果原生一侧混杂着异常和错误码,让 C# 一侧原样接收会很难处理。 可以在 C++/CLI 一侧统一做这些事:
- 把异常转换成 .NET 的异常
- 把错误码转换成有意义的异常或结果类型
- 补上日志所需的上下文
在边界处先翻译成“有意义的失败”,调用方就会清爽很多。
flowchart TB
accTitle: 在边界处翻译异常与错误码
accDescr: 表示把原生一侧混杂的异常与错误码在 C++/CLI 一侧统一承接,异常转换成 .NET 异常,错误码转换成有意义的异常或结果类型,并补上日志所需的上下文。
mixed["原生的异常与错误码"] --> tr["在 C++/CLI 一侧统一承接"]
tr --> e1["异常转换成 .NET 的异常"]
tr --> e2["错误码转换成有意义的形式"]
tr -.-> log["补上日志所需的上下文"]
图10:在边界处先翻译成“有意义的失败”,C# 调用方就会清爽。
5.4. 可以把 ABI 的波动挡在 C# 之外
C++ 的类和方法,并不像 C 函数那样有简单的 ABI。 一旦 C# 开始直接了解这些内情,导出函数和封送处理的种种情况就会浮到表面。
只要插入 C++/CLI 包装器,就能做到 把 C++ 的情况封闭在 C++ 一侧,只让 C# 看到稳定的一面。 这种分离在更新库的时候同样见效。
flowchart TB
accTitle: 用包装器挡住 ABI 波动的结构
accDescr: 表示由于 C++ 的类和方法不像 C 函数那样有简单的 ABI,把这些情况封闭在 C++ 一侧、只让 C# 看到稳定的一面,这种分离在更新库时同样见效。
abi["C++ 类的 ABI 并不简单"] --> hide["C++ 的情况封闭在 C++ 一侧"]
hide --> stable["只让 C# 看到稳定的一面"]
stable -.-> update["更新库时同样见效的分离"]
图11:不把封送处理和导出函数的种种情况摆到台面上,只让 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,这种情况很常见。
flowchart TB
accTitle: P/Invoke 方案实质变成 C 兼容 API 设计的过程
accDescr: 表示本打算用 P/Invoke 直接调用,却要准备 C 桥接函数、在 C# 一侧写 SafeHandle 和 StructLayout,再加上可变长度、释放、回调等议题,实质上已经开始设计 C 兼容 API 的过程。
start["打算用 P/Invoke 直接调用"] --> bridge["另外准备 C 桥接函数"]
bridge --> decl["写 SafeHandle 和 StructLayout"]
decl --> more["可变长度、释放、回调的议题"]
more --> real["实质上开始设计 C 兼容 API"]
图12:本以为“只是选了 P/Invoke”,回过神来往往已经在设计 C 兼容 API 了。
这些议题挪到 C++/CLI 一侧后会变成下面这样。它们并不会像变魔术一样消失,更准确的说法是 转移到了在 C++ 一侧能自然写出来的形式。
| P/Invoke 会出现的议题 | P/Invoke 一侧的典型处理 | 在 C++/CLI 一侧会怎样 |
|---|---|---|
| 可变长度的数据怎么返回 | 准备“查询所需大小的函数”和“填充缓冲区的函数”两段式接口,由 C# 一侧分配缓冲区 | 直接接住原生返回的 std::vector,装进 List<int> 或数组后返回(6.3) |
| 字符串缓冲区由谁释放 | 在 C API 里加上释放用的函数,并遵守 C# 一侧必须调用它的约定 | std::wstring 的生命周期在原生一侧就闭环,给 C# 的只是新建一个 String^ 返回(6.3) |
| 错误详情放在哪里 | 除了返回值里的错误码,还要准备取出详情的函数或 out 参数的结构体 | 用 try / catch 接住原生的异常,转换成有意义的 .NET 异常再抛出(6.3) |
| 回调的生命周期怎么保障 | 为了不让委托被 GC 回收,用字段等方式一直持有引用 | 把回调的注册与注销封闭在 C++ 一侧,只给 C# 看到事件或委托 |
| 句柄的所有权怎么表达 | 派生 SafeHandle,在 ReleaseHandle 里调用释放函数 |
在包装器的析构函数 / 终结器里 delete 原生对象(6.3) |
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;
};
这里 C++/CLI 特有的是 ~AnalyzerWrapper() 和 !AnalyzerWrapper() 这两个成员。两者看起来都像 C++ 的析构函数,但职责对应的是 .NET 的 Dispose 模式。
| C++/CLI 的写法 | 编译器生成的东西 | 从 C# 看到的行为 |
|---|---|---|
~AnalyzerWrapper()(析构函数) |
实现 IDisposable 的 Dispose() |
离开 using 时,或者调用 Dispose() 时执行 |
!AnalyzerWrapper()(终结器) |
重写 Object::Finalize 的 Finalize() |
GC 回收时执行。什么时候跑无法确定 |
惯用做法是 把原生资源的释放写在终结器里,再由析构函数调用它。下面的实现中,~AnalyzerWrapper() 只是调用 this->!AnalyzerWrapper(),正是这个道理;这样写之后,即使 C# 一侧忘了调用 Dispose(),最后 GC 也会兜住。析构函数被调用时,GC::SuppressFinalize 会抑制终结,所以不会重复释放。
另外,Dispose()、Finalize()、Dispose(bool) 由编译器生成,不在 C++/CLI 一侧自己写。反过来,也不能从 C++/CLI 的代码直接调用 Dispose(),而要用 delete 运算符调用析构函数。这组对应关系整理在 How to: Define and consume classes and structs (C++/CLI) - Microsoft Learn 的“Destructors and finalizers”一节。
flowchart TB
accTitle: 析构函数与终结器的分工
accDescr: 表示从 C# 的 using 或 Dispose 会调用析构函数,经由终结器释放原生资源;忘记 Dispose 时由 GC 调用终结器兜底,并用 SuppressFinalize 防止重复释放。
us["C# 的 using 或 Dispose"] --> dtor["析构函数(相当于 Dispose)"]
forget["忘记调用 Dispose"] -.-> gc["GC 回收时的终结器"]
dtor --> fin["调用终结器"]
fin --> del["delete 原生资源"]
gc -.-> del
dtor -.-> sup["用 SuppressFinalize 防止重复释放"]
图13:把释放写在终结器里、由析构函数调用它的惯用做法。就算忘了 Dispose,最后 GC 也会兜住。
// 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)
{
// 如果在释放之后被调用,就在进入原生代码之前在这里拦住。
// 析构函数(= Dispose)会把 _native 置为 nullptr,
// 没有这个检查就会经由空指针进入原生代码,
// 抛出的不是 .NET 异常,而是访问冲突并让整个进程崩溃。
// 从 C# 一侧看,“Dispose 之后再碰就抛 ObjectDisposedException”
// 才是期望的行为,所有使用 _native 的方法都需要这个检查
if (_native == nullptr)
{
throw gcnew ObjectDisposedException("AnalyzerWrapper");
}
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 和分发限制看得相当严格
- 最好先把整体架构的要求看清楚。
最后一条“AOT 和分发限制”比较抽象,这里把实际会起作用的限制列出来。这一带如果还带着 .NET Framework 时代的感觉,很容易踩。
| 限制 | 内容 | 实务中的影响 |
|---|---|---|
| 操作系统 | 以 .NET(.NET Core 系列)为目标的 C++/CLI 仅限 Windows | 如果计划在 Linux 容器或 macOS 上运行,这一步就已经无法选它 |
| Native AOT | Native AOT 的不支持列表里明确写着 C++/CLI。同时,Assembly.LoadFile 之类的动态加载、System.Reflection.Emit、Windows 的内置 COM 也都不能用 |
与用 PublishAot 产出单个原生二进制的方针无法兼容 |
| 输出形式 | 以 .NET 为目标时 不能生成 exe,只能是 DLL。也不能以 .NET Standard 为目标 |
入口点放在 C# 一侧的 exe 中,C++/CLI 作为 DLL 被引用 |
| 项目形式 | 用的是 .vcxproj,而不是 SDK 样式的 csproj。一个项目也不能对多个 .NET 做多目标 |
如果 .NET Framework 版和 .NET 版都需要,就把项目文件拆开 |
| 运行时依赖 | 指定 /clr 时会同时启用 /MD,因此需要 MSVC 运行库的 DLL。以 .NET 为目标时,还需要把 ijwhost.dll 放进输出 |
如果以 XCOPY 分发或单文件发布为前提,要先确认这一点 |
| CPU 架构 | 混合程序集包含原生机器指令,因此不能像 C# 的 AnyCPU 那样用一个二进制覆盖所有架构 | 按 x86 / x64 等每个目标分别构建并分发 |
| 加载方式 | .NET 7 及以后总是加载到默认的 AssemblyLoadContext。.NET 6 及以前,如果最先是从原生一侧被调用,可能会加载到另一个 AssemblyLoadContext |
如果架构上要为每个插件分开加载上下文,要先确认行为 |
另外,C++/CLI 项目能以 .NET(.NET Core 系列)为目标,是从 Visual Studio 2019 开始的。如果只有比它更旧的环境,就得先按 .NET Framework 的前提来考虑。
总之,判断标准就是“相对于原生 DLL 的复杂度,在哪一层做转换最自然”。 简单就用 P/Invoke,复杂就用 C++/CLI。 按这样区分,大体都能顺利推进。
flowchart TB
accTitle: 不适合选择 C++/CLI 的场合
accDescr: 表示已经有干净的 C API、需要跨平台、边界面小且类型简单、对 AOT 和分发限制看得严格这四种场合下,不选 C++/CLI 包装器更好。
n1["已经有 C API"] --> no["不选 C++/CLI"]
n2["需要跨平台"] --> no
n3["边界小、类型也简单"] --> no
n4["AOT、分发限制严格"] --> no
no -.-> judge["在哪一层做转换才自然"]
图14:C++/CLI 并非万能。符合这四条的话,先考虑 P/Invoke 或重新审视架构。
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
- How to: Define and consume classes and structs (C++/CLI) - Microsoft Learn
- /clr (Common Language Runtime compilation) - Microsoft Learn
- Native AOT deployment overview - 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
相关文章
共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。
Arm 版 Windows 上业务应用能运行吗 ── x64 仿真(Prism)与原生 DLL・COM 的现实
面向开发者与信息系统部门,回答「Arm 版 Windows 上业务应用能运行吗」这一问题。梳理 x64 仿真(Prism)的原理、驱动程序等无法运行的层面、.NET 中 AnyCPU 与 P/Invoke 的组合问题,以及 Arm 适配自查清单。
参数为什么会坏掉 ── Windows 命令行参数的规则
在 Windows 上并不存在参数的数组,传给 CreateProcess 的是一条字符串,切分由接收方完成。本文讲解 CommandLineToArgvW・CRT・.NET 的切分规则,以及 .NET 的 ArgumentList 与 C++ 中正确的组装方法。
父进程消失之后还剩下什么 —— 用 Job Object 圈养子进程
为什么强制结束 UI 之后,SDK 的辅助进程仍然残留,一直占着摄像头或 COM 端口?本文从测量应用的视角,讲解如何用 Job Object 把进程树变成一个单位,并借助 KillOnJobClose 与完成端口来设计子进程的寿命。
命名管道实务 ── 从设计到安全,吃透 Windows 进程间通信的标准方案
以实务视角讲解 Windows 进程间通信的标准方案——命名管道。依据一手资料梳理字节模式与消息模式的取舍、同时接纳多个客户端的服务器结构、ACL 与模拟的安全设计,以及 .NET 的命名管道流。
在 C# 与 PowerShell 中使用 WMI/CIM——硬件信息获取、进程监控与远程查询实务指南
获取电脑序列号、监控磁盘剩余空间、检测进程启动,这些需求的经典答案就是 WMI/CIM。本文讲解 Get-CimInstance 等 CIM cmdlet 的用法与从旧版 Get-WmiObject 的迁移、C# 中 System.Management 与 CIM API ...
相关主题
与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。
Windows 技术主题
汇整 KomuraSoft LLC 关于 Windows 开发、故障调查与既有资产活用文章的主题中心。
32 位 / 64 位互通
整理 32 位 / 64 位互通、原生边界与相关 Windows 设计判断的主题页面。
常见问题
汇总了咨询这一主题时常见的问题。
- 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 和分发限制看得很严格,也应该先确认整体架构的要求。