C#からコールバック関数を使うCの関数を呼ぶ

概要

この記事には、コールバックを利用するネイティブコードの関数を C# (.NET Framework) のアプリケーションから利用する方法について簡単に書いています。

解決方法

コールバックを利用するネイティブコードの関数を C# (.NET Framework) から利用するには「コールバック関数を指すポインタを与えるべき引数に何を与えるか」という違いで次の2通りの方法があります。

  1. C# のデリゲートオブジェクトを与える
  2. C# のデリゲートオブジェクトが包んでいる関数のポインタを取得して、その値を使う

前者の方法では、C# のデリゲートオブジェクトを「コールバック関数へのポインタを与えるべき引数」として与えます。C# の範囲内で C/C++ のコールバック関数に相当するものはデリゲートオブジェクトですから、直感的で分かりやすい方法だと思います。.NET Framework 1.1 以降で使える方法です。

後者の方法では、デリゲートオブジェクトから関数ポインタを取りだして「コールバック関数へのポインタを与えるべき引数」へと渡します。これには .NET Framework 2.0 から Marshal クラスに追加された GetFunctionPointerForDelegate というメソッドを使用します。

デリゲートオブジェクトを引数に与える例

コールバック関数のポインタを与えるべき引数にC# のデリゲートオブジェクトを与える例を示します。

例として、次のようなネイティブコード関数 CallBackTenTimes を呼ぶことを考えます。

// file : CallBackTenTimes.c

// CallBackTenTimes 用コールバック関数ポインタの型
typedef  void (__stdcall *CallBackTenTimesProc)( void );

// 引数に与えた関数を10回呼び出す
void __stdcall
CallBackTenTimes( CallBackTenTimesProc proc )
{
    int i;
    for( i=0; i<10; i++ )
    {
        proc();
    }
}

仮にこのネイティブコードを CallBackTenTimes.dll という名前のDLLにビルドしたとします。さて、C# からこの CallBackTenTimes 関数を利用する場合、前述の通りデリゲートオブジェクトを使います。CallBackTenTimes が引数に取る関数ポインタは「返値なしで引数なしの関数」へのポインタですので、C# 側でこれと同等のデリゲート型を定義します。たとえば次のようになります(デリゲートの型名は何でも良いです)。

delegate void CallBackTenTimesProc();

続いて、呼び出すネイティブ関数を DllImport 属性を使って宣言します。このとき、関数ポインタを与えるべき引数の型を、先ほど定義したデリゲート型にします。たとえば次のようになります。

[DllImport("CallBackTenTimes.dll")]
static extern void CallBackTenTimes( CallBackTenTimesProc proc );

以上で C# 側からこの関数を呼び出す準備は終了です。C# 側からは C# で定義した静的メソッドとまったく同じようにこの関数を呼び出せますので、たとえば次のようなコードで呼び出せることが確認できます。

using System;
using System.Runtime.InteropServices;

public class CSharpApp1
{
    public static void Main( string[] args )
    {
        CallBackTenTimes(
            new CallBackTenTimesProc( MyCallBackTenTimesProc )
        );
    }
    
    static void MyCallBackTenTimesProc()
    {
        Console.WriteLine( "Hello, callback!" );
    }
    
    delegate void CallBackTenTimesProc();
    
    [DllImport("CallBackTenTimes.dll")]
    static extern void CallBackTenTimes( CallBackTenTimesProc proc );
}

この C# コードを実行すると、Hello callback! とコンソール上に10回表示されます。

デリゲートオブジェクトから関数ポインタを取得する例

デリゲートオブジェクトから関数ポインタを取得する例を示します。ただし、筆者はこの方法をあまりおすすめしません。

.NET Framework 2.0 からは Marshal クラスの GetFunctionPointerForDelegate メソッドでデリゲートオブジェクトから関数ポインタを取り出せるようになりました。これを使って、前例のネイティブ関数を呼び出す例を次に示します。

using System;
using System.Runtime.InteropServices;

public class CSharpApp2
{
    public static void Main( string[] args )
    {
        CallBackTenTimesProc proc;
        IntPtr funcPtr;
        
        proc = new CallBackTenTimesProc( MyCallBackTenTimesProc );
        funcPtr = Marshal.GetFunctionPointerForDelegate( proc );
        
        CallBackTenTimes( funcPtr );
    }
    
    static void MyCallBackTenTimesProc()
    {
        Console.WriteLine( "Hello, Callback!" );
    }
    
    delegate void CallBackTenTimesProc();
    
    [DllImport("CallBackTenTimes.dll")]
    static extern void CallBackTenTimes( IntPtr proc );
}

GetFunctionPointerFromDelegate を利用したことで、ネイティブ関数の宣言で引数がIntPtrになっています。

さて、ところでデリゲートオブジェクトを引数に直接与える例と比べると手間が増えていることに気づくでしょうか。「デリゲートオブジェクトを引数に与える」という表現は C# のコードを書く人間の立場に立った表現ですが、実際にはそのデリゲートオブジェクトから関数ポインタを取り出す作業を .NET Framework がやっています。つまり関数ポインタを自分で取り出すこの方法では、.NET Framework がやっていた仕事をわざわざ自分で行う分だけ手間が増えていることになります。

この方法のメリットは、「手間の軽減」です。仮に、異なるシグネチャのコールバック関数を受け取る SetCallback という関数があるとしましょう。その場合、関数ポインタを取り出さないと次のようにコールバック関数の数だけ SetCallback の宣言をする必要があります。

delegate Int32 CallbackType1Proc( Int32 x, Int32 y );
delegate void CallbackType2Proc();
delegate Int32 CallbackType3Proc( UInt32 a );
...

// コールバック1用
[DllImport("hoge.dll")]
static extern IntPtr SetCallback (
    Int32             callbackType,
    CallbackType1Proc callback
);

// コールバック2用
[DllImport("hoge.dll")]
static extern IntPtr SetCallback (
    Int32             callbackType,
    CallbackType2Proc callback
);

// コールバック3用
[DllImport("hoge.dll")]
static extern IntPtr SetCallback (
    Int32             callbackType,
    CallbackType3Proc callback
);
...

これに対して、関数ポインタを取り出せばネイティブ関数の宣言は次のように一つですみます。

delegate Int32 CallbackType1Proc( Int32 x, Int32 y );
delegate void CallbackType2Proc();
delegate Int32 CallbackType3Proc( UInt32 a );
...

// すべてのコールバックタイプで共通
[DllImport("hoge.dll")]
static extern IntPtr SetCallback (
    Int32  callbackType,
    IntPtr callback
);

このように宣言を書く手間を軽減できる点は、一応メリットと呼べると思います。しかし、筆者としては以下の理由でこの方法をおすすめしません。

  • 結局呼び出し側の手間が増えているので本当に手間を軽減できているのか怪しい (呼び出し箇所が多くなると逆に手間が増える)
  • 型制約が効かなくなる (異なるシグネチャのメソッドを与えられるため不注意エラーが起こりやすくなる)
  • 取り出した関数ポインタはC#の範囲外 (後述の生存期間の問題など)

Calling convention の問題

問題

C/C++ の関数には calling convention というものがあります。calling convention が何を指しているのかはさておき、重要なのは、この calling convention の指定を間違えると正常な呼び出しができないことです。

前章で挙げた例をみると、CallBackTenTimesProc 関数ポインタの型定義に __stdcall と書いてあります。これはその関数の calling convention が stdcallであることを示しています(この命令が使えるかどうかはコンパイラ依存)。したがって、CallBackTenTimes 関数へ与えるコールバック関数はstdcallでなければなりません。

さて、続いてC#の方に目を移します。細かい話は知りませんが、C# のメソッド(を包んだデリゲートオブジェクト)を関数ポインタとして P/Invoke すると stdcall の関数になるようです。したがって、もし次のようなネイティブコードの関数を P/Invoke する場合は先ほどの例と同じようにはいきません。

void __cdecl
qsort( void* items,
       size_t itemCount,
       size_t itemSize,
       int (__cdecl * compare_proc)( const void* item1, const void* item2 )
    );

この例はC言語標準ライブラリの qsort 関数です。この関数の第四引数は calling convention が cdecl のコールバック関数へのポインタです。したがって、この関数を先ほどと同じように P/Invoke すると正常に実行できずクラッシュします。

解決方法

この問題を C# のコード上で解決するには、.NET Framework 2.0 以降で導入された System.InteropServices.UnmanagedFunctionPointerAttribute 属性を使って「cdecl でなければならないコールバック関数のデリゲート型」を修飾します。先ほどの qsort の例を次に示します。

// qsort 用の cdecl な比較関数の宣言
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
delegate int CompareProc( IntPtr item1, IntPtr item2 );

// qsort 関数
[DllImport("msvcrt.dll", CallingConvention=CallingConvention.Cdecl)]
static extern unsafe void qsort( void* items, IntPtr itemCount, IntPtr itemSize, CompareProc proc );

普通のコールバック関数を使う例と比べるとデリゲート型に属性が一つ付くだけです。これで、問題無く呼び出しできるようになります。

閑話休題 .NET Framework 1.1 以前では、残念ながらC#側で対応することはできません。どうしても .NET Framework 1.1 以前のプログラムからこのような関数を P/Invoke したい場合、「C# プログラムから受け取ったstdcallな関数」を呼び出すだけの cdecl な関数が、最終的に qsort の引数として渡されるようにすれば問題ありません。具体的な実装例を、雑な実装ではありますがサンプルプログラムとして作成しています。必要な方は、このページの最後からダウンロードしてご参照ください。とはいえ筆者としては、よほどの理由が無い限り2.0へ移行するべきと思います。2.0への移行はこれ以外にも様々なメリットがありますので・・・。

デリゲートオブジェクトの生存期間の問題

ネイティブ関数へ C# のデリゲートオブジェクトを与える場合、注意しなければならない点がもう一つあります。ネイティブ関数が、受け取った関数ポインタを後になってアクセスするためにどこかへ登録するような場合は、デリゲートオブジェクトへの参照を残して生存期間をしっかり確保してやる必要があります。たとえばウィンドウプロシージャを書き換える場合などが該当します。

以下、私が勝手に行った推察を述べます。C++ のクラスメンバ関数の仕様を考えれば分かるとおり、関数のシグネチャー(用語の使い方が正しいか怪しいですが)は this ポインタを第一引数に取るものになります。つまりネイティブコード側から見ると、同じ返値、引数であっても静的か非静的かによってシグネチャーが異なります。しかし C# のデリゲートオブジェクトは静的・非静的に関わらず包むことができ、ネイティブ関数に渡すと静的メソッドのようなシグネチャーになっています。ということは、デリゲートオブジェクト内で何らかの処理をしており、ネイティブ関数に渡される関数ポインタは「マネージメソッドの静的・非静的の違いを吸収する、のり付け関数」へのポインタになっているのではないかと考えられます。そして、おそらく「のり付け関数」は実行中にデリゲートオブジェクトが動的に生成してメンバーとして保持するのでしょう。となれば、デリゲートオブジェクトがガベージコレクションによって破棄されると、その「のり付け関数」は消えて無くなります。すると、ネイティブ関数に渡された関数ポインタの値が無効になります。

以上の推察が正しいとすれば、ネイティブ関数を呼び出してから制御が返ってきた後でもネイティブ側の別メソッドで参照されるような関数ポインタ引数には、ローカル変数のデリゲートオブジェクトを渡すわけにはいかないと言えます。実際、引数で受けた関数ポインタを記憶して、新しくスレッドを立ててその関数ポインタを呼び続けるようなネイティブ関数を作り、ローカル変数のデリゲートオブジェクトを渡してガベージコレクションを強制実行してみたところ、確実に NullReferenceException が起こりました。逆にデリゲートオブジェクトをローカル変数ではなくクラスのメンバーに保持して同じことをしてみたところ、正常に実行できました。推察が正しいかどうかは分かりませんが、問題への対策としては生存期間を確保することで正しい解であるようです。

ウィンドウプロシージャを書き換える SetWindowLong を呼び出す場合にローカル変数のデリゲートオブジェクトを使うとどうなるかを図示してみました。

f:id:sgryjp:20181102133715p:plain

謝辞

.NET Framework 2.0 では UnmanagedFunctionPointerAttribute 属性を使うことでコールバック関数の calling convention を指定可能であることをお知らせくださった creeper さんに、この場を借りて感謝申し上げます。

サンプルプログラムのソース

cdecl な関数ポインタを引数に取るネイティブ関数を呼ぶサンプルプログラムをダウンロードできるようにしておきました。興味のある方は次のリンクからどうぞ。なお、簡単な説明書き (README.md) が同梱されていますのでそちらも参照してください。

github.com