🔧

CppSharp で Native Binding を楽に行う

2022/06/26に公開

はじめに

現在、 Unity + ネイティブプラグインで趣味開発をしています。

.NET でネイティブコードを使うには P/Invoke の記述が必要になります。 P/Invoke 定義は数個くらいの関数だったら手書きでも構わないのですが、数が多かったりネイティブ側のインターフェースが Fix されてなくて頻繁に変更が発生したりするとそれに追従するのも手間が大変になるわけです。構造体や列挙型の定義も面倒です。

こういった場合、 P/Invoke のコードを自動生成してくれるツールを使うのがよいです。今回試してみたツールが

の 2 つになります。どちらも有名な .NET のプロダクトに採用されているのですが、 SharpGenTools については結局うまく使うことができなかったので CppSharp で進めています。

今回の記事では趣味開発で得た知見を整理したものです。単純な C の関数を呼ぶところから C# のインターフェースをネイティブ側からコールバックで呼べるようにするところまでやっていきます。

サンプルプロジェクトはこちらです。

https://github.com/aosoft/CppSharpSample

Windows で確認していますがネイティブコードは CMake プロジェクトで作成しています。

CppSharp を使うための準備

ネイティブコードの準備

ネイティブコードとの Binding が目的ですので、まずネイティブコードを用意してください。 DLL の export 定義をしたヘッダーファイルが必要です。

CppSharp の準備

次に CppSharp を用いて Binding コードを生成するツールのプロジェクト (今回は CppSharpSample-bindgen) 用意します。 .NET のコンソールプロジェクトを作成し、そのプロジェクトに "CppSharp" のパッケージを NuGet からインポートします。

https://www.nuget.org/packages/CppSharp/

ILibrary 実装クラスの作成

CppSharp では出力内容の細かい指定を ILibrary 実装クラスで行います。

このうち必須なのは Setup メソッドくらいで他は空実装でも問題ありません。必要に応じてカスタマイズしていけばよいかなと思います。

Library.cs
public void Setup(Driver driver)
{
    driver.Options.GeneratorKind = GeneratorKind.CSharp;
    driver.Options.OutputDir = _outputPath;
    var module = driver.Options.AddModule("CppSharpSampleNative");
    module.OutputNamespace = "CppSharpSampleBinding";
    module.IncludeDirs.Add(_includePath);
    module.Headers.Add("header.h");
}

Setup メソッドでは

  • 出力先パス
  • import する DLL に関する設定
    • DLL 名
    • 生成時の名前空間
    • 対応するヘッダファイル名
    • include フォルダパス

などを設定します。

今回はこれに加えて

Library.cs
public void SetupPasses(Driver driver)
{
    driver.Generator.Context.ParserOptions.LanguageVersion =
        CppSharp.Parser.LanguageVersion.CPP17;
}

として使用言語を C++17 と明示指定しています。

Preprocess, Postprocess ではモジュール、クラス、メンバー単位で生成するコードの細かい制御をすることができます。ただ指定しても効果が正しくきかないものも多い感じで基本的にはあまりいじらない方がいいかなという感じです。

C/C++ の構造体、クラスは .NET のクラスとして生成されますが、これを構造体 (値型) にもできます。しかしメモリ管理観点から適切な実装にはならなさそうなのと、どうしても値型を使いたい場合は __Interanal という内部構造体を直接使う事もできるのでクラスのままでいいかなという感じです。

今回のサンプルでは Name を OriginalName で上書きをしています。 CppSharp ではデフォルトではメソッド名等を C# 標準の Pascal Case に変換するのですが、 OriginalName で上書きすることでオリジナルの定義に戻しています。個人的には自動生成の段階ではオリジナルのままにしておきたいです (後述のラッパーライブラリのレベルで C# 準拠にする) 。

.NET ラッパーライブラリプロジェクトの準備

CppSharp で生成したコードはそのまま使うでも構わないのですが、一般的な開発者から見た場合は使いにくい場合が多いと思うので、 CppSharp 生成コードを元にしたラッパーライブラリの実装をお勧めします。

結局手作業で何か作るなら最初から P/Invoke コードを手作業で作っても同じでは?と思うかもしれませんが、ネイティブコードと直接接続する部分を自動生成してくれれば、この部分の実装ミスをなくせるという大きなメリットが得られます。

また CppSharp で生成したコードは高い確率で unsafe を含んだコードを生成しますが、ラッパープロジェクトライブラリで unsafe 部分を隠蔽すれば実際のアプリ側プロジェクトでは unsafe なしにすることもできるようになります。

コード生成の実行

コード生成は CppSharp.ConsoleDriver.Run でパラメーターに ILibrary 実装のインスタンスを指定します。

CppSharp でのコード生成は少なくとも C/C++ の include パスと生成コードの出力先パスを設定する必要がありますが、私の実装では Library クラスのインスタンス生成時にそれらのパスを指定するようにしています。

Program.cs
var currentDir = Environment.CurrentDirectory;
while (true)
{
    var info = Directory.GetParent(currentDir);
    if (info == null)
    {
        break;
    }

    var includeDir = Path.Combine(info.FullName, "cpp", "include");
    if (Directory.Exists(includeDir))
    {
        var outputDir = Path.Combine(info.FullName, "dotnet", "CppSharpSample", "CppSharpSample", "Generated");
        if (!Directory.Exists(outputDir))
        {
            Directory.CreateDirectory(outputDir);
        }

        CppSharp.ConsoleDriver.Run(new CppSharpSampleBindgen.Library(includeDir, outputDir));
        break;
    }

    currentDir = info.FullName;
}

必ずしもこうしなければならない、という事ではありませんがポイントとして、

  • ネイティブ、 CppSharp Binding 生成、ラッパープロジェクトは同一リポジトリ内に配置する
    • これらのプロジェクトは相互に依存しているので、同一リポジトリ内に配置して相対パスでプロジェクト間を跨いだパス指定をできるようにした方がよい
  • Binding 生成プロジェクトを基準に親フォルダをたどっていき、ネイティブの include パスを探索する
  • 同様にラッパープロジェクトを探索し、ラッパープロジェクト内の "Generated" フォルダを出力パスに設定する

としています。一例として参考にしてください。

生成されたコードを使う

今回のサンプルでのネイティブ側の定義は次の通り。

CppSharp は C++ に対応しているので extern "C" がなくても C++ のマングリングにも合わせてきますが、 C++ である必要がないなら extern "C" はつけた方が望ましいでしょう。

基本

header.h
std::int32_t DLLEXPORT Sum(std::int32_t a, std::int32_t b);

単純な C 言語の関数です。これは次のようになります。

CppSharpSampleNative.cs
[SuppressUnmanagedCodeSecurity, DllImport("CppSharpSampleNative", EntryPoint = "Sum", CallingConvention = __CallingConvention.Cdecl)]
internal static extern int Sum(int a, int b);

使う側はこの P/Invoke メソッドを直接呼ぶのではなく、ラップしたメソッド呼びます。

CppSharpSampleNative.cs
public static int Sum(int a, int b)
{
    var __ret = __Internal.Sum(a, b);
    return __ret;
}

関数ポインタ

header.h
using FnSum = std::int32_t (*)(std::int32_t, std::int32_t);

std::int32_t DLLEXPORT Sum2(FnSum fn, std::int32_t a, std::int32_t b);

CppSharpSampleNative.cs
public unsafe delegate int FnSum(int __0, int __1);

public static int Sum2(global::CppSharpSampleBinding.FnSum fn, int a, int b)
{
    var __arg0 = fn == null ? global::System.IntPtr.Zero : Marshal.GetFunctionPointerForDelegate(fn);
    var __ret = __Internal.Sum2(__arg0, a, b);
    return __ret;
}

となり、 GetFunctionPointerForDelegate を使ったコードを生成してくれるので、 C# で定義したメソッドをネイティブ側に渡す事が可能になっています。

ポインタと文字列

ポインタは基本的に IntPtr か .NET のポインタに変換されますが、 "const char *" と "const wchar_t *" は特例で string にマーシャリングするコードになります。 string マーシャリングを無効にする方法はないようです。

"const char *" は UTF-8 文字列にマーシャリングをするようですが、 .NET ランタイムの機能ではなく CppSharp の実装を使うようです。これは昔の .NET Framework に UnmanagedType.LPUTF8Str はなかったのでまあ仕方ないのですが、 UnmanagedType.LPUTF8Str を使うモードやそもそも UnmanagedType.LPStr も使える (指定できる) ようにしてほしいです。

"const wchar_t *" は マニュアル によると

Wide strings are marshaled either as UTF-16 or UTF-32, depending on the width of wchar_t for the target.

とあるのでコンパイラによって挙動が変わりそうです。クロスプラットフォーム時は要注意かもしれません。まあアライメントの問題もありますが・・・

ちなみに "const char *" だと string にマーシャリングされましたが "char *" だと "sbyte*" とそのままにしてくれたので、マーシャリングを避けたい場合は const を外してしまうのも手と思います。

CppSharp のランタイム

生成されたコードは多くの場合、単体でそのまま使うことができますが、一部で特殊な処理に対応するためにランタイムを必要とする場合があります。

  • const char * 文字列のマーシャリング
  • C++ クラス

そうなった場合は生成コードを使うプロジェクト (ラッパーライブラリ) 側で CppSharp.Runtime のパッケージを NuGet から追加します (Runtime は生成するプロジェクトでは不要です) 。

https://www.nuget.org/packages/CppSharp.Runtime

C++ クラスで連携する

CppSharp はなんとびっくりなのですが、 C++ のクラスを .NET のクラスとして Binding するコードを生成してくれます。これを使うとクラスの形のまま .NET でアクセスすることが可能です。実際生成したコードを見てみると、 vtable をがんばっていじりまわして使えるようにするコードが生成されています。また、仮想メソッドを C# で C++ クラス (の Binding クラス) をオーバーライドし、それをネイティブ側に持ち込む事も考慮されているようです。

ただ実際に試してみるとうまく動作せず、不正アクセスになってしまいました。

https://github.com/mono/CppSharp/issues/1645

仮に動いたとしても、結構複雑なコードが生成されるのでちょっと使用には気がひけます。

そんなわけで、 C 言語ベースの関数ポインタテーブル + コンテキストの受け渡しで C++ のクラスを .NET で使ったり、 .NET のインターフェース実装を C++ 側から呼べるようにしていきたいと思います。

.NET → ネイティブ

やることは昔書いた下記の記事と大体同じです。

https://qiita.com/tan-y/items/64711b244cf294d6bb9d

違うところは関数テーブルが配列ではなく CppSharp で生成した Binding クラスを用いるところです。

まず C 側で関数テーブルの構造体を定義します。

header.h
struct NativeFunctionTable
{
    void (*Destroy)(void *);
    std::int32_t (*GetValue)(void *);
    void (*SetValue)(void *, int32_t);
    void (*Print)(void *);
};

引数の void * はコンテキスト用のポインターなので必ずつけます。

続いて C++ でクラス定義し、上記の関数テーブルにマッピングします。過去記事にも書きましたが C++ クラスメンバの関数ポインタは通常のポインタとは互換性がない (キャストできない) ため中継メソッドを定義します。中継メソッドの実装もテンプレートで省力化します (このやり方の詳しい説明は 過去記事 を参照してください) 。

source.cpp
class NativeClass {
public:
    void Destroy();
    std::int32_t GetValue();
    void SetValue(int32_t value);
    void Print();
};

template<typename T, typename TRet, typename ... Args>
struct Proxy {
    template<typename TRet(T::*func)(Args...)>
    static TRet Func(void *context, Args... args) {
        return (reinterpret_cast<T *>(context)->*func)(args...);
    }
};

const NativeFunctionTable DLLEXPORT *GetFunctionTable(){
    static const NativeFunctionTable Table = {
        Proxy<NativeClass, void>::Func<&NativeClass::Destroy>,
        Proxy<NativeClass, std::int32_t>::Func<&NativeClass::GetValue>,
        Proxy<NativeClass, void, std::int32_t>::Func<&NativeClass::SetValue>,
        Proxy<NativeClass, void>::Func<&NativeClass::Print>
    };
    return &Table;
}

void DLLEXPORT *CreateNativeContext() {
    return new NativeClass();
}

以上を定義して CppSharp でコード生成します。

次に生成コードを元にしてラッパークラスの実装をします。

NativeInterfaceCpp.cs
public sealed class NativeInterfaceCpp : IDisposable
{
    private readonly CppSharpSampleBinding.Delegates.Action___IntPtr _destroy;
    private readonly CppSharpSampleBinding.Delegates.Func_int___IntPtr _getValue;
    private readonly CppSharpSampleBinding.Delegates.Action___IntPtr_int _setValue;
    private readonly CppSharpSampleBinding.Delegates.Action___IntPtr _print;

    private IntPtr _context;

    public NativeInterfaceCpp()
    {
        var native = CppSharpSampleBinding.header.GetFunctionTable();
        _destroy = native.Destroy;
        _getValue = native.GetValue;
        _setValue = native.SetValue;
        _print = native.Print;
        _context = CppSharpSampleBinding.header.CreateNativeContext();
    }
    
    public void Dispose()
    {
        if (_context != IntPtr.Zero)
        {
            _destroy(_context);
            _context = IntPtr.Zero;
        }
    }

ポイントですが

  • GetFunctionTable で取得した (Binding の) NativeFunctionTable から各メソッドのデリゲートを取得し、フィールドにおいておきます
    • GetFunctionTable で取得した Binding の NativeFunctionTable はキャッシュされるので放置、毎回取得で問題ないです
      • ネイティブ側も GetFunctionTable で返すポインタは static の固定テーブルです
    • Binding の NativeFunctionTable のプロパティで公開されているデリゲートですが、毎回 GetDelegateForFunctionPointer を呼び出しているので、面倒ですがキャッシュしておいた方が無難かなと思います
  • CreateNativeContext で C++ クラスインスタンスを生成、そのポインタを取得しておきます
  • Dispose の実装で C++ 側の Destroy を呼び、インスタンス破棄をするようにします
NativeInterfaceCpp.cs
    public int Value
    {
        get => _getValue(_context);
        set => _setValue(_context, value);
    }

Binding を通してアクセスする際、コンテキスト = C++ クラスインスタンスなので、インスタンスを合わせて渡してネイティブ側のコードを呼び出します。

ネイティブ → .NET (コンテキスト管理なし)

今度は逆方向で .NET のインターフェースをネイティブ側から呼べるようにしましょう。これも .NET → ネイティブと同じで関数テーブル定義の構造体を利用します。ここでは先ほど定義した NativeFunctionTable をそのまま使いまわしますが、実際は個別に定義することになるでしょう。

まず .NET 側のインターフェースを定義します。

INativeInterface.cs
public interface INativeInterface : IDisposable
{
    int Value { get; set; }
    void Print();
}

次に INativeInterface の実装を呼び出すデリゲートを設定した Binding の NativeFunctionTable を生成する処理を実装します。

NativeFunctionTable.Extensions.cs
namespace CppSharpSampleBinding
{
    public partial class NativeFunctionTable
    {
        public static NativeFunctionTable CreateNativeFunctionTable(CppSharpSample.INativeInterface csharp)
        {
            var ret = new NativeFunctionTable();
            ret.Destroy = _ => csharp.Dispose();
            ret.GetValue = _ => csharp.Value;
            ret.SetValue = (_, value) => csharp.Value = value;
            ret.Print = _ => csharp.Print();
            return ret;
        }
    }
}

Binding が partial class として出力されるので、それを利用して Binding 側に組み込む形で実装しました。

Binding のデリゲートプロパティはそのまま GetFunctionPointerForDelegate によりネイティブの関数ポインタに変換されます。また、関数ポインタはその関数へのアドレスにしかすぎないのですが、デリゲートは 関数ポインタ + コンテキスト であるため .NET → ネイティブ時では必要だったネイティブ側のコンテキスト (C++ クラスインスタンス) を別途渡す必要はなく、関数ポインタテーブルだけでいけてしまうという違いがあります (代わりに関数テーブルのポインタは静的ではなく動的に生成されているものということになるので扱いに注意) 。上記のコードの場合、 csharp 変数がラムダ式内にキャプチャされ、それがデリゲートに含まれている事を意図していることになります。

header.h
void DLLEXPORT TestCallback(const NativeFunctionTable *funcTable, void *context);

この関数は関数ポインターテーブルを要求しています。これを .NET 側から呼び出す時は次のようになります。

Static.cs
public static void TestCallback(INativeInterface intf)
{
    using var native = CppSharpSampleBinding.NativeFunctionTable.CreateNativeFunctionTable(intf);
    CppSharpSampleBinding.header.TestCallback(native, IntPtr.Zero);
}

元々の定義は C/C++ で使う事を想定してコンテキストのポインタを渡すように定義していますが、 .NET から呼ぶ時は前述の通り関数ポインタテーブル自体にコンテキストを含んでしまっているので null 渡しをしています (ので .NET からしか使わないのであれば void *context はなくても OK です) 。ネイティブ実装である TestCallback から .NET のコードを呼び出し、実行がされます。

ネイティブ → .NET (コンテキスト管理あり (Unity 向け))

(2022/7/18 追記)

前節で書きましたが、 .NET の理屈からデリゲートにはコンテキストを含むので個別管理は必要がないはずなのですが、前節の実装を Unity 上で実行すると Unity 自体が不正アクセスで落ちるという現象が発生します。

これはかなり悩まされていたのですが、どうもネイティブ側からマネージド側にアクセスする際に GC が動作してインスタンスがなくなっているかのように見える (?) 事がある ようなのです。ちなみに変数が null かどうかチェックしようとする (if (xx == null) とか) だけで例外が出るといった状態です。マネージドは確かに実体のポインターは不定かもしれませんがマネージドの参照まで怪しくなるのはちょっと不可解です。

これは GCHandle.Alloc で GC から保護した上で GCHandle のポインターをコンテキストとしてネイティブ側に渡し、このポインターを経由してアクセスすることで安定するようになりました。

NativeFunctionTable.Extensions.cs
namespace CppSharpSampleBinding
{
    public partial class NativeFunctionTable
    {
        public static NativeFunctionTable CreateNativeFunctionTableWithContext(CppSharpSample.INativeInterface csharp)
        {
            var ret = new NativeFunctionTable();

            //  他メンバは省略
            ret.SetValue = static (contextPtr, value) =>
            {
                var context = GCHandle.FromIntPtr(contextPtr).Target as CppSharpSample.INativeInterface;
                if (context != null)
                {
                    context.Value = value;
                }
            };
            
            return ret;
        }
    }
}

長くなるので一部省略 (完全なコード)

各コールバックの実装で、コンテキスト管理なしでは第一引数は無視していましたが、今回はここで渡されるポインターはマネージドコールバックであるはずなので、 GCHandle.FromIntPtr でポインターからマネージドコードに変換し、対応するコールバック実装を呼び出します。

呼び出し側もちょっと面倒になりますが

Static.cs
public static void TestCallback(INativeInterface intf)
{
    using var native = CppSharpSampleBinding.NativeFunctionTable.CreateNativeFunctionTableWithContext(intf);
    
    var h = GCHandle.Alloc(intf);
    var context = GCHandle.ToIntPtr(h);
    try
    {
        CppSharpSampleBinding.header.TestCallback(native, context);
    }
    finally
    {
        h.Free();
    }
}

Handle.Alloc と GCHandle.ToIntPtr で GC からの保護とポインター取得します。これをコンテキストとして渡します (管理なし版では IntPtr.Zero を渡していました) 。

呼び出しがおわったら GCHandle.Free を用いて context の解放をします。

毎回 GCHandle.Alloc / Free が面倒な場合はラッパーに保護した GCHandle を保持するという方法もとれます。

NativeFunctionTable.Extensions.cs
namespace CppSharpSampleBinding
{
    public partial class NativeFunctionTable
    {
        internal GCHandle Context { get; private set; }

        public static NativeFunctionTable CreateNativeFunctionTableWithContext(CppSharpSample.INativeInterface csharp)
        {
            var ret = new NativeFunctionTable();

            //  他メンバは省略
            ret.SetValue = static (contextPtr, value) =>
            {
                var context = GCHandle.FromIntPtr(contextPtr).Target as CppSharpSample.INativeInterface;
                if (context != null)
                {
                    context.Value = value;
                }
            };
            
            ret.Context = GCHandle.Alloc(csharp);
            return ret;
        }

        partial void DisposePartial(bool disposing)
        {
            if (Context.IsAllocated)
            {
                Context.Free();
                Context = default;
            }
        }
    }
}

Context プロパティに保護したポインターを保持し、 DisposePartial (CppSharp の生成クラスに組み込んでいるので、独自の Dispose は DisposePartial に記述します) で GCHandle.Free しています。

この場合の呼び出しは

Static.cs
public static void TestCallback(INativeInterface intf)
{
    using var native = CppSharpSampleBinding.NativeFunctionTable.CreateNativeFunctionTableWithContext(intf);
    CppSharpSampleBinding.header.TestCallback(native, GCHandle.ToIntPtr(native.Context));
}

となります。

ただこちらのやり方は長時間保護する事になるため GC 的にはあまり望ましくないようにも思うので避けた方がよいかもしれません。

Unity で使う

CppSharp で生成されたコードは Unity で問題なく使用できます。 CppSharp.Runtime が必要になるコードが生成されたとしても CppSharp.Runtime は .NET Standard 2.0 なので、 NuGet から取得して Plugin フォルダにコピーすれば OK です。

CppSharp が生成したソースをそのまま Unity に持ってくるのもよいですが、ラッパーライブラリを通常の .NET プロジェクトで実装、テストした上でビルドした .dll を持ってくるのもよいと思います。

Unity でネイティブ → .NET のコールバック呼び出しは、前節で書いたマネージドインスタンスの扱いや、 std::thread などアンマネージドスレッドからの呼び出しはできないといった注意点があります。割とはまりやすいので Unity においては避けられるなら避けておいた方が無難そうです。

おわりに

自分の望む理想的なコードは手書きで書くのが最良で、今回の記事でも CppSharp で自動生成するといいながらも結局かなり手書きをすることになっていて、 CppSharp を使う意味はあまりないのではと思うかもしれません。

CppSharp のような自動生成ツールを使う最大のメリットは 間違いやすい C/C++ のコードを見ながら直接対応する C# コードを書く、という事をやらずにすむ という点にあります。ラッパーライブラリの実装などは IDE の補完もきくのでたいした負担ではありません。個人的には関数ポインタのデリゲート定義を生成してくれるのが本当にありがたいと思いました。

私の作業状況が "ネイティブ側のメソッドが多い" "ネイティブ側のインターフェースが Fix されてなく変更が頻繁にある" というのにまさに当てはまっており、手作業で書くのは現実的ではないという判断になりました。このような状況では CppSharp の利用は有力な選択肢であると思います。ネイティブコードを利用する際は是非検討してみてください。

Discussion