从 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 章)

目录

  1. 先说结论(一句话)
  2. P/Invoke 已经够用的情况
  3. P/Invoke 突然变得吃力的边界
  4. 插入 C++/CLI 包装器的架构
  5. C++/CLI 能让哪些事情变轻松
  6. 代码片段
  7. 即便如此,也不适合选择 C++/CLI 的情况
  8. 总结
  9. 参考资料

图中实线表示始终成立的关系,虚线表示带条件的关系(成立条件写在详情页各关系的说明中)。关系的完整列表(共 21 条,附依据与确信度)以及主要概念的定义,汇总在知识地图详情页(日文)。数据:JSON-LD / Turtle

1. 先说结论(一句话)

  • 如果对方是一组 C 函数,P/Invoke 更直接
  • 如果对方是 C++ 库,插入一层 C++/CLI 包装器会更便于维护
  • 尤其是牵涉到 类、所有权、字符串、数组、异常、回调 时,不要让 C# 一侧硬撑

说到底,就是 不要把原生 DLL 的种种情况直接带进 C#。 原生一侧的情况由 C++ 承接,只把展示给 .NET 的那一面整理干净。 这种分工一旦顺利运作,写代码和调试都会从容很多。

由对方 DLL 的形态决定的选型表示本文的结论:如果对方是一组 C 函数,P/Invoke 更直接;如果对方是 C++ 库,插入一层 C++/CLI 包装器会更便于维护,以分支形式呈现。一组 C 函数C++ 库对方的 DLL 属于哪一种P/Invoke 更直接插入 C++/CLI 包装器原生一侧的情况由 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,实现也更好读。

可以判定 P/Invoke 已经够用的条件表示只要凑齐扁平的 C API、简单的参数与返回值、明确的字符串与资源约定这几个条件,在 C# 一侧声明后直接使用就够了。extern C 的扁平函数 APIP/Invoke 已经够用参数与返回值都简单字符串与资源的约定明确在 C# 一侧声明后直接用

图2:对方的 API 整理到这种程度,就没必要勉强引入 C++/CLI。

3. P/Invoke 突然变得吃力的边界

问题出在对方并不是“单纯的 C API”的时候。 从这里开始,情况会陡然一变。

3.1. 当开始面对 C++ 类时

当原生 DLL 以 C++ 类为中心设计时,本来是想直接调用类的方法,但 P/Invoke 能直接对付的只有 DLL 的导出函数。 也就是说,最终仍需要在某处加一层 落到 C 形式函数的层。

到这一步,实际做的事情几乎就是“写一层包装”。 既然如此,与其在 C# 一侧长出一大堆 IntPtr 和释放函数,把包装挪到 C++ 一侧更自然。

面对 C++ 类却选择 P/Invoke 的结果表示即使想调用 C++ 类的方法,P/Invoke 能对付的也只有 DLL 的导出函数,于是需要一层落到 C 形式的层,实质上等于在写包装的过程。想调用 C++ 类的方法能调用的只有导出函数需要一层落到 C 形式函数的层做的事情几乎就是包装那不如挪到 C++ 一侧更自然

图3:即使想用 P/Invoke 硬撑,只要对方是 C++ 类,最终还是要在某处写一层包装。

3.2. 当所有权与生命周期管理不易看清时

在 C++ 里,下面这些事情很常见:

  • 是由调用方负责释放吗
  • 返回的指针是借用的吗
  • 是 const& 还是所有权转移
  • 内部有没有做缓存,因而对生命周期有前提

如果用 C# 的 IntPtr 来表达这些,一开始能跑,事后回头再读却相当痛苦。 一旦开始出现“这个指针到底该谁在什么时候释放”的问题,边界面很快就浑浊了。

用 IntPtr 表达所有权前提导致的浑浊表示把谁来释放、是借用还是所有权转移、生命周期是否有前提这些 C++ 一侧的情况用 C# 的 IntPtr 表达出来,事后回头会读不懂,边界面很快变浑浊。谁来释放用 C# 的 IntPtr 表达借用还是所有权转移生命周期是否有前提一开始能跑,事后读不懂边界面很快浑浊

图4:把所有权与生命周期的前提用基于 IntPtr 的方式带来带去,就会因为“谁在什么时候释放”的问题让边界面浑浊。

3.3. 当出现 std::wstring、std::vector、回调、异常时

从这一带开始,P/Invoke 进入了“不是写不出来,但写得并不舒服”的领域。

  • 想在 C# 一侧原样表达 std::wstring
  • 想返回 std::vector<T>
  • 想用回调接收原生处理的进度
  • 失败时会抛出 C++ 异常

这类因素一多,C# 一侧就会多出 MarshalAs、手动缓冲区、定长数组、委托生命周期管理、错误码解读等等。

当然,只要肯下功夫,还是写得出来的。 麻烦的是,下功夫的地方并不是本质所在。 本来想做的是业务逻辑或 UI,而不是在边界面上打格斗。

C++ 要素增加带来的 C# 一侧负担堆积表示想返回 wstring 或 vector、想用回调接收进度、会抛出 C++ 异常这些因素越多,C# 一侧的 MarshalAs、手动缓冲区、委托生命周期管理就堆得越高。想返回 wstring 或 vectorC# 一侧的代码越堆越多想用回调接收进度失败时会抛出 C++ 异常MarshalAs、手动缓冲区委托生命周期管理、错误解读

图5:每多一个 C++ 味道的要素,C# 一侧边界代码的负担就堆高一层。

3.4. 当不想把 C++ 的种种情况泄漏到 C# 时

原生 DLL 一侧的 API 未必原样就适合 C#。

比如原生一侧的设计是这样的:

  • 把多个方法调用组合成一次处理
  • 错误用返回值加 out 参数返回
  • 初始化顺序有前提
  • 线程安全性有限制

即便如此,很多时候仍希望给 C# 一侧看到更直接的 API。 作为做这层转换的角色,C++/CLI 相当合适。

把原生一侧的情况转换后再展示给 C# 的层表示初始化顺序、线程安全性限制等原生 API 的设计情况由 C++/CLI 转换层承接,再向 C# 一侧展示更直接的 API 这样的职责划分。原生 API 的设计情况初始化顺序、线程限制等C++/CLI 的转换层向 C# 展示直接的 API

图6:不把原生一侧的设计情况直接朝向 C#,而是把 C++/CLI 当作转换层插进去。

4. 插入 C++/CLI 包装器的架构

架构本身很简单。

面向 .NET 的 API直接处理原生头文件与类型C# 应用C++/CLI 包装器 DLL原生 C++ DLL

图7:在 C# 应用与原生 C++ DLL 之间插入一层 C++/CLI 包装器 DLL 的架构。

让 C# 只看到 符合 .NET 风格的 API,把下面这些封闭在 C++/CLI 一侧:

  • 字符串转换
  • 数组或向量的转换
  • 异常的转换
  • 所有权的梳理
  • 错误码的解读
  • 必要时还包括线程边界或回调的吸收

重要的是 不要让 C++/CLI 项目本身长得太大。 它的职责始终只是“翻译”和“整形”。 一旦开始把业务逻辑也塞进去,这一层就会反客为主。

封闭在 C++/CLI 包装器里的工作表示把字符串和数组的转换、异常与错误码的转换、所有权的梳理这些翻译和整形的工作封闭在 C++/CLI 一侧,而不放入业务逻辑的职责界线。C++/CLI 包装器的职责字符串、数组的转换异常、错误码的转换所有权的梳理不要放入业务逻辑

图8:包装器的职责限定在“翻译”和“整形”,不让它长得太大很重要。

5. C++/CLI 能让哪些事情变轻松

5.1. C++ 的类型可以当作 C++ 类型来处理

这一点相当重要。 在 C++/CLI 一侧可以包含原生头文件,原样使用 C++ 的类型。

也就是说,不必在 C# 一侧勉强“重现 C++ 的世界”。 无论 std::wstring 还是 std::vector,都可以先当成 C++ 类型接住,再按需要的形式传给 .NET 一侧。

先接住 C++ 类型再传给 .NET 的流程表示包含原生头文件后把 wstring 和 vector 当作 C++ 类型原样接住,转换成需要的形式再传给 .NET 一侧,从而不必在 C# 一侧重现 C++ 的世界。原生的 wstring 或 vector在 C++/CLI 一侧当作 C++ 类型接住转换成需要的形式传给 .NET 一侧不在 C# 里重现 C++ 的世界

图9:C++ 的类型先当作 C++ 类型接住,转换之后再传给 .NET 就好。

5.2. 可以把 API 整形成面向 .NET 的形式

可以用 C# 一侧眼熟的形式提供 API:

  • string
  • byte[]
  • List<T>
  • IDisposable
  • 异常

这个差别看着不起眼,却会大幅改变使用方的负担。 尤其在团队开发中,不了解原生细节的成员也能上手,这一点很见效。

5.3. 更容易梳理异常与错误的职责

如果原生一侧混杂着异常和错误码,让 C# 一侧原样接收会很难处理。 可以在 C++/CLI 一侧统一做这些事:

  • 把异常转换成 .NET 的异常
  • 把错误码转换成有意义的异常或结果类型
  • 补上日志所需的上下文

在边界处先翻译成“有意义的失败”,调用方就会清爽很多。

在边界处翻译异常与错误码表示把原生一侧混杂的异常与错误码在 C++/CLI 一侧统一承接,异常转换成 .NET 异常,错误码转换成有意义的异常或结果类型,并补上日志所需的上下文。原生的异常与错误码在 C++/CLI 一侧统一承接异常转换成 .NET 的异常错误码转换成有意义的形式补上日志所需的上下文

图10:在边界处先翻译成“有意义的失败”,C# 调用方就会清爽。

5.4. 可以把 ABI 的波动挡在 C# 之外

C++ 的类和方法,并不像 C 函数那样有简单的 ABI。 一旦 C# 开始直接了解这些内情,导出函数和封送处理的种种情况就会浮到表面。

只要插入 C++/CLI 包装器,就能做到 把 C++ 的情况封闭在 C++ 一侧,只让 C# 看到稳定的一面。 这种分离在更新库的时候同样见效。

用包装器挡住 ABI 波动的结构表示由于 C++ 的类和方法不像 C 函数那样有简单的 ABI,把这些情况封闭在 C++ 一侧、只让 C# 看到稳定的一面,这种分离在更新库时同样见效。C++ 类的 ABI 并不简单C++ 的情况封闭在 C++ 一侧只让 C# 看到稳定的一面更新库时同样见效的分离

图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,这种情况很常见。

P/Invoke 方案实质变成 C 兼容 API 设计的过程表示本打算用 P/Invoke 直接调用,却要准备 C 桥接函数、在 C# 一侧写 SafeHandle 和 StructLayout,再加上可变长度、释放、回调等议题,实质上已经开始设计 C 兼容 API 的过程。打算用 P/Invoke 直接调用另外准备 C 桥接函数写 SafeHandle 和 StructLayout可变长度、释放、回调的议题实质上开始设计 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”一节。

析构函数与终结器的分工表示从 C# 的 using 或 Dispose 会调用析构函数,经由终结器释放原生资源;忘记 Dispose 时由 GC 调用终结器兜底,并用 SuppressFinalize 防止重复释放。C# 的 using 或 Dispose析构函数(相当于 Dispose)忘记调用 DisposeGC 回收时的终结器调用终结器delete 原生资源用 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。 按这样区分,大体都能顺利推进。

不适合选择 C++/CLI 的场合表示已经有干净的 C API、需要跨平台、边界面小且类型简单、对 AOT 和分发限制看得严格这四种场合下,不选 C++/CLI 包装器更好。已经有 C API不选 C++/CLI需要跨平台边界小、类型也简单AOT、分发限制严格在哪一层做转换才自然

图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. 参考资料

共享相同标签的最新文章。可以围绕相近的主题进一步加深理解。

与本文相近的主题页面。以本文为起点,可以进一步了解相关服务和其他文章。

本文与以下服务页面相关联,欢迎从最接近的入口查看。

常见问题

汇总了咨询这一主题时常见的问题。

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 软件开发、技术咨询与故障排查为中心,擅长难以复现的故障调查,以及既有资产仍在运行的项目。

返回博客列表