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

C/C++ 是電腦世界的共同語言。它速度快,又能直接與系統溝通,因此許多語言都能透過它來串接底層功能。有時候,也會將關鍵的程式邏輯用 C/C++ 寫好,只需實作一次,就能讓其他語言重複使用。

在 C# 中,串接 C/C++ 的方式有很多種。本次想記錄其中一種做法:透過 DllImport 讓 C# 呼叫原生函式。從使用 Visual Studio 編譯 C/C++ 程式碼成 DLL 開始,一步步到實際呼叫為止。如果手上已經有現成的 DLL 和對應的 .h 檔,也能藉此了解整個過程。

本次的開發環境,如下

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

事前準備與方案架構

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

因為這次會用到 C++ 來建立原生 DLL,所以還需要安裝 C++ 的開發工具。

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

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

理論上最少要安裝這些,

  • 使用 C++ 的桌面開發
  • MSVC v143 - VS 2022 C++ x64/x86 建置工具
  • C++ 核心功能

因為個人測試過一些功能,

安裝的項目也比較混亂,

所以並不是這麼確定 ,

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

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

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

安裝項目中工作負載頁籤,勾選使用C++的桌面開發


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


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

1
2
3
4
5
6
7
Solution: CsUseCppNativeSample
├─ CppNativeDll # C++ 專案,撰寫原生程式碼,編譯成 DLL
│ ├─ NativeDll.h
│ └─ NativeDll.cpp
├─ CSharpLibrary # C# 類別庫,透過 DllImport 呼叫 C++ DLL
│ └─ CsWrapper.cs
└─ CSharpTestApp # C# 專案,測試用

整體分成三個主要部分,有下至上分別是,

  • CppNativeDll:C++ 專案
  • CSharpLibrary:C# 類別庫
  • CSharpTestApp: C# 專案

當然,如果想直接在 C# 專案去使用 C++ 專案,也是可行的,

架構可以依需求自行調整。

不過個人習慣在實務上這樣分三層,方便維護與測試,也更容易擴充功能。

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

並一步步完成整個流程。

建立 C++ DLL 專案與程式

建立C++ Native專案

首先,從建立 C++ Native 專案 開始。

Visual Studio 建立好方案後,

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

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


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

個人習慣從 空白專案 開始,所以這邊選擇「空白專案」,

建立C++空白專案


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

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

C++空白專案建立完成


接下來,建立一個空白的標頭檔(.h),

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

C++專案新增空白檔案


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

新增標頭檔畫面


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

建立一個 C++ 原始檔 (.cpp)。

新增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
27
#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
#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;
}

整份程式裡,最需要注意的是 .h 檔中的兩個部分:

  • extern "C":用來告訴編譯器這些函式使用 C 語言的命名方式,讓之後 C# 在使用 DllImport 時能正確對應函式名稱。
  • __declspec(dllexport):用來將這些函式 導出到 DLL,使外部應用程式(例如 C#)可以呼叫。

這兩個關鍵字是讓 C# 能順利呼叫 C++ 程式的必要設定。

到此為 C++ 程式碼部分的基本說明。


下面為額外的補充,

如果覺得每個函式都重複寫這兩個關鍵字比較繁瑣,

可以使用 #ifdef 或 macro 統一管理。例如:

1
2
3
4
5
6
7
8
#ifdef _WIN32
#define DLL_EXPORT extern "C" __declspec(dllexport)
#else
#define DLL_EXPORT extern "C"
#endif

DLL_EXPORT int Add(int a, int b);
DLL_EXPORT double Divide(int a, int b);

那如果希望程式能跨平台共用,則可以 導出符號與呼叫約定一起包起來,例如,

1
2
3
4
5
6
7
8
9
10
#ifdef _WIN32
#define DLL_EXPORT extern "C" __declspec(dllexport)
#define CALL_CONV __stdcall
#else
#define DLL_EXPORT extern "C"
#define CALL_CONV
#endif

DLL_EXPORT int CALL_CONV Add(int a, int b);
DLL_EXPORT double CALL_CONV Divide(int a, int b);

這樣就能統一管理函式導出與呼叫方式,

不管在 Windows 或其他平台都能維持一致,程式碼更乾淨,也更容易維護。

編譯 C++ DLL

C++ 程式碼撰寫完成後,接下來要編譯出 Native DLL。

首先,在專案上按右鍵 → 屬性。

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


會出現一個在 C++ 專案中常見的屬性設定視窗,

因為前面建立專案時選擇了空白專案,

所以需要做一些設定調整。

找到組態類型後,可以看到目前選擇的是「應用程式(.exe)」,

將它改為「動態程式庫(.dll)」,然後按下「套用」即可。

將C++專案組態類型設定為動態程式庫


接下來,只要對著專案按下「建置」,

就可以在對應的組態資料夾中看到編譯出的 DLL,如下圖所示:

C++專案編譯動態函示庫結果


接下來,就輪到 C# 的部分了。

在 C# 中呼叫 C++ 函式庫

建立呼叫 C++ 的 C# 程式碼

接下來,我們要撰寫讓 C# 能呼叫 C++ DLL 的程式。

這裡建立了一個 C# 類別庫,

並在專案中新增一個名為 CsWrapper.cs 的檔案。

如前面所說,

專案架構可以依需求自行調整,

不一定要建立類別庫。

此時整個方案的架構如下圖所示,

呼叫C++的C#類別庫範例架構


再來是整個 C# 程式碼的部分,


CsWrapper.cs

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
public static class CsWrapper
{
// struct 對應
[StructLayout(LayoutKind.Sequential)]
public struct Point
{
public int x;
public int y;
}

// callback delegate
public delegate void ResultCallback(int result);

// enum 對應
public enum Status
{
OK = 0,
ERROR = 1
}

// DllImport 封裝
[DllImport(
"CppNativeDll.dll",
EntryPoint = "Add",
CallingConvention = CallingConvention.Cdecl)]
private static extern int Add_Internal(int a, int b);

[DllImport("CppNativeDll.dll", CallingConvention = CallingConvention.Cdecl)]
private static extern double Divide(int a, int b);

[DllImport("CppNativeDll.dll", CallingConvention = CallingConvention.Cdecl)]
private static extern int MulPoint(Point p);

[DllImport("CppNativeDll.dll", CallingConvention = CallingConvention.Cdecl)]
private static extern void Sub(int a, int b, ResultCallback onResult);

[DllImport("CppNativeDll.dll", CallingConvention = CallingConvention.Cdecl)]
private static extern void SumArray([In] int[] arr, int len, out int sum);

[DllImport("CppNativeDll.dll", CallingConvention = CallingConvention.Cdecl)]
private static extern Status CheckValue(int val);

// 封裝成靜態方法給外部使用
public static int AddNumbers(int a, int b)
=> Add_Internal(a, b);

public static double DivideNumbers(int a, int b)
=> Divide(a, b);

public static int MultiplyPoint(int x, int y)
=> MulPoint(new Point { x = x, y = y });

public static void Subtract(int a, int b, ResultCallback callback)
=> Sub(a, b, callback);

public static int SumArrayElements(int[] arr)
{
SumArray(arr, arr.Length, out int sum);
return sum;
}

public static Status Check(int val)
=> CheckValue(val);
}

大致上可以分成兩大部分:

  1. 串接 C++ 的結構與方法

    包含 structenumdelegate 以及使用 DllImport 呼叫 C++ DLL 的原生函式。

  2. 對外封裝給使用者的方法

    將原生函式封裝成靜態方法,提供簡單易用的介面給其他 C# 專案呼叫。


關於 DllImport Attribute 的部分,

以下以 Add 與 Divide 為例做個說明,

1
2
3
4
5
6
7
8
9
10
11
[DllImport(
"CppNativeDll.dll", // C++ DLL 檔案名稱,如果 DLL 放在同層資料夾,則不需要寫路徑
EntryPoint = "Add", // 指定 C++ DLL 中實際的函式名稱
CallingConvention = CallingConvention.Cdecl // 呼叫約定,必須與 C++ 函式一致
)]
private static extern int Add_Internal(int a, int b);

// 因 C# 這邊定義的方法名與 C++ 相同,所以可省略 EntryPoint 的設定
[DllImport("CppNativeDll.dll", CallingConvention = CallingConvention.Cdecl)]
private static extern double Divide(int a, int b);


詳細的設定說明可以自行上網查閱,

這裡要特別注意的是 C++ 原生 DLL 的檔案名稱與位置。


在程式執行時,DLL 必須放在所設定的路徑,

否則程式可能會發生 Crash。

至於要放在哪裡,則取決於你的專案策略。


例如,有些廠商雖然提供 .NET DLL,

但實際上原生 C++ DLL 是隨驅動程式一起安裝,

透過安裝程式放置到指定位置的。


另外,如果 C++ 原始碼弱較為複雜、

或直接呼叫時需要處理許多轉換 (例如結構體或字串),

那麼可以在這一層 C# 類別中,

做適當的封裝與轉換,

讓外層程式在使用時能保持乾淨且一致的介面。

C# 串接 C++ 執行結果

接下來進入實際執行的部分。

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

並將前面建立的 C# 類別庫 加入至專案參考中。

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

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


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

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

Program.cs

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

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

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

CsWrapper.Subtract(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 = {CsWrapper.SumArrayElements(arr)}");

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

撰寫完成後,可以先執行一次建置。

由於在前面的 C# 類別庫中,

DllImport 是設定 C++ DLL 與執行檔位於同一層目錄,

因此建置完成後,需將編譯出來的 C++ DLL 手動複製到輸出資料夾中,

如下圖所示,

C#執行檔專案編譯結果,並需要複製C++ DLL到同個目錄下


最後執行整個程式,

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

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


上述步驟做完,到能看到結果輸出,

到這邊便能掌握一些基礎的從建立 C++ DLL 到 C# 的使用的其中一種方法。


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

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


隨著實際開發與使用的過程中,

或許產生一些想法,

接下來也會分享幾個可以參考的方向。

額外補充

補充1:C++ 如何進入中斷點

在開發過程中,難免需要 debug。

即使像本次範例一樣,

將 C++ 與 C# 專案放在同一個方案中,

可能會發現,即便在 C++ 程式碼中插入了中斷點,

在 Debug 模式 執行 C# 專案時,中斷點仍然不會被觸發。

那麼,要如何對 C++ 程式碼進行除錯呢?


其實只要稍作設定,

就能讓程式在執行時進入 C++ 的中斷點。


首先,在「執行檔專案」上按右鍵 → 屬性,
在選單中找到 「偵錯」,
接著點選 「開啟 debug 啟動設定檔 UI」。

執行檔專案屬性中,偵錯項目設定


在啟動設定檔中,

找到 「啟用機器碼偵錯」的選項,
將它勾選後關閉視窗,這樣設定就完成了。

啟動設定檔設定啟用機器碼偵錯


接著回到 C++ 程式碼中設定中斷點,

再以 Debug 模式 執行程式,

此時中斷點就會如預期生效,

也能檢視 C++ 端的變數內容與執行流程。

啟用機器碼偵錯後,進入C++程式碼中斷點與變數內容顯示


補充2:利用 Costura.Fody 將 C++ DLL 做打包

前面提到,C# 專案在呼叫 C++ DLL 時,

通常需要把對應的 C++ DLL 放在指定的目錄下,

否則在執行階段會找不到對應的函式庫。


當遇到一些狀況時,

像是不希望使用者看到這個 C++ DLL,

或者希望最終只發佈單一的 .NET DLL 檔案,

這時就會稍微麻煩一些。


這時候,可以考慮使用 Costura.Fody 套件,

該套件會在編譯時,

可以將所設定的 C++ DLL 檔案包起來。

詳細操作如下,

在專案中開啟 NuGet 套件管理器,

搜尋並安裝 Costura.Fody 套件。

Nuget 搜尋 Costura.Fody 套件結果


著從專案結構來看,

由於建置環境設定為 x64,

需要建立一個名為 costura-win-x64 的資料夾,

(若使用舊版本套件,則為 Costura64 )

並將 C++ DLL 複製到該資料夾中。

設定該 DLL 的屬性,將「建置動作」設為「內嵌資源」。

至於專案中的 FodyWeavers.xml,

這個檔案是自動生成,無需特別理會。

設定 Costura.Fody 所需路徑與 DLL 建置動作選擇內嵌資源


接著,可以試著 刪除 bin 目錄中的所有檔案,

然後重新建置 C# 執行檔專案。

即使輸出資料夾中沒有原本的 C++ DLL,

執行程式仍能正常運作。

這就代表 C++ DLL 已經被 自動包入 C# 類別庫的 DLL 之中。

C++ DLL 使用套件包入後的應用程式建置結果輸出


如果使用 發佈功能,並且設定為單一檔案發佈,

原本只會將 .NET DLL 包進去,而不會包含 C++ DLL。

不過,如果搭配 Costura.Fody 套件,

C++ DLL 也會被自動包入進去,

最終就可以只產生單一執行檔,直接執行。

單一檔案發布,搭配 Costura.Fody 套件後的輸出結果


如果想進一步了解 單一檔案發佈 的細節,

可以參考下列網址中的另一篇文章:
發佈 .NET 單一執行檔到本地資料夾


如果 NuGet 套件中本身就包含 C++ DLL,

發佈後仍可能會在輸出資料夾看到該 DLL,

不論是否有將它放入指定的資料夾中,

這都是正常現象,屬於發佈功能的行為。


前面示範的是將 C++ DLL 包在 C# 類別庫那一層。

如果希望在執行檔專案這一層使用,也是完全可行的。

只不過,相關的 Costura 設定就需要在執行檔專案中進行設定。

補充3:C++ DLL 輸出路徑設定

如果在方案中修改了 C++ 程式碼,

每次要執行整個程式時,都需要手動複製 C++ DLL 到對應目錄,

這樣做起來會很麻煩,有沒有辦法簡化這個流程呢?


這時候,可以透過建置後事件來解決這個問題。

在 C++ 專案上按右鍵 → 屬性,

左側選單找到「建置事件」並找到「建置後事件」,

點下去後,接著點選右邊視窗中的「命令列」,

C++專案屬性頁,選擇建置後事件選項後的狀況


接著輸入以下指令與參數,確認評估值 OK 後,按下「確定」

1
xcopy /Y /I "$(TargetPath)" "..\CSharpLibrary\costura-win-x64\"

配合評估值來看,大家應該可以猜出,

這是一個將編譯好的 DLL,自動複製到指定位置的指令,

建置後要執行的命令列指令輸入


最後在屬性頁按下按下「套用」。

C++專案屬性頁,建置後事件設定完命令列的結果


這樣一來,未來每次 C++ 編譯完成後,

對應的 DLL 就會自動複製到指定路徑,

相關參數與路徑可以依實際專案需求自行調整。

總結

以上就是利用 DllImport 讓 C# 呼叫 C++ 原生函式的整體流程。

其實只要是 .NET 架構下的語言,例如 VB.NET,

也都能用相同的方式進行,

就算是在 C# 類別庫 中使用,也完全沒問題,

因為 .NET 各模組之間本身就能相互呼叫。


若是從他人手中取得的 C++ DLL 或既有的 DllImport 程式碼,

在理解了這些原理後,

相信對整體架構的運作方式就不會那麼陌生。


而若是自行開發的 C++ 函式庫,

在實際串接的過程中若遇到一些小麻煩,

也可以參考後面的補充章節,

有幾個能讓整體開發流程更加順暢的方法。


另外,在實際部署或讓使用者端執行軟體時,

電腦端有時也需要安裝對應的 C++ 執行階段元件(Runtime)。

這部分依照編譯方式與環境設定而定,

實務上有遇過必須安裝的情況,也有不需要的狀況。

若未來有更完整的了解,

會再針對這部分做進一步的補充。

自言自語543

依稀記得在學時期,

第一個接觸的程式語言(應該)就是 C++ ,

也有印象還有用過什麼 CMake 。


以前曾稍微碰過 MFC 專案,

當時有用個第三方函示庫,

雖然是編譯好的 C++,

但卻不是 DLL,而是 .h 和 lib 檔。


又或者是也有用過其他的程式語言,

去串接過 C++ 的程式碼,

其實還滿多程式語言的底層,

都是 C/C++ 的呢…

(指標什麼的我不想知道)


總之,除了 C# 有各種方式可以接 C/C++ 以外,

其他語言也是各種五花八門的串接方式呢…

評論




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