2020年9月13日 星期日

Between C# and C++: COM Interop, Marshaling, PInvoke and DLL Communitation

這篇文章將分別著重在 C# 的部分做討論,會試圖拿測試環境和 Sharpdevelop IDE 出來講解,這是因為測試環境和實際運作環境不同,而 Sharpdevelop IDE 是很好的例子。


事實上,要快速的了解整個 COM Interop / PInvoke 並不是簡單的事,有很多名詞需要釐清、還有他們之間的差異,光透過實作程式碼去探索,可能也會耗費許多時間,所以整理出這篇文章解釋我所理解的部分。


必讀文件是很重要的一環,不能只是光靠這篇整理的筆記,由於微軟文件連結一直在變,如果連不到文章,請使用標題去搜尋。


必須要先知道的幾件事:

  1.  C# 無法直接輕易的 Call C++ Dll API, 這是因為大多數 C++ Dll API 不是基於 .NET Framework 做包裝,C# 就必須指定進入點(有必要需要指定 EntryPoint)。

  2.  在 Production 環境檢查 dll 為什麼無法引用, debug 訊息又沒有太多資料時,建議先從依賴套件開始檢查,使用 DLL 檢查相依性工具: Dependencies: (https://github.com/lucasg/Dependencies),他有 GUI 程式,你可以把 dll 放進去,看看缺少了什麼,這個小主題會在【發佈整套應用程式之前,重要的處理】小節討論。

  3. 傳送字串需要注意你的資料是 ANSI 還是 Unicode。  

  4. 32bit / 64bit 的 DLL 並不存在向上向下相容,所有程式碼包含調用的程式碼,只能符合一致性,調用正確位元的 DLL,否則會出錯。


概念 - Managed 與 Unmanaged 程式碼

必讀文件:

先說說 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 

選讀文件:


顯然目前這篇文章還沒有真的解釋這兩個是什麼東西,所以順便列舉一些程式碼解釋。

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 叫做呼叫慣例,就是一種規範,根據這篇文章的解釋,總共分為三大類的規範:

  1. 決定參數 Stack 是從左到右、還是從右到左,或是透過 register 傳遞
  2. 誰維護 Stack
  3. 名稱修飾方式

總共會碰到好幾種 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 為什麼刻意去指定【有多少筆字串】,就是接下來要討論的事情了。


基本資料型別

必讀文件: 

剛才那段 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 操作跟指標操作對沒有 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# 範例,你應該要熟度下面兩份文件的操作:

至於 ref, in, out [In, Out] 這個寫法,我會用比較粗淺的方式解釋,詳細需要琢磨這篇文章:
如果你去讀了上面那篇 Marshaling Different Types of Arrays 這篇文章的程式碼,做一點整理,以下解釋是這樣的:
  • [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++ 不能讀,只能改。
微軟在 Marshaling  Different Types of Arrays 中文版特別提到: 「除非陣列是明確地以傳值方式封送處理,否則預設行為會將陣列封送處理做為 In 參數。 您可以明確套用 InAttribute 和 OutAttribute 屬性來變更此行為。」

使用 ref, in, out 呼叫與定義範例:
//第一種
    //使用 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);


使用 [In, Out] 呼叫與定義範例:

//不管哪一種,都必須先賦值,基本上是 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;
    }
}

這裡的結果肯定是可以執行,可是仔細看就會發現奇怪的點:


myp 這個陣列居然最後指回傳 1 個,原本不是 5 個嗎?    這個問題就出在 Struct 解出來的資料大小,在第一個之後就跑掉了,所以整個被忽略掉。

解決方案是從源頭做起,沒有辦法輕易的從 type 當作參數 (parameter) 下手,直接接收到值,而是要利用 IntPtr 先接收資料,再手動給定資料空間大小,然後用 Marshal 工具轉換回來。

其中有關 IntPtr 的操作,包含字串變數轉成 IntPtr 指標,或是 IntPtr 指標轉成字串等這類的問題,都可以透過 Marshal 這個 Library 解決。

所以,我們重新釐清步驟,如果想要 "把資料傳給 C++ 讀,改完再丟給 C#",做法會是:

  1. 把 Struct Array 轉成 IntPtr  (Struct Array To IntPtr)
  2. 把 P/Invoke 參數換成 out/ref IntPtr 出去給 C++ 端
  3. 接收回來用,用轉換 Method 轉成 Struct Array
但,我們這裡就不做 1. (若有把 Struct Array 丟給 C++ 讀取的需要,可以自行搜尋 Struct Array to IntPtr,解法應該會是換成 LongPtr: Convert array of structs to IntPtr)。

所以我們現在要寫一個 Method ,把 IntPtr 按照 Struct 資料大小,轉換回原本的 Struct Array ,然後嘗試把空的 IntPtr 丟到 C++ ,並用這個 Method 轉回來。

C#:
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,在不同電腦上執行後,就會出現找不到模組的錯誤訊息。


【找不到指定模組】這句話很容易令人誤解,事實上這是因為在 DLL 中找不到 Dependencies 才會出現的錯誤,這就必須要用工具去檢查 (可以用 Dependencies)。



Reference:

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

沒有留言:

張貼留言

© Mac Taylor, 歡迎自由轉貼。
Background Email Pattern by Toby Elliott
Since 2014