這篇文章將分別著重在 C# 的部分做討論,會試圖拿測試環境和 Sharpdevelop IDE 出來講解,這是因為測試環境和實際運作環境不同,而 Sharpdevelop IDE 是很好的例子。
事實上,要快速的了解整個 COM Interop / PInvoke 並不是簡單的事,有很多名詞需要釐清、還有他們之間的差異,光透過實作程式碼去探索,可能也會耗費許多時間,所以整理出這篇文章解釋我所理解的部分。
必讀文件是很重要的一環,不能只是光靠這篇整理的筆記,由於微軟文件連結一直在變,如果連不到文章,請使用標題去搜尋。
必須要先知道的幾件事:
- C# 無法直接輕易的 Call C++ Dll API, 這是因為大多數 C++ Dll API 不是基於 .NET Framework 做包裝,C# 就必須指定進入點(有必要需要指定 EntryPoint)。
- 在 Production 環境檢查 dll 為什麼無法引用, debug 訊息又沒有太多資料時,建議先從依賴套件開始檢查,使用 DLL 檢查相依性工具: Dependencies: (https://github.com/lucasg/Dependencies),他有 GUI 程式,你可以把 dll 放進去,看看缺少了什麼,這個小主題會在【發佈整套應用程式之前,重要的處理】小節討論。
- 傳送字串需要注意你的資料是 ANSI 還是 Unicode。
- 32bit / 64bit 的 DLL 並不存在向上向下相容,所有程式碼包含調用的程式碼,只能符合一致性,調用正確位元的 DLL,否則會出錯。
概念 - Managed 與 Unmanaged 程式碼
必讀文件:
- 與非受控程式碼交互操作
- Interoperating with unmanaged code (上一個英文版)
- Consuming Unmanaged DLL Functions
- 什麼是 Managed 程式碼?
先說說 Unmanaged 程式碼的部分,所謂 Unmanaged 指的是 C# .Net 使用對外的 API、Win32 系統 API,這些 API 本身的資料類型、簽章或是錯誤處理機制(Exception) 可能與 .Net 本身不同,比方說,使用了 Unmanaged 程式碼呼叫系統端,取得目前的執行緒 ID:
[DllImport("kernel32.dll")] static extern uint GetCurrentThreadId(); static void Main(string[] args){ uint threadId = GetCurrentThreadId(); }
這樣的 API 所回傳的資料將透過 Unmanaged 機制處理。
至於 Managed 程式碼,在 C# .Net 中,微軟的文件也提到不管是 Mono, .Net, .Net Core 這幾種,都是由 Common Language Runtime (CLR) 負責把一般的程式碼轉編譯成機器碼才執行,等於是包了一層 Runtime,這個 CLR 可以幫你處理記憶體回收、安全性、型別等等,就是你平常熟知的 Runtime 系統層面, 相較 Unmanaged 程式碼來說,你用 C/C++ 不太可能讓編譯結果跑在 .Net 任何的 Runtime 下,也就不享有同一個 Runtime 機制。
如下圖, C++ Unmamaged Code 並不會被任一個 .Net Framework, Mono, .Net Core Runtime 去管理到記憶體或是任何型別機制。
如果你還是看不太懂這張圖,簡單的說就是 C/C++ 沒有 Runtime ,這種編譯出來的 Dll 套件操作都是 Unmanaged,他是直接對系統操作, C# 有 Runtime,這種編譯出來的 Dll 可以被 .Net 任何 Runtime 直接接軌使用,也會被記憶體回收機制管理。
詳情差異表,可以看這篇文章:
COM Interop, PInvoke
選讀文件:
- What is the difference between pInvoke and COM Interop?
- Example COM Class (C# Programming Guide)
- 平台叫用 (P/Invoke)
- Platform Invoke (P/Invoke) (上一個英文版)
顯然目前這篇文章還沒有真的解釋這兩個是什麼東西,所以順便列舉一些程式碼解釋。
COM Interop, PInvoke 這兩種都是調用第三方 dll 的標準跟技術,以 COM Interop 來講,對 .Net Framework 支援度會比較高,像是呼叫 Office 系列、 Adobe、Active X 之類的,不過 COM Interop 也還是 Unmanaged (本質的操作就是 Unmanaged)。
找了一個範例是 Office Visio 操作的 Code,可以了解 COM Interop 是怎麼使用的:
//https://gist.github.com/saveenr/f7dbdcad1234f71ed444 // First Add a reference to the Visio Primary Interop Assembly: // In the "Solution Explorer", right click on "References", select "Add Reference" // The "Add Reference" dialog will launch // then in the ".NET" Tab select "Microsoft.Office.Interop.Visio" // Click "OK" using IVisio = Microsoft.Office.Interop.Visio; namespace Visio2007AutomationHelloWorldCSharp { class Program { static void Main(string[] args) { IVisio.ApplicationClass visapp = new IVisio.ApplicationClass(); IVisio.Document doc = visapp.Documents.Add(""); IVisio.Page page = visapp.ActivePage; IVisio.Shape shape = page.DrawRectangle(1, 1, 5, 4); shape.Text = "Hello World"; } } }
從 Visual Studio Reference 這個地方,就可以看到引用的 Reference 有 COM 的程式庫:
而 PInvoke 是希望從 Managed Code 底下去操作 Unmanaged Code,在中文版的 【平台叫用 P/Invoke】寫了一段似是而非的話: 「P/Invoke 是一種技術,可讓您從受控碼存取結構、回呼和非受控程式庫中的函式。」 再進一步解釋,他裡面說的從受控碼存取結構,意思就是在一般的 C# Code 裡面執行,而執行的是 Unmanaged(非受控) 的 Code。
這需要一點程式碼的示意 (付上以微軟文件解釋的那個詭異名詞的對照):
//hi Main 這邊是 Managed Code(受控碼) 喔 static void Main(string[] args){ //hi 我在這裡想執行 Unmanaged Code (非受控) }
P/Invoke Library 是在 System 跟 System.Runtime.InteropServices 底下,他的用法也比較特別,需要引用確切的 DLL 位置,甚至需要指定進入點,微軟官方的舉例也很不錯, P/Invoke 叫法範例:
using System; using System.Runtime.InteropServices; public class Program { // Import user32.dll (containing the function we need) and define // the method corresponding to the native function. [DllImport("user32.dll", CharSet = CharSet.Unicode, SetLastError = true)] private static extern int MessageBox(IntPtr hWnd, string lpText, string lpCaption, uint uType); public static void Main(string[] args) { // Invoke the function as a regular managed method. MessageBox(IntPtr.Zero, "Command-line message box", "Attention!", 0); } }
上面的程式碼,是呼叫 MessageBox 顯示一個訊息,不過他是直接呼叫 Win32 API 出來,不是從系統內建的 MessageBox.Show() 去呼叫,這段就是 Unmanaged Code, P/Invoke 也會是這篇文章主要要討論的內容。
這裡 P/Invoke 出現的 DllImport ,如果你的 Function 不想要叫 MessageBox,則需要在參數列指定 Entry="MessageBox" (也就是 C++ 函數名稱),如果不加 Entry ,函數名稱就要和 C++ 的一樣。
附上一個 Linux .Net Core 的 P/Invoke 叫法範例: Gist
開端 - C++ P/Invoke 開一個 Function 出來給別人用
這篇文章的目的是 C# 去調用 C++ Dll,所以 C++ 會是一開始的重點,如果你正在解決這個問題,而且你沒有 C++ 原始碼只有 DLL,那你可能需要有心理準備,需要 Debug 的時間可能會拉得很長,而且你還需要了解 P/Invoke 很多的概念,最好還是把 C++ Code 拿到手吧。
首先,寫一個 C++ 把資料傳給 C# ,他的寫法是:
#include <iostream> //沒有用的 Code,但還是留著 int main() { std::cout << "Hello World!\n"; } //指定匯出 GetCharArray 這個 Function 給外部使用 extern "C" __declspec(dllexport) void GetCharArray(char* arrayNew[5]); //思路是,讓 C# 丟進去一個指標,讓 C++ 修改完後,給 C# 讀 void GetCharArray(char* arrayNew[5]) { arrayNew[0] = const_cast("Test"); arrayNew[1] = const_cast ("Test2"); arrayNew[2] = const_cast ("Test4"); arrayNew[3] = const_cast ("Test5"); arrayNew[4] = const_cast ("Test6"); }
其中的 char* 指標,是用來存放 C# 字串的,關於這些型別的部分,會在文章後半段開始討論,請先試圖把這個 C++ Code 編譯成 DLL 吧,編譯平台記得先選用 x86 (32bit)。
然後在 C# 端,他是這麼呼叫這個 DLL 中的 GetCharArray:
using System; using System.Collections.Generic; using System.Linq; using System.Runtime.InteropServices; using System.Text; using System.Threading.Tasks; namespace dllcrossstringtest { class Program { static void Main(string[] args) { int 有多少筆字串 = 5; //已知 5 筆資料,也可以開 1000 筆,不過執行成功後,要拉到最上面看 (本章會討論這個大小部分) IntPtr[] pointers = new IntPtr[有多少筆字串]; GetCharArray(pointers); string[] results = new string[有多少筆字串]; for (int i = 0; i < 有多少筆字串; i++) { results[i] = Marshal.PtrToStringAnsi(pointers[i]); Console.WriteLine(results[i]); } Console.ReadLine(); } [DllImport(@"C:\Users\Mac\source\repos\stringdllexport\Debug\stringdllexport.dll")] public static extern void GetCharArray(IntPtr[] results); } }
其中 IntPtr 是當你不想用 unsafe code 模式去存取 Unmanaged Code 指標時,可以用的方式,在前面我們討論過 COM Interop, P/Invoke 都還是 Unmanaged Code,而我們也沒有詳細討論 unsafe code 的部分,所以在這篇文章,存取 Unmanaged Code 的指標,都用 IntPtr 代替。
這篇文章也稍微解釋了 IntPtr 的用途: Just what is an IntPtr exactly?
然後,馬上就出錯了,不要急,有一些地方,我們要再進一步釐清。
Calling Convention 問題
選讀文件:
- 从栈不平衡问题 理解 calling convention
- 呼叫慣例 calling convention
- CallingConvention Enum
- Using Win32 calling conventions
- 几种调用约定(Calling convention)的介绍
Calling Convention 叫做呼叫慣例,就是一種規範,根據這篇文章的解釋,總共分為三大類的規範:
- 決定參數 Stack 是從左到右、還是從右到左,或是透過 register 傳遞
- 誰維護 Stack
- 名稱修飾方式
總共會碰到好幾種 Calling Convention 的形式 (MSVC 有 cdecl, std, fast 三種常見的) :
- Cdecl - 呼叫者 (caller) 維護 stack , 參數從右往左
- Pascal - 被呼叫者 (callee) 維護 stack, 參數從左往右
- Stdcall - 被呼叫者 (callee) 維護 stack - 參數從右到左
- Fastcall - 被呼叫者 (callee) 維護 stack,第一個及第二個小於 4 bytes 的參數會放到 register,其餘是從右到左
- Thiscall - 被呼叫者維護 stack,參數從右到左
本篇文章就不詳細介紹其機制,請從選讀文件進去了解。
在這篇文章,我們感興趣的是 C# 跟 C++ Calling Convention,也就是為什麼剛才那段 Code 會出錯的原因,就是 Calling Convention 不一樣。
在上面的例子, C# 預設的 Calling Convention 是 Stdcall, C++ 預設匯出的 Calling Convention 是 Cdecl,我們希望它們都可以抱持一致,而且盡量不要留下默認的 Code,那可能會造成誤會,因此,我們這樣修正:
C++:
#include <iostream> //沒有用的 Code,但還是留著 int main() { std::cout << "Hello World!\n"; } //照著 Function 有的參數加上 __stdcall extern "C" __declspec(dllexport) void __stdcall GetCharArray(char* arrayNew[5]); //在這邊指定了 Calling Convention 是 __stdcall void __stdcall GetCharArray(char* arrayNew[5]) { arrayNew[0] = const_cast("Test"); arrayNew[1] = const_cast ("Test2"); arrayNew[2] = const_cast ("Test4"); arrayNew[3] = const_cast ("Test5"); arrayNew[4] = const_cast ("Test6"); }
C# :
using System; using System.Collections.Generic; using System.Linq; using System.Runtime.InteropServices; using System.Text; using System.Threading.Tasks; namespace dllcrossstringtest { class Program { static void Main(string[] args) { int 有多少筆字串 = 5; //已知 5 筆資料,也可以開 1000 筆,不過執行成功後,要拉到最上面看 (本章會討論這個大小部分) IntPtr[] pointers = new IntPtr[有多少筆字串]; GetCharArray(pointers); string[] results = new string[有多少筆字串]; for (int i = 0; i < 有多少筆字串; i++) { results[i] = Marshal.PtrToStringAnsi(pointers[i]);//轉換回 String 型別 Console.WriteLine(results[i]); } Console.ReadLine(); } //在這行特別指定了 Calling Convention 是 Stdcall //不過如果你沒有修正 C++ Code,這邊的 Calling Convention 就要保持 Cdecl (因為 C++ 預設 Cdecl) [DllImport(@"C:\Users\Mac\source\repos\stringdllexport\Debug\stringdllexport.dll", CallingConvention = CallingConvention.StdCall)] public static extern void GetCharArray(IntPtr[] results); } }
其這樣一改完 __stdcall,就會執行成功了,然後,我們得好好解釋這段程式碼的 pointers 為什麼刻意去指定【有多少筆字串】,就是接下來要討論的事情了。
基本資料型別
必讀文件:
- 使用 C++ Interop (隱含 PInvoke)
- C# 自訂結構封送處理
- C# 封送處理不同類型的陣列
- Marshaling Different Types of Arrays (上一個英文版)
- Copying and Pinning
剛才那段 Code,不想每一個都 IntPtr 接收,這該怎麼辦?
的確是可以讓 P/Invoke 指定的 Function 方法中的參數,使用指定陣列值回傳,像是以下的作法:
//只要 c# 要使用指標,就要用 unsafe (不推薦的做法) unsafe static void Main(string[] args) { //char 指標陣列,用 char ** 接 fixed (char** test = new char*[5]) //無法得知 pointer 大小(即便浮動) { GetCharArray(test); //還是無法事先得知 pointer 大小 for(int i = 0; i < 5; i++) { //麻煩,還需要轉換用 IntPtr 轉出字串 Console.WriteLine(Marshal.PtrToStringAnsi((IntPtr)test[i])); } } Console.ReadLine(); } //這個 code 型別也要改成 unsafe [DllImport(@"C:\Users\Mac\source\repos\stringdllexport\Debug\stringdllexport.dll", CallingConvention = CallingConvention.StdCall)] public unsafe static extern void GetCharArray(char** results);
Unsafe 這個關鍵字,是 C# 要做所有指標操作時要加上的詞,而且還必須在專案屬性(Properties) 中打開 【Allow Unsafe Code】 這個選項才能執行。
這裡要釐清的是,Unsafe 裡面是允許執行 unmanaged code,直接的使用 * 或 & 去操作指標,本身就是 unmanaged 的行為,故在 unsafe 中。
未來我們都會用 IntPtr, ref, out, [In,Out] 這些東西,取代 unsafe 裡面要做的那些指標操作,這一些東西就是在 managed code 裡面操作 unmanaged 的取代方式。
這跟文章有一點脫離主題,因為這是跟 C# 要操作 Pointer 相關函數時會碰到的知識,可以作為延伸閱讀:
- Unsafe code and pointers (C# Programming Guide)
- Unsafe code
- Calling C++ function from C#, with lots of complicated input and output parameters
顯然,使用上面這段 unsafe 操作跟指標操作對沒有 C++ 經驗的人,將會是個噩夢,他要處理的東西太多了。
剛才那段程式碼,就算指定了 1000 個字串,他居然也可以跑出來,那如果 C++ 重新分配了指標大小,在 C# 端可以浮動的知道嗎?
這裡很多時候不會像 C# Array Object 還可以使用 .length 拿到陣列大小,而是得另外傳一個 int 指標,回傳新的大小,是比較保險的作法,在這裡我們只縮減陣列大小,因為要是增加大小,就必須重新指定記憶體,做 Alloc ,這不是這篇文章要探討的主題。
C++ 重新指定的範例:
//根據 P/Invoke.DLL 的實作進行修改的 C++ Code //https://docs.microsoft.com/zh-tw/dotnet/framework/interop/marshaling-data-with-platform-invoke#pinvokelibdll extern "C" __declspec(dllexport) void __stdcall TestArrayOfStrings(char** arrayNew, int* count); void __stdcall TestArrayOfStrings(char* ppStrArray[], int* count) //替換前 4 個 string index 變成 123456789 { int result = 0; STRSAFE_LPSTR temp; const size_t alloc_size = sizeof(char) * 10; *count = 4; for (int i = 0; i < *count; i++) { temp = (STRSAFE_LPSTR)CoTaskMemAlloc(alloc_size); StringCchCopyA(temp, alloc_size, (STRSAFE_LPCSTR)"123456789"); // CoTaskMemFree must be used instead of delete to free memory. CoTaskMemFree(ppStrArray[i]); ppStrArray[i] = (char*)temp; } }
C# 多傳送一個 int 出去,用來知道新陣列的長度:
static void Main(string[] args) { string[] strArray = { "one", "two", "three", "four", "five" }; int newLength = 5; TestArrayOfStrings(strArray, ref newLength); //newLength 是 Pass by Reference, strArray 是 Pass by value for (int i = 0; i < newLength; i++) //newLength 變 4 個 { Console.WriteLine(strArray[i]); //被 C++ 讀取後修改,這裡只會出現 123456789 } Console.ReadLine(); } [DllImport(@"C:\Users\Mac\source\repos\stringdllexport\Debug\stringdllexport.dll", CallingConvention = CallingConvention.StdCall)] public static extern void TestArrayOfStrings([In, Out] string[] results, ref int count);
上面的例子呈現的是當 C# 傳資料過去給 C++ 修改後,得到的東西已經被修改過的例子,而且我們可以浮動知道長度 (newLength)。 關於資料長度的延伸閱讀: 固定大小緩衝區 (C# 程式設計手冊)
這裡又出現新的東西 ref, in, out 跟 [In, Out] 又是什麼? 該如何決定使用什麼?
先回答「該如何決定使用什麼?」 的部分,關於 C++ 和 C# 範例,你應該要熟度下面兩份文件的操作:
- Marshaling Different Types of Arrays (C# 呼叫 C++ 的實作)
- Marshaling Data with Platform Invoke (C++ 的對應實作)
- [In, Out] 同時寫: 傳值(Pass by value),而且說明是: The array size cannot be changed, but the array is copied back. -> 指的是傳過去 C++ 陣列大小無法被改變,但是陣列是複製回來的。 (複製將對系統造成成本負擔)
- ref : 傳址(Pass by reference),而且說明是: The array size can change, but the array is not copied back -> 址的是陣列打小可以被改變,但是陣列不是複製回來的,表示 C++ 端會直接對記憶體做修正,在 C# 這邊讀取修正值。
- out: 和 ref 一樣都是傳址呼叫,不過 out 不一樣的地方是,不需要事先賦值給變數:
ref 寫法: int xxx = 100; pinvokeRun(ref xxx); //必須要先初始化 xxx = 100
out 寫法: int xxx; pinvokeRun(out xxx); //不需要初始化 xxx - in: 和 ref 一樣也是傳址呼叫,但是只能唯獨,你不能修改 in 進去的位址資料,而且和 ref 一樣都需要事先初始化。
- [In]: Pass by value ,丟過去的東西修改了會單純複製新的記憶體,不會傳回來原本的 C# ,所以要是上面那段 code 只單純寫 Pass by val,不會讀到被修改的東西。
等於是 C++ 只能讀,但是改完的東西 C# 接不到。 - [Out]: Pass by value ,只有 Out ,只會把值的空間大小傳出去,的東西修改完會對應到複製的新記憶體,但陣列大小依然無法被改變。
如果上方程式只有寫 Out,而且你又在 C++ 試圖讀出 C# 傳進來的值,像這樣:
std::cout << (ppStrArray)[1] << std::endl;
那它一定會出錯,因為 ppStrArray[1] 沒有收到 C# 進來的東西,不過如果你不讀它,只是單純對記憶體 index 修正,是不會出錯的,等於是 C++ 不能讀,只能改。
//第一種 //使用 ref, strArray 要先賦值 string[] strArray = { "one", "two", "three", "four", "five" }; TestArrayOfStrings_1(ref strArray, ref newLength); //要加 ref 把資料送出去 //定義 PInvoke 出來 1 [DllImport(@"C:\Users\Mac\source\repos\stringdllexport\Debug\stringdllexport.dll", CallingConvention = CallingConvention.StdCall)] public static extern void TestArrayOfStrings_1(ref string[] results, ref int count); //第二種 //使用 in, strArray 要先賦值 string[] strArray = { "one", "two", "three", "four", "five" }; TestArrayOfStrings_2(in strArray, ref newLength); //要加 in 把資料送出去 //定義 PInvoke 出來 2 (略寫) public static extern void TestArrayOfStrings_2(in string[] results, ref int count); //第三種 //使用 out, strArray 不需要賦值 string[] strArray; TestArrayOfStrings_3(out strArray, ref newLength); //要加 out 把資料送出去 //定義 PInvoke 出來 3 (略寫) public static extern void TestArrayOfStrings_3(out string[] results, ref int count);
//不管哪一種,都必須先賦值,基本上是 Pass by value string[] strArray = { "one", "two", "three", "four", "five" }; //第一種 TestArrayOfStrings_1(strArray, ref newLength); //不用加任何東西,直接 pass by value //定義 PInvoke 出來 1 [DllImport(@"C:\Users\Mac\source\repos\stringdllexport\Debug\stringdllexport.dll", CallingConvention = CallingConvention.StdCall)] public static extern void TestArrayOfStrings_1([In, Out] string[] results, ref int count); //第二種 TestArrayOfStrings_2(strArray, ref newLength); //不用加任何東西,直接 pass by value //定義 PInvoke 出來 2 (略寫) public static extern void TestArrayOfStrings_2([In] string[] results, ref int count); //第三種 TestArrayOfStrings_3(strArray, ref newLength); //不用加任何東西,直接 pass by value //定義 PInvoke 出來 3 (略寫) public static extern void TestArrayOfStrings_3([Out] string[] results, ref int count);
自訂資料型別
前面我們花了好多時間在處理基本資料型態的陣列如何傳送,特別是字串的型態,當陣列會用了,一般的資料型別就算不帶陣列,也可以輕易處理,但是如果是自訂的 Struct 怎麼辦。
基本上這篇文章也就以陣列型態的自訂型別去討論,現在想要把一個 Struct Array 傳送到 C++ ,修改每一個值換成 1234 之後,丟回來 C# ,至少我們現在知道要用 out, ref 去做傳址,才能在 C++ 端修改,不過我們先來來看看一個資料大小造成的問題,如果我們直接想要把 Struct Array 當成參數 Type 傳遞,會發生什麼事:
C++ (這是固定的範本):
//自訂一個 Struct Type typedef struct _MYPOINT { int x; int y; char* name; } MYPOINT; //雙 pointer 表示 struct 陣列 extern "C" __declspec(dllexport) void __stdcall TestArrayOfStructs(MYPOINT ** pPointArray, int* size); void __stdcall TestArrayOfStructs(MYPOINT** pPointArray, int* size) { //先開一個 newPA 新記憶體,建立空間大小跟傳進來的 size 陣列大小一樣,乘上 MYPOINT Struct 資料大小 MYPOINT* newPA = (MYPOINT*)CoTaskMemAlloc(*size * sizeof(MYPOINT)); //遍歷每一個 Array index for (int i = 0; i < *size; i++) { //開一個新的字串 std::string s = "1234"; //取得字串的 uint32_t 大小 uint32_t lLen = (uint32_t)s.size(); //新增一個 string pointer ,給定大小, +1 是 C++ 中的 String Termination 是用 '\0' 補上變成結尾,C# 會自動判別,故 +1 char* lName = (char*)CoTaskMemAlloc(sizeof(char) * lLen + 1); //把字串空間複製到 pointer 上 (c_str 用法可以參考: https://www.cnblogs.com/qlwy/archive/2012/03/25/2416937.html) strcpy_s(lName, lLen + 1, s.c_str()); //include '\0' //把字串指標放到 struct array index 上 newPA[i].name = lName; newPA[i].x = i; //隨便給 newPA[i].y = i; //隨便給 } //釋放原來傳進來的記憶體 CoTaskMemFree(*pPointArray); //把原來傳進來的記憶體賦予新的 pointer *pPointArray = newPA; }
C# 呼叫部分 (以明確 type array 設想去做):
static void Main(string[] args) { MYPOINT[] myp = new MYPOINT[] { new MYPOINT(1, 1,"NAME"), new MYPOINT(2, 3, "NAME"), new MYPOINT(2, 3, "NAME"), new MYPOINT(2, 3, "NAME"), new MYPOINT(2, 3, "NAME") }; int newSize = myp.Length; TestArrayOfStructs(out myp, ref newSize);//直接把 myp 丟出去給 C++ 改 } [DllImport(@"C:\Users\Mac\source\repos\stringdllexport\Debug\stringdllexport.dll", CallingConvention = CallingConvention.StdCall)] public static extern void TestArrayOfStructs(out MYPOINT[] results, ref int size); //Sequential Kind 是指 C++/C# 要對應的記憶體排序方式是按照先後順序的資料空間 //詳細可以參考這篇文章: https://dotblogs.com.tw/atowngit/2009/08/31/10333 [StructLayout(LayoutKind.Sequential)] public struct MYPOINT { public int X; public int Y; public string name; public MYPOINT(int x, int y, string name) { this.X = x; this.Y = y; this.name = name; } }
這裡的結果肯定是可以執行,可是仔細看就會發現奇怪的點:
解決方案是從源頭做起,沒有辦法輕易的從 type 當作參數 (parameter) 下手,直接接收到值,而是要利用 IntPtr 先接收資料,再手動給定資料空間大小,然後用 Marshal 工具轉換回來。
其中有關 IntPtr 的操作,包含字串變數轉成 IntPtr 指標,或是 IntPtr 指標轉成字串等這類的問題,都可以透過 Marshal 這個 Library 解決。
所以,我們重新釐清步驟,如果想要 "把資料傳給 C++ 讀,改完再丟給 C#",做法會是:
- 把 Struct Array 轉成 IntPtr (Struct Array To IntPtr)
- 把 P/Invoke 參數換成 out/ref IntPtr 出去給 C++ 端
- 接收回來用,用轉換 Method 轉成 Struct Array
static void Main(string[] args) { MYPOINT[] myp = new MYPOINT[] { new MYPOINT(1, 1,"NAME"), new MYPOINT(2, 3, "NAME"), new MYPOINT(2, 3, "NAME"), new MYPOINT(2, 3, "NAME"), new MYPOINT(2, 3, "NAME") }; IntPtr mypRawPtr = new IntPtr(); //建立空的 IntPtr int newSize = myp.Length; //大小還是原來的 Array Size TestArrayOfStructs(out mypRawPtr, ref newSize); //丟出空 Ptr 返回後,就有值了 MarshalUnmananagedArray2Struct<MYPOINT<(mypRawPtr, newSize, out myp); //把值讀回去 myp 陣列修改(所以用 out) } //已經改用 IntPtr 傳參數了 [DllImport(@"C:\Users\Mac\source\repos\stringdllexport\Debug\stringdllexport.dll", CallingConvention = CallingConvention.StdCall)] public static extern void TestArrayOfStructs(out IntPtr results, ref int size); //轉換的 Method //使用泛型,就可以用不同 Struct 去轉回 Array (通用性) public static void MarshalUnmananagedArray2Struct<T<(IntPtr unmanagedArray, int length, out T[] mangagedArray) { var size = Marshal.SizeOf(typeof(T)); //讀取目標型別的大小 mangagedArray = new T[length]; //開出每個型別該有的預設空間 //把每一個 IntPtr 陣列值的每一項,依照大小格式轉回去 Struct,再丟回 managedArray for (int i = 0; i < length; i++) { IntPtr ins = new IntPtr(unmanagedArray.ToInt64() + i * size); mangagedArray[i] = (T)Marshal.PtrToStructure(ins, typeof(T)); } }
在上面很大段的討論中,這些 Code 寫法是極度帶有我個人風格的,其中有
- return 回傳最多只用基本型別,而且最好是 int ,這可以拿來當作 status code (狀態碼) 偵錯
- 盡量把 Parameter 區域(調用參數區) 放進去 IntPtr 指標且用 P/Invoke Pass by ref 出去,修改完後丟給 C# 接收。
C++ Common Library Runtime 支援模式
你可能會發現到 C++ 專案有一個設定是 CLR 支援模式,這個部分我們在這個文章中都沒有打開過,最主要是這裡所遇到的情況有一點極端,如果 DLL 供應商無法打開這個選項,那麼上面所有的知識是必須要學會的, C++ CLR 模式一打開,在任何 .Net Framework Runtime 就會變成 Managed Code,這也是基於專案已經包了一層 .Net Managed 的殼在 C++ DLL 上。
詳情可以參考這篇文章:
C# Equivalent to C++ Types 使用型別等價性
從上方的文章內容中,隱約可以知道 C++ 與 C# 溝通之間,可能會在 Struct 傳遞時遇到內在結構資料大小的問題,會導致讀取到的資料完不完整,如同剛才傳了 5 個 array element,最後只收到 1 個回傳,這樣的問題。
當你正在對照 C++ Types ,寫到 C# 的 Layout.Sequential Struct 時,也應該要注意 C++ 使用的型別會影響你讀到的資料,比方說 C++ 使用 uint8, 在 C# 端不應該使用 long 等空間大小不一樣的型別做接收,這會同時影響到其他型別。
舉例來說,如果我的 Struct Array 對應錯型別,很容易發生以下狀況 (只有打勾的地方才是對的接收值)。
仔細對照右側的 type ,有些地方讀得到值,有些地方值會是亂七八糟的,那表示對應資料的時候,因為大小不一樣,造成讀取時切割到其他欄位應有的資料,顯示出來就變成這樣了。
綜合以上觀點,如果遇見亂碼可以嘗試除錯的辦法有這幾種:
- 修正 CharSet
- 修正 Type 型別
- 修正 CallingConvention
特別說明是在 CharSet,這篇文章都是基於 ANSI 做討論,但若你的資料是 Unicode ,請務必在 DllImport 參數列中,自行指定成 CharSet=CharSet.Unicode。
發佈整套應用程式之前,重要的處理
一定要把 C++ Dll Compile 改成 Release 釋出,如果在 Visual Studio Debug 模式下釋出,會帶有許多 VC++ 的除錯工具,導致在不同的電腦上執行會出現 Dependencies 找不到的問題。
比較看看下圖一和圖二。
圖一是 Visual Studio Release 後的 Dependencies Check:
圖二是 Visual Studio Debug 後的 Dependencies Check:
當使用 Debug 後的 DLL,在不同電腦上執行後,就會出現找不到模組的錯誤訊息。
https://docs.microsoft.com/en-us/cpp/dotnet/using-cpp-interop-implicit-pinvoke?view=vs-2019
https://docs.microsoft.com/en-us/dotnet/framework/interop/marshaling-data-with-platform-invoke
https://docs.microsoft.com/zh-tw/dotnet/framework/interop/marshaling-different-types-of-arrays
https://docs.microsoft.com/zh-tw/dotnet/framework/interop/marshaling-data-with-platform-invoke#pinvokelibdll
https://dotblogs.com.tw/atowngit/2009/08/30/10313
https://dotblogs.com.tw/atowngit/2009/08/31/10333
http://swaywang.blogspot.com/2012/11/ccnative-dlls-pinvoke.html
https://stackoverflow.com/questions/11555043/get-string-array-from-c-using-marshalling-in-c-sharp
https://stackoverflow.com/questions/7883781/marshaling-exported-string-vector-from-c-dll/16007149
https://blog.csdn.net/nei504293736/article/details/101060693
沒有留言:
張貼留言