要在 C# 中使用原生 DLL,C++/CLI 包裝是有力選項的理由 - 與 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 才發揮價值的情境不同

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++ 類別為核心設計,從 C# 真正想看到的是方法,但 P/Invoke 能直接對上的只是 DLL 的匯出函式。 也就是說,最後某處仍得有一層 把 C++ 壓成 C 形式函式 的轉換。

走到這裡,其實就已經在「寫包裝」了。 既然如此,與其在 C# 端長出一堆 IntPtr 與釋放函式,不如 把包裝往 C++ 那一側擺,比較自然。

3.2. 所有權與生命週期難以看清時

C++ 中常出現:

  • 是呼叫端釋放嗎
  • 回傳的指標是借用嗎
  • 參數是 const& 還是所有權轉移
  • 內部有快取、對生命週期有假設

這些情境用 C# 的 IntPtr 表達,初期能動,但之後回頭看就會很吃力。 「這個指標,到底誰什麼時候刪?」一旦出現,邊界就會模糊起來。

3.3. std::wstringstd::vector、回呼、例外出現時

到這裡,P/Invoke 就進入「寫得出來、但寫得不舒服」的區段。

  • 想把 std::wstring 在 C# 直接表達
  • 想回傳 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,並把:

  • 字串轉換
  • 陣列 / vector 轉換
  • 例外轉換
  • 所有權整理
  • 錯誤碼的語義
  • 必要時的執行緒界面或回呼吸收

全封在 C++/CLI 這一層內。

重點是 別把 C++/CLI 專案做太大。 它的角色就是「翻譯」與「整形」。 一旦開始寫業務邏輯,那層就變主角了。

5. 用 C++/CLI 能輕鬆什麼

5.1. C++ 型別可以直接當 C++ 用

這點很大。 C++/CLI 端可以直接 #include 原生標頭,原封不動用 C++ 的型別。

也就是說,C# 不必硬「重現 C++ 的世界」。 std::wstringstd::vector 先當 C++ 型別接住,再按需要改裝送到 .NET 就好。

5.2. 可以把 API 整形成 .NET 風

給 C# 看到的是熟悉的:

  • string
  • byte[]
  • List<T>
  • IDisposable
  • 例外

這些差異看似細微,但會明顯改變使用者端的負擔。 尤其在團隊開發中,不熟原生側的成員也比較能放心使用。

5.3. 例外與錯誤的責任比較好整理

原生側混著例外與錯誤碼時,C# 直接接會很難處理。 在 C++/CLI 那一層統一整理:

  • 把例外轉成 .NET 例外
  • 把錯誤碼轉成有意義的例外或結果型別
  • 補上日誌需要的 context

就能讓呼叫端乾淨許多。 在邊界上一次翻譯成「有意義的失敗」,後面會輕鬆非常多。

5.4. ABI 的動盪可以對 C# 隱藏

C++ 的類別、方法 ABI 並不像 C 函式那樣簡單。 一旦 C# 要直接接觸,就會碰上匯出函式與 marshalling 的各種細節。

插一層 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 端接住原生細節,把 API 整形給 C#:

// 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 與 marshalling 屬性硬撐,做一層薄的 C++/CLI 包裝往往更能保持邊界乾淨

尤其在下列情境下 C++/CLI 仍是實際的選項:

  • 類別型 API
  • 所有權假設
  • std::wstringstd::vector
  • 例外轉換
  • 回呼
  • 階段式遷移

做這層包裝雖然沒什麼酷炫感, 但「在邊界上把東西整好」這件事,對後續的維護有長期的正面影響。 想同時活用 Windows 既有資產與 .NET 時,C++/CLI 依然很有用。

9. 參考資料

共用相同標籤的最新文章。能以相近的主題延伸理解。

與本文相近的主題頁面。以本文為起點,可進一步連到相關服務與其他文章。

本文連結到以下服務頁面,歡迎從最接近的入口查看。

作者檔案

本文作者的個人檔案頁面。

Go Komura

小村軟體有限公司 代表

以 Windows 軟體開發、技術諮詢與故障調查為中心,在難以重現的故障調查與既有資產仍在運作的專案上具有優勢。

回到部落格一覽