LoginSignup
12

More than 3 years have passed since last update.

【C#】 DllExportでWindowスタイルなAPIをC#風に扱う

Last updated at Posted at 2020-03-10

TL;DR;

  • HRESULT例外の互換(DllImportにPreserveSig=falseをつける)
  • 出力引数戻り値に移動(PreserveSig=false
  • refやoutポインタと互換
  • 文字列ポインタstringを互換(MarshalAs)

概要

C#とC/C++の相互運用といえば、C++/CLI、C++/CX、COM、IPCなどが考えられます。
その中でも、System.Runtime.InteropServices.DllImport(P/Invoke)という機能があります。
DllImportは基本的には、C++でExportした関数と同じ関数名で(互換性のある)引数、戻り値のメソッドをC#に宣言し、属性でマークすることで使用できます。
しかし、引数や戻り値に関してはintなどの型互換だけでなく、参照<->ポインタ、HRESULT<->例外などの互換も可能です。
また、C言語ではよく見かける出力引数を、一定条件下で戻り値として扱うこともできます。

サンプル

次のようなC++のDLLがあるとします。
C言語(Windows)ではよく、戻り値を成否通知に使い、引数に二重ポインタを渡し、ポインタ先に返したいオブジェクトのポインタを書き込みます。

// 二重ポインタ(本体の確保は呼び出され側)
extern "C" __declspec(dllexport) HRESULT WINAPI GetBestCouple(TCHAR** ppAnswer)
{
    // *ppAnswer = TEXT("YukaMaki"); // NG
    // *ppAnswer = new TCHAR[32]; // NG

    // C#に返すメモリはCoTaskMemAllocで確保しないといけない
    *ppAnswer = (TCHAR*)CoTaskMemAlloc(32);
    lstrcpy(*ppAnswer, TEXT("YukaMaki"));
    return S_OK;
}
// 一重ポインタ(本体の確保は呼び出し側)
extern "C" __declspec(dllexport) HRESULT WINAPI GetBestCouple2(TCHAR* pAnswer)
{
    lstrcpy(pAnswer, TEXT("YukaMaki"));
    return S_OK;
}

ちなみに、これは以下のようなメモリ配置になっています。(sizeof(void*)==4の場合)

これを、色々な条件でDLLImportしてみましょう。

Case 1

普通に二重ポインタ(TCHAR**)を渡し、実態へのポインタを格納しているメモリ領域を、C++側が確保した領域へのポインタに書き換えてもらいます。
C#側では二重ポインタ(TCHAR**)からTCHAR*を取り出して、マーシャラでデコード。

// HRESULT GetBestCouple(TCHAR** ppAnswer)
[DllImport("D3DVisualization.dll", EntryPoint = "GetBestCouple")]
static extern int GetBestCouple_H_PP_(IntPtr ppAnswer);

// 本体A(TCHAR[])へのポインタB(TCHAR*)へ
// のポインタC(TCHAR**)のBの部分のメモリを確保してCにアドレスを記録
// TCHAR* pAnswer; // 確保
// TCHAR** ppAnswer = &pAnswer;
// の操作に相当
IntPtr ppAnswer = Marshal.AllocHGlobal(IntPtr.Size);
int hResult = GetBestCouple_H_PP_(ppAnswer);
// TCHAR* pAnswer = *ppAnswer;
// の操作に相当
IntPtr pAnswer = Marshal.ReadIntPtr(ppAnswer);
string answer = Marshal.PtrToStringAuto(pAnswer);

Case 2

Case 1と同様の関数を呼び出しているが、C#側がout参照を渡しています。(これはref/in参照でもOK)
C#の参照もポインタと同じようなものなので、ポインタの参照、つまり二重ポインタと同様に働きます。
そのため、二重ポインタから本体の領域へのポインタを取り出す操作がなくなっています。

// HRESULT GetBestCouple(TCHAR** ppAnswer)
[DllImport("D3DVisualization.dll", EntryPoint = "GetBestCouple")]
static extern int GetBestCouple_H_RP_(out IntPtr pAnswer);

IntPtr pAnswer;
int hResult = GetBestCouple_H_RP_(out pAnswer);
string answer = Marshal.PtrToStringAuto(pAnswer);

Case 3

Case 2では戻り値を受け取っていましたが、捨てることも可能。

// HRESULT GetBestCouple(TCHAR** ppAnswer)
[DllImport("D3DVisualization.dll", EntryPoint = "GetBestCouple")]
static extern void GetBestCouple_V_P_E(out IntPtr pAnswer);

IntPtr pAnswer;
GetBestCouple_V_P_E(out pAnswer);
string answer = Marshal.PtrToStringAuto(pAnswer);

Case 4

PreserveSig=falseを付けることで、HRESULTをC#の例外に変換してくれます。
戻り値はなくなりvoidになりますが、代わりに出力引数を戻り値にできます。
(Case 3のようにout参照にできるものを変えさせられます。)

// HRESULT GetBestCouple(TCHAR** ppAnswer)
[DllImport("D3DVisualization.dll", EntryPoint = "GetBestCouple", PreserveSig = false)]
static extern IntPtr GetBestCouple_P__E();

IntPtr pAnswer = GetBestCouple_P__E();
string answer = Marshal.PtrToStringAuto(pAnswer);

Case 5

ここまでは、文字列をポインタを受け取って、マニュアルでstringにデコードしていました。
MarshalAsを利用することで、直接string(またはStringBuilder)で受け取ることができます。

C#のcharはWide文字なのでWCHRstringWCHAR*と互換になります。
(アルファベットを含むほとんどの文字を2byteで表現します、つまり基本的にUTF-16)
そして、C++側のTCHARがWCHARであれば、変換の必要が無いためコピーを回避してポインタだけを受け取れます。(Case 4までより効率的)
CoTaskMemAllocとGC

HRESULT PassStringRef4([in, out] LPWStr *s); // C++
void PassStringRef4([MarshalAs(UnmanagedType.LPWStr)] ref string s); // C#

文字列に対する既定のマーシャリング(MSDN)

上記のようにMSDNで紹介されているように、直接stringで受け取ってみます。
refで書いてありますが、outでも大丈夫です。outにする(&InAttributeを消す)と、C++側にはnullになって渡ります。
ちなみにLPWStrは「Wide-String(=WCHR*)のlongポインタ」という意味で、TCHARはWCHARになります。(ここでは)
なのでTCHAR**=LPWStr*です。

この後にp0p1の指すメモリを見てみると、p0は元のアドレスで「GynGyn」がそのまま残っています。
一方で、p1はp0とは別のアドレスになっており、「YukaMaki」が書き込まれています。
このことからも、answerが指し示すTCHAR*が変わったことがわかります。
GCHandle.Allocは、Managedオブジェクトのメモリ位置を固定してポインタを取得します。本当は、GCHandle.Allocの後はGCHandle.Freeする必要があります)

// HRESULT GetBestCouple(TCHAR** ppAnswer)
[DllImport("D3DVisualization.dll", EntryPoint = "GetBestCouple")]
static extern int GetBestCouple_V_RS_([MarshalAs(UnmanagedType.LPTStr), In, Out] ref string answer);

string asnwer = "GyunGyun"; // stringの実体はTCHAR*
IntPtr p0 = GCHandle.Alloc(asnwer, GCHandleType.Pinned).AddrOfPinnedObject();
GetBestCouple_V_RS_(ref answer); // ref stringは実質TCHAR**
IntPtr p1 = GCHandle.Alloc(asnwer, GCHandleType.Pinned).AddrOfPinnedObject();
// p0: 0x10B4 "GyunGyun\0\0\0\0\0\0\0\0\0" (上書きされず残っている)
// p1: 0x2A40 "YukaMaki\0\0\0\0\0\0\0\0\0"

Case 7

二重でないポインタ版。
一重ポインタを出力引数に取る場合、一般的には呼び出し側が書き込み先の領域を確保することが期待されます。

もちろん、asnwerBufferが十分な大きさが無いと、Overflowします。
超巨大な文字列を扱う場合などはこちらの方がいいかもしれません。

// HRESULT GetBestCouple2(TCHAR* pAnswer)
[DllImport("D3DVisualization.dll", EntryPoint = "GetBestCouple2")]
static extern int GetBestCouple_V_S_([MarshalAs(UnmanagedType.LPWStr)] string asnwerBuffer);

string asnwerBuffer = new string('_', 16); // 16+1文字分を確保して'_'で埋める
// answerBuffer: "________________\0"
GetBestCouple_V_S_(asnwerBuffer); // answerBufferが確保した領域に上書きする
// answerBuffer: "YukaMaki\0______\0"

CoTaskMemAllocとGC

CoTaskMemAllocで確保したメモリは、返却時にシステムによって解放されます。
わかりやすいように、C++側で512M文字(1GB)を確保します。
その後、C#側に制御が戻り、GC.Collectを読んで強制GCを走らせた後、使用メモリ量が急激に減少していることが確認できました。

また、グラフをよくみると、C++で確保した瞬間に1GB、C#に返るときに追加で1GB確保しています。コピーが発生しているように見えます。UnmanagedTypeLPSTRLPWSTRにしたり、PreserveSig=trueにしても同じ結果になります。

DLLEXPORT HRESULT WINAPI Get512MString(TCHAR*& dst)
{
    size_t size = 512 * 1024 * 1024; // 512 MB 確保
    dst = (TCHAR*)CoTaskMemAlloc(size * sizeof(TCHAR));
    for (size_t i = 0; i < size / 8; i++)
        memcpy(dst + i * 8, TEXT("YukaMaki"), 8 * sizeof(TCHAR));
    dst[size - 1] = TEXT('\0');
    return S_OK;
}
[DllImport("D3DVisualization.dll", PreserveSig = false)]
[return: MarshalAs(UnmanagedType.LPTStr)]
static extern string Get512MString();

int getCount() => Test().Length; // スコープが抜けるように別関数化
Console.WriteLine(getCount()); // 536870911
GC.Collect(); GC.Collect();

コピーは発生しているけど、最終的にどちらも解放されます。
コメント 2020-03-13 030258.png
Test()の戻り値をメンバにキープしてGCされないようにすると、C++で確保した分だけが解放されます。このことから、コピーされているのは間違いないですね。
コメント 2020-03-13 034752.png

ちなみに、CoTaskMemAllocのメモリはC#にリターンした時に問答無用で解放されるので、C++側の静的な領域にポインタを確保しておいて、もう一度使おうとすると、アクセス違反でクラッシュします。

まとめ

DllImportのために宣言するメソッドは意外と柔軟であることがわかりました。
そもそも、DLLのリンクはextern Cなので、関数名しか見ていませんが…(故にオーバーロードとか無理)
型互換以外(ポインタも型ですが)についてまとめましたが、型も結構自由が利き、ユーザ定義のclass/structなんがが使えたりします。
StructLayoutAttribute クラス(MSDN)

…とはいえ、DllImportしたメソッドはラップして利用することが推奨されていますし、ここまでの紹介は全てラッパーで解決できることだったりします。

一番ハマったのは、C++でメモリ確保をする部分でした。
CoTaskMemAllocを使わないと互換化できない…ということでした。
MarshalAsのMSDNページに書いといてくださいよ…:rolling_eyes:
ただ、逆に開放はGCでやってくれます。

結論として、余分なコピーは発生しますが、IntPtrで受け取ってMarshal.PtrToStringAutoでデコードするのが一番安定するような気がします。

参考

WinAPIからの値の受け取り方について(C# と VB.NET の質問掲示板)
PreserveSigAttribute クラス(MSDN)
文字列に対する既定のマーシャリング(MSDN)
【Windows/C#】なるべく丁寧にDllImportを使う(Qiita)
DLLから文字列を取得する方法(@IT)
C#とCとのやり取りでCから配列変数を渡す例
ワイド文字(Wikipedia)
既定のマーシャリングの動作 -> アンマネージ シグネチャ(MSDN)
P/Invoke時におけるマーシャラの動作(マーシャリング)(KrdLab's blog)
【Unite 2017 Tokyo】パフォーマンス向上のためのスクリプトのベストプラクティス (slideshare)
Standard ECMA-335 Common Language Infrastructure (CLI) (ECMA)

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12