抱歉,您的瀏覽器無法訪問本站
本頁面需要瀏覽器支持(啟用)JavaScript
了解詳情 >

C/C++ 是電腦世界的共同語言。而在 C# 中,串接 C/C++ 的方式有很多種。前次記錄了一種做法:透過 DllImport 讓 C# 呼叫原生函式,這次要來記錄另一種方式。

先回顧一下,當提到 .NET,通常會先想到 C#,其次可能是 VB.NET 或 C++/CLI ( F#表示:當我塑膠?) ,而 C++/CLI 的特點在於,它能同時撰寫 .NET 的 managed 程式,也能直接整合原生 unmanaged 的 C/C++ 程式碼。

本文將記錄如何將 C/C++ 原始碼放入 C++/CLI 專案中,並將其製作成可供 C# 直接引用的類別庫。

本次的開發環境,如下

  • IDE : Visual Studio 2022
  • .NET版本 : . NET 8.0

事前準備與方案架構

在開始前,除了需要安裝 Visual Studio、C# 和 .NET 之外,

因為這次會用到 C++/CLI 的部分,所以還需要相關的開發工具。

如果 Visual Studio 安裝時沒有勾選相關項目,

可以開啟 Visual Studio Installer 來進行安裝。

理論上最少要安裝這些,

  • MSVC v143 - VS 2022 C++ x64/x86 建置工具
  • C++ 核心功能
  • C++/CLI support for v143 build tools

因為我測試過一些功能,

安裝的項目也比較混亂,

所以並不是這麼確定 ,

(哪天有機會確定後再更新)

以下附上我的安裝清單與畫面供參考,

如有需要可依照個人需求自行調整環境。

安裝項目中個別元件頁籤,勾選的項目


本次的預計的方案架構如下,

1
2
3
4
5
6
7
Solution: CsUseCppCliSample
├─ CppCliLibrary # C++/CLI 類別庫,內含 unmanaged 與 managed 程式碼
│ ├─ NativeDll.h
│ ├─ NativeDll.cpp # 純原生 C++ 實作(unmanaged)
│ └─ NativeWrapper.h
│ └─ NativeWrapper.cpp # C++/CLI 包裝層(managed),對外提供使用
└─ CSharpTestApp # C# 專案,測試用

整體分成兩個主要部分,分別是,

  • CppCliLibrary:C++/CLI 類別庫
  • CSharpTestApp: C# 專案

雖然最後是由 C# 來呈現執行結果,

不過 C++/CLI 是一種可以同時撰寫「原生 C++」與「.NET 託管程式碼」的 C++ 延伸語法,

它可以:

  • 呼叫原生 C++ 程式碼
  • 同時產生 .NET 類別庫

因此, C++/CLI 類別庫編譯出來的是 ****.NET 類別庫 DLL,

也就是說,只要是 .NET 平台上的語言 ( C#、VB.NET… 等),

理論上都可以直接引用該 DLL,而不限於 C#。

接下來,將從建立專案開始,

並一步步完成整個流程。

建立 C++/CLI 類別庫專案

首先,從建立 C++/CLI 類別庫專案開始。

Visual Studio 建立好方案後,

在方案上按右鍵 → 加入 → 新增專案。

從方案上右鍵點選加入後,點選新增專案


語言篩選部分可以選擇 C++,

接著找到 CLR 類別庫的項目,

次範例將執行於 .NET 架構上,

因此選擇 CLR 類別庫(.NET),

如果目標平台是 .NET Framework 的話,

則要選擇 CLR 類別庫(.NET Framework)。

建立C++/CLI專案


設定專案名稱後即可建立專案,

建立完成後,畫面會像下圖這樣,

已經有一些必要與預設的檔案。

C++/CLI專案建立完成


接下來,建立新的標頭檔(.h),

在「標頭檔」資料夾上按右鍵 → 加入 → 新增項目。

C++/CLI專案新增項目


接著選擇「標頭檔 (.h)」,輸入名稱後按下「新增」,

這邊總共新增兩個 .h 檔 NativeDll.h 和 NativeWrapper.h

新增標頭檔畫面


同樣的步驟,在「來源檔案」資料夾中,

建立 C++ 檔 (.cpp)。

C++/CLI專案新增項目


這邊建立與兩個 .h 對應的兩個 cpp 檔,

NativeDll.cpp 和 NativeWrapper.cpp。

新增C++檔畫面


上述步驟完成後,
整個專案的架構與狀況如下圖所示,

建立完標頭檔與C++檔專案結構狀況


到這邊為止,專案的初步建立就完成了。

C++ 原生程式碼範例

再來是 C++ 原生程式碼 .h 與 .cpp 的程式內容。

下面提供的程式碼可以直接貼進專案中使用。

這個範例示範了一些基本的 C++ 方法,

各功能方法的細節這裡就不特別說明,

NativeDll.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#pragma once

// 基本型別
extern "C" __declspec(dllexport) int Add(int a, int b);
extern "C" __declspec(dllexport) double Divide(int a, int b);

// struct
typedef struct {
int x;
int y;
} Point;
extern "C" __declspec(dllexport) int MulPoint(Point p);

// callback
typedef void (*Callback)(int result);
extern "C" __declspec(dllexport) void Sub(int a, int b, Callback onResult);

// 陣列
extern "C" __declspec(dllexport) void SumArray(int* arr, int len, int* sum);

// enum
typedef enum {
OK = 0,
ERROR = 1
} Status;
extern "C" __declspec(dllexport) Status CheckValue(int val);

NativeDll.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include "pch.h"

#include "NativeDll.h"
#include <numeric>

int Add(int a, int b) {
return a + b;
}

double Divide(int a, int b) {
return (b == 0) ? 0.0 : static_cast<double>(a) / b;
}

int MulPoint(Point p) {
return p.x * p.y;
}

void Sub(int a, int b, Callback onResult) {
if (onResult) onResult(a - b);
}

void SumArray(int* arr, int len, int* sum) {
if (arr && sum) {
*sum = 0;
for (int i = 0; i < len; ++i) *sum += arr[i];
}
}

Status CheckValue(int val) {
return (val >= 0) ? OK : ERROR;
}

補充說明,在 NativeDll.h 中,

可以看到使用了 extern "C"__declspec(dllexport)

這是因為本範例的 C++ 程式碼,同時也可以提供給 P/Invoke 機制或不同平台使用的情境。

(有興趣的話,可前往 C# 與原生 C/C++ DLL:從建立到呼叫的實作筆記)

如果實務專案是:

  • 純 C++ 使用
  • 或僅提供給 C++/CLI 專案呼叫

其實不需要撰寫這些宣告,直接使用一般的 C++ 函式即可。

建立呼叫 C++ 的 C++/CLI 程式碼

接下來實作 C++/CLI 包裝層,

這一層的角色很單純,

將 Native C++ 的函式轉換成 .NET 可直接使用的類別與方法。

  • 對外,它是一個標準的 .NET 類別庫。
  • 對內,它負責呼叫前面實作的 Native C++ 函式。

NativeWrapper.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#pragma once

using namespace System;

// 設定 .NET namespace,提供給外部專案引用
namespace NativeBridge
{
// 對應 Native 的 Status enum
// 使用 enum class 建立 .NET 可用的強型別列舉
public enum class CheckStatus
{
OK = 0,
ERROR = 1
};

// 對應 Native 的 callback
// 在 .NET 中使用 delegate 表示函式指標
public delegate void ManagedCallback(int result);

// 使用 ref class 建立 .NET 類別
// 此類別將作為 Native C++ 的包裝層
public ref class NativeWrapper
{
public:

// 對應 Native 的各方法
static int Add(int a, int b);
static double Divide(int a, int b);
static int MulPoint(int x, int y);
static int SumArray(array<int>^ arr);
static CheckStatus CheckValue(int val);
static void Sub(int a, int b, ManagedCallback^ cb);
};
}

NativeWrapper.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
#include "pch.h"

#include "NativeWrapper.h"
#include "NativeDll.h"

using namespace System;
using namespace System::Runtime::InteropServices;
using namespace NativeBridge;

// ------------------------------------
// 基本型別:直接轉呼叫
// ------------------------------------

int NativeWrapper::Add(int a, int b)
{
// 直接呼叫 Native C++ 函式
return ::Add(a, b);
}

double NativeWrapper::Divide(int a, int b)
{
return ::Divide(a, b);
}

// ------------------------------------
// struct 轉換
// ------------------------------------

int NativeWrapper::MulPoint(int x, int y)
{
// 建立 Native 的 Point 結構
Point p{ x, y };

// 傳入 Native 函式
return ::MulPoint(p);
}

// ------------------------------------
// .NET 陣列 → Native 指標
// ------------------------------------

int NativeWrapper::SumArray(array<int>^ arr)
{
if (arr == nullptr || arr->Length == 0)
return 0;

// pin_ptr 會暫時固定記憶體位置
// 避免 .NET GC 在呼叫期間移動陣列
pin_ptr<int> p = &arr[0];

int sum = 0;

// 將 .NET 陣列轉成原生指標後呼叫 Native
::SumArray(p, arr->Length, &sum);

return sum;
}

// ------------------------------------
// enum 轉換
// ------------------------------------

NativeBridge::CheckStatus NativeWrapper::CheckValue(int val)
{
// 先取得 Native enum
::Status nativeStatus = ::CheckValue(val);

// 轉換成 .NET enum class
return static_cast<NativeBridge::CheckStatus>(nativeStatus);
}

// ------------------------------------
// delegate → 函式指標
// ------------------------------------

void NativeWrapper::Sub(int a, int b, ManagedCallback^ cb)
{
if (cb == nullptr)
return;

// 將 .NET delegate 轉為函式指標
IntPtr ptr = Marshal::GetFunctionPointerForDelegate(cb);

Callback nativeCb = static_cast<Callback>(ptr.ToPointer());

// 呼叫 Native 函式並傳入 callback
::Sub(a, b, nativeCb);
}

從以上程式碼可以看得出來,

C++/CLI 是一種介於 C++ 與 .NET 之間的混合寫法。


在檔案結構上,仍然保留了 C++ 的風格,

例如 .h 與 .cpp 分離,將宣告與實作拆成兩個檔案。

不過在 .NET 架構下,其實也可以將定義與實作寫在同一個檔案中,

就像 C# 的 .cs 檔一樣。

實務上是否拆分,可以依專案規模與習慣自行決定。


至於程式內容的寫法,

例如 namespacepublicprivateclass 等語法,

其實和 C# 非常接近。

不過因為本質上還是 C++,

會出現一些 C++/CLI 才有的特殊符號。

例如,::^%


在 C# 中,大多數成員存取只會看到使用 .

而在 C++/CLI 中,則會混用 C++ 與 .NET 的語法。

雖然這些符號都有規則可循,

但實際撰寫時確實比較容易讓人混淆,

特別是在 Native 與 .NET 型別交錯出現的時候。

剛開始接觸時,可能會有些燒腦。

以前曾因為弄錯符號編譯不過搞半天,

還好現在有 AI 可以問


另外,在 class NativeWrapper 前面可以看到 ref 關鍵字。

這個關鍵字主要用來區分與一般 C++ 類別的差異。

也就是說:

  • 僅使用 class → 代表一般 C++ 類別(Unmanaged)
  • 使用 ref class → 代表 .NET 類別(Managed)

所謂 Managed 與 Unmanaged 的差異如下,

  • Managed:由 .NET 執行環境(CLR)負責執行與記憶體管理。
  • Unmanaged:傳統 C/C++ 程式碼,記憶體需自行管理,

在 .NET 中,像 C#、VB.NET ,撰寫出的程式碼就屬於 Managed,

而 C++/CLI 因為同時支援 Managed 與 Unmanaged 程式碼,

因此在類別宣告時,需要透過 ref 關鍵字來明確區分。

編譯 C++/CLI 類別庫

完成 C++ 與 C++/CLI 程式碼撰寫後,接下來進行專案編譯。

一般情況下,若沒有特別需求,

可以像一般 C# 類別庫一樣直接建置(Build)即可。


若需要指定目標 Framework 或 Windows 版本,

則可以依下列步驟進行設定。


首先,在專案上按右鍵 → 選擇「屬性」。

C++/CLI專案右鍵選單,選擇屬性項目


進入 C++/CLI 專案的屬性設定視窗後,

點選「組態屬性」→「進階」。

在此設定:

  • .NET 目標 Framework:選擇 .NET 8.0
  • .NET 目標 Windows 版本:選擇 10.0.19041.0

上述設定為本範例使用的環境版本,

實際專案可依開發環境與需求自行調整。

C++/CLI設定.NET目標


C# 串接 C++/CLI 執行結果

最後進入實際執行的部分。

這裡建立一個 C# 主控台專案。

建立C#主控台專案後,整體的方案架構


C# 主控台專案相依性加入前面的 C++/CLI 專案。

C#專案加入C++/CLI專案參考


完成後,整個方案的架構如下圖所示。

C#專案加入C++/CLI專案參考結果


再來,在 Program.cs 中撰寫一些簡單的測試程式,

實際呼叫前面封裝的 NativeWrapper 中的方法。


Program.cs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
using NativeBridge;

class Program
{
static void Main()
{
Console.WriteLine($"Add: 3+5 = {NativeWrapper.Add(3, 5)}");
Console.WriteLine($"Divide: 10/2 = {NativeWrapper.Divide(10, 2)}");

Console.WriteLine($"MulPoint: 4*5 = {NativeWrapper.MulPoint(4, 5)}");

NativeWrapper.Sub(10, 3, res => Console.WriteLine($"Sub callback: 10-3 = {res}"));

int[] arr = { 1, 2, 3, 4, 5 };
Console.WriteLine($"SumArray: 1+2+3+4+5 = {NativeWrapper.SumArray(arr)}");

Console.WriteLine($"CheckValue(5) = {NativeWrapper.CheckValue(5)}");
Console.WriteLine($"CheckValue(-1) = {NativeWrapper.CheckValue(-1)}");
Console.ReadKey();
}
}

編譯前,把編譯目標設定與類別庫相同,

C#專案.NET目標設定


最後執行整個程式,

便可看到執行的結果如下圖,

C#專案到呼叫C++程式的執行結果


上述步驟完成後,就能順利看到執行結果,

到這邊為止,應該已能掌握從建立 C++/CLI 到在 C# 中使用的基本流程。


補充:C++/CLI 進入中斷點

程式撰寫過程中,難免需要 debug。

而 C++/CLI 跟使用 C# 一樣,

直接在需要的地方下中斷即可。


例如在 C++/CLI 轉接層程式下中斷,

C++/CLI程式碼中斷點與變數內容顯示


或專案內 C++ 原生程式中下中斷,

C++程式碼中斷點與變數內容顯示


沒有特殊狀況,都可以下中斷點,

並且可以看到變數的內容,

不需要額外開混合模式。

總結

以上就是使用 C++/CLI 串接 C++ 原生函式的整體流程。


整體來說,相較於直接使用原生 C++ DLL 搭配 DllImport 進行呼叫,

透過 C++/CLI 建立一層橋接,

流程步驟個人感覺不算複雜。

在實務上有幾個地方需要特別注意:

  • 一開始需要先安裝並啟用 C++/CLI 相關元件。
  • C++/CLI 的語法同時包含 C++ 與 .NET 的寫法,但會出現一些特殊符號(例如 ^gcnew),初學時可能會覺得有點不習慣。
  • 雖然檔案同樣分為 .h.cpp,但內容可能是原生 C++,也可能是 C++/CLI 程式碼。

另外,原生 C++ 的寫法,

有時候在 C++/CLI 做橋接時會變得比較麻煩。

有些在 C++ 裡很正常的設計,

在 .NET 環境下不一定適合,

硬是照原本方式實作,

反而會讓程式變得很複雜。

因為 C++/CLI 在 managed 這一側還是走 .NET 的機制,

所以如果發現橋接寫起來太繞,

其實可以換個做法,

用比較符合 .NET 習慣的方式來設計,

不一定要完全照搬原本的 C++ 寫法。

自言自語543

遙想當年稍微碰觸過 MFC 專案後,

對於使用 .h 和 .cpp 檔撰寫視窗程式,

有了初步的認識 。

(是個超麻煩的東西)


而過了幾年後開始接觸 .net framework,

其中就碰到 C++/CLI…

又或是當時稱為 Visual C++ .NET 的專案,

看了一頭霧水後,

決心買一本書來好好的鑽研,

把書中的範例手動 key 到電腦中,

但是卻怎麼樣也編譯不起來…

後面才知道書中的語法都是舊的…


最後搞懂 .h 和 .cpp 在 C++/CLI 的狀況後,

反而是看著網路上 C# 的範例,

自行轉換到 C++/CLI 中,

這樣一步一腳印慢慢地了解並熟悉 .net framework。


記得以前的 Visual Studio 的範本,

有 C#、VB.NET 和 C++.NET 這三本柱

不過某個版本開始把 C++/CLI 給移除了,

現在不僅要自己去安裝,

並且如果要建立 C++/CLI 的 Winform 專案,

似乎也要完全從零開始建立。


至於在 C# 中要呼叫 C++,

不管是使用 DllImport,還是透過 C++/CLI 做橋接,

其實都算是解法之一。

只是 C++/CLI 微軟已沒有特別的去推進,

或許未來還會有更好的整合方式,

等有機會再來研究看看。

評論




本站使用 Volantis 作為主題,總訪問量為