从 C# 调用原生 DLL:C++/CLI 包装器 vs P/Invoke

· · C++/CLI, C#, Windows 开发, 原生集成

想从 C# 中使用 Windows 的现有资产或既有 DLL,这样的需求相当常见。 如果对方是像 Win32 API 那样直接的 C 接口,P/Invoke 就足够了。

不过,在实务中出现的往往是更「有个性」的 DLL。 有 C++ 类,有一套所有权的规矩,也会抛异常,std::wstringstd::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)

目录

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

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# 一侧可以直接了当地写出 SafeHandleStructLayout

只要整理到这种程度,在 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::wstringstd::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 包装器的架构

架构本身很简单。

面向 .NET 的 API直接处理原生头文件与类型C# 应用C++/CLI 包装器 DLL原生 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:

  • string
  • byte[]
  • 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# 能看到的,只有 stringList<int>IDisposableIntPtr、释放函数、原生字符串缓冲区之类的问题完全看不到。 这一点意义重大。

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

返回博客列表