0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

C/C++ DLL から C# プログラムへのコールバックを実現する方法

Last updated at Posted at 2025-01-18

はじめに

筆者は、C++ で作ったDLLを C# アプリケーションから呼び出して使う事があります。その際、C/C++ で書かれた DLL と C# アプリケーションを連携させるうえで、C++のDLLからのコールバック、つまり ネイティブコード → C# の呼び出し(リバース P/Invoke)は必須であり、重要なポイントとなります。

本記事では、.NET 7 で登場し、.NET 8、.NET 9 でさらに進化した LibraryImport を活用したコールバックの実装方法を解説します。


1. さらに進化した P/Invoke: LibraryImport

1.1 DllImportLibraryImport の違い

  • DllImport(.NET Framework 1.0 から存在)

    • 開発者が手動で設定を行うため、マーシャリングや呼び出し規約の誤りによる実行時エラーが起こりやすい。
    • 実行時オーバーヘッドが高くなる場合がある。
    • LoadLibrary と組み合わせて動的に DLL を読み込むユースケースにも対応しやすい。
  • LibraryImport (.NET 7 で登場し、.NET 8、.NET 9 で進化)

    • ソースジェネレーターが P/Invoke 呼び出し用コードをコンパイル時に自動生成。
    • 型安全性やパフォーマンス、コードの簡潔さが向上。
    • ただし、動的ライブラリ読み込み (LoadLibrary) は現時点で未対応(静的に解決される P/Invoke 呼び出しのみ可能)。

LibraryImport において、動的ライブラリ読み込み (LoadLibrary) は .NET 9 においても未対応 なので、動的ライブラリ読み込みが必要な場合は、今まで通り DllImport を使う必要があります。


1.2 LibraryImportの進化:.NET 7から.NET 9まで

LibraryImportは、.NET 7で登場した新しいP/Invoke(プラットフォーム呼び出し)の仕組みとして誕生し、.NET 8、.NET 9を経て、より効率的で使いやすい形へと進化しました。以下に主な変遷をまとめます。

.NET 7: 導入と基本機能

  • 初登場
    従来のDllImportの代替として導入され、ネイティブライブラリとの相互運用を簡単かつ効率的に行える新しい方法として注目されました。

  • ソースジェネレーターの活用
    P/Invokeコードをコンパイル時に自動生成し、パフォーマンスとメンテナンス性を向上。

  • 基本的な特徴

    • 手動の型変換や低レベルコードの記述を大幅に削減。
    • ネイティブ関数呼び出しのパフォーマンスが改善。

.NET 8: 安定版と強化

  • 正式リリース
    プレビュー機能から安定版となり、本番環境でも使用が推奨されるように。

  • 進化した機能

    1. プラットフォーム依存性の制御強化
      異なるOSでのネイティブライブラリのロードや関数呼び出しが簡単になり、クロスプラットフォーム対応が向上。
    2. エラーハンドリングの改善
      ネイティブ関数呼び出し時のエラーチェックが強化され、デバッグが容易に。
    3. メモリ管理の最適化
      ヒープアロケーションを減らし、より効率的なメモリ利用を実現。
    4. カスタム型サポート
      複雑なデータ型や構造体のマーシャリングが簡略化され、柔軟性が向上。
  • パフォーマンス向上
    ネイティブ関数を頻繁に呼び出す場合でも、低レイテンシを実現する設計へ進化。

.NET 9: 最適化の完成形

  • マルチプラットフォームサポートの強化
    よりシームレスなクロスプラットフォーム開発が可能になり、異なるプラットフォーム向けの設定が一層簡単に。

  • 高度なエラーハンドリング
    ネイティブコードで例外が発生した際、診断情報が詳細化されており、デバッグ効率が大幅に向上。

  • ソースジェネレーターのさらなる改良

    • 生成されるコードがさらに最適化され、ネイティブ関数呼び出し時のオーバーヘッドをいっそう削減。
    • カスタマイズ性が向上し、開発者が独自のマーシャリングや特殊な設定を行いやすくなった。
  • テスト支援の拡張
    ユニットテストやモックを用いたP/Invokeのテストがより容易に。

全体のまとめ

  1. .NET 7LibraryImport が登場し、P/Invoke のソースジェネレーターによるコード生成で簡便化とパフォーマンス向上が実現。
  2. .NET 8 で正式版となり、クロスプラットフォーム対応、エラーハンドリング、メモリ管理、カスタム型サポートが強化。
  3. .NET 9 においてさらに最適化が進み、開発者体験(DX)の向上とテストのしやすさが大幅に改善。

LibraryImport は、.NETエコシステムにおけるネイティブ連携を効率化し、コードのシンプルさとパフォーマンスの両立を実現する重要な進化の一つといえます。


1.3 LibraryImport の主な利点

  1. ソースジェネレーター: 自動生成されるコードにより、設定ミスやマーシャリングの煩雑さが大幅に軽減される。
  2. 型安全: コンパイル時にエラーが検出されやすく、実行時のクラッシュを未然に防ぐ。
  3. パフォーマンス: 事前に最適化されるため、従来の DllImport より高速。
  4. シンプルなコード: [LibraryImport] 記述が比較的短く、メンテナンス性が高い。

1.4 .NET 9 の LibraryImport による自動マーシャリング

.NET 9LibraryImport では、ソースジェネレーターによる 自動マーシャリング 機能がさらに強化されています。以下が主な特徴です。

  1. プリミティブ型の自動変換

    • int, float, double などの基本型は、追加設定なしで自動マーシャリング可能。
    • 呼び出し規約(CallingConvention)さえ一致していれば、C++ の int / float / double と透過的につなげられる。
  2. 文字列の扱い

    • 従来は [MarshalAs] などの属性を多用していたが、LibraryImport ではソースジェネレーターが最適コードを生成するため、開発者の手間が削減される。
  3. 構造体や複雑な型への対応

    • ネイティブとの構造体受け渡しにおいても、ソースジェネレーターが自動的にレイアウトを考慮。
    • アライメントずれや誤ったオフセットによるクラッシュを防ぎやすい。
    • 配列やカスタム型の場合も、サポートされているマーシャリング方式があれば自動生成の恩恵を受けられる。
  4. パフォーマンス面での最適化

    • .NET 9 でさらにヒープアロケーションを抑える仕組みを強化。
    • P/Invoke 呼び出しが多い場合でも、余計なコピーを減らすよう生成コードが工夫されている。
  5. カスタマイズ性

    • 完全自動だけでなく、独自のニーズに応じて [MarshalAs] やカスタムマーシャラーを組み合わせられる。
    • ソースジェネレーターの出力を拡張できるため、特殊なデータ構造や独自形式の文字列などに対応しやすい。

まとめ:
.NET 9LibraryImport は「自動生成されたコードによってミスを減らし、型安全性と高パフォーマンスを両立できるマーシャリング」を実現します。従来の DllImport で煩雑になりがちだった文字列変換や構造体のレイアウト管理も、かなり簡潔に書けるようになりました。

LibraryImportの自動マーシャリング機能を使いこなすうえでの注意点を以下の記事にまとめました。
.NET 9 の LibraryImport 自動マーシャリング機能を使いこなすうえでの注意点


2. リバース P/Invoke(コールバック)とは?

C# から C++ を呼び出す(P/Invoke)だけでなく、C++ → C# を呼び出すケースがあります。これを コールバックリバース P/Invoke と呼びます。

  1. C# 側でコールバック用の静的メソッドを用意。
  2. ネイティブコードに関数ポインタを渡す。
  3. ネイティブコードが任意のタイミングで C# メソッドを呼び出す。

2.1 コールバックの流れを図で解説

  • Point: コールバックは、「C# → C++ → C#」という形で呼び出しが循環する。

3. LibraryImport でコールバックを実装する例

3.1 C++ 側コード

#include <iostream>
typedef void (*CallbackFunc)(int);

extern "C" __declspec(dllexport) void RegisterCallback(CallbackFunc callback) {
    std::cout << "Registering callback..." << std::endl;
    // サンプルとして、すぐにコールバックを呼び出す
    callback(42); 
}
  • RegisterCallback: コールバック用関数ポインタを受け取り、例としてすぐに呼び出している。

3.2 C# 側コード

using System;
using System.Runtime.InteropServices;

class Program
{
    // 1) ネイティブコードを呼び出す(C# → C++)
    [LibraryImport("example.dll", CallingConvention = CallingConvention.Cdecl)]
    public static partial void RegisterCallback(IntPtr callback);

    // 2) コールバック対象の C# メソッド(C++ → C#)
    [UnmanagedCallersOnly(CallingConvention = CallingConvention.Cdecl)]
    public static void MyCallback(int value)
    {
        Console.WriteLine($"Callback invoked with value: {value}");
    }

    static void Main()
    {
        // 3) デリゲートを作成
        Action<int> callbackDelegate = MyCallback;

        // 4) デリゲート → 関数ポインタ
        IntPtr callbackPtr = Marshal.GetFunctionPointerForDelegate(callbackDelegate);

        // 5) ネイティブコードへ登録
        RegisterCallback(callbackPtr);

        // 実行すると C++ 側で 42 がコールバックされる
    }
}

解説ポイント

  1. LibraryImport("example.dll")
    ソースジェネレーターが呼び出しコードを生成し、RegisterCallback を簡単に呼び出せるようにする。

  2. [UnmanagedCallersOnly]
    ネイティブコードが直接呼び出せる形式のメソッドであると宣言。

    • 注意: このメソッドは マネージドコードからは直接呼び出せず、ネイティブコード経由でのみ呼び出せる
    • CallingConvention.Cdecl は C++ 側の呼び出し規約に合わせる。
  3. Marshal.GetFunctionPointerForDelegate
    C# 側のデリゲートを関数ポインタに変換し、C++ 側に登録。
    .NET 9 + LibraryImport でもここは手動の設定が必要。

  4. デリゲートの寿命管理

    • 例ではローカル変数(callbackDelegate)を利用しており、メソッド内ですぐ呼ばれているため問題なし。
    • しかし、長期間のコールバックに備えるなら、静的フィールドにデリゲートを保持して GC による回収を防ぐ必要がある。

4. なぜ Marshal.GetFunctionPointerForDelegate が必要?

LibraryImport は「C# → C++」を自動化する一方、「C++ → C#」の呼び出し(リバース P/Invoke)はまだ自動化されていません。

  1. デリゲートはマネージドオブジェクト
    ネイティブコードへ直接渡すにはポインタに変換する必要がある。

  2. コールバックはソースジェネレーターの対象外
    LibraryImport が自動生成するのはあくまで P/Invoke(C#→C++)の呼び出しコードであり、C++→C# の呼び出しは手動で設定する必要がある。

コールバックという観点だけで見れば、DllImport でも LibraryImport でもやり方はほぼ同じです。しかしながら、マイクロソフトの方針としては、今後は DllImport よりも LibraryImport の利用を推奨する流れになっています。詳しくは 6.3章を参照ください。


5. コールバック処理でのベストプラクティス

5.1 必要なときだけ関数ポインタを渡す(推奨)

  • 必要なタイミングで Marshal.GetFunctionPointerForDelegate を呼び出し、そのまま関数の引数としてネイティブに渡すことを推奨します。
  • 理由:
    • スコープが限定されるため、不要になった後はネイティブ側からの参照を切りやすく、長期間のポインタ保持による事故(不正アクセス)を減らせる。
    • 同時に複数のコールバックポインタを扱うときも、不要になったものを整理しやすい。

5.2 GC 管理(静的フィールドで保持する必要性)

  • 長期間コールバックが呼ばれる状況(DLL 内でコールバック ポインタをずっと保存しているなど)がある場合は、デリゲートを静的フィールドや長寿命の変数で保持し、ガベージコレクションによる回収を防ぐ必要がある。
  • ローカル変数で保持すると、メソッド終了後にデリゲートが回収され、クラッシュの原因になる可能性があるので注意。

5.3 呼び出し規約の一致(クロスプラットフォーム考慮)

  • C++ 側が __cdecl なら、C# 側も CallingConvention.Cdecl に合わせる。
  • Windows 以外(Linux, macOS など)では、__cdecl がデフォルトではない場合があるため、プラットフォーム依存の呼び出し規約に留意が必要。

5.4 DLL 配置

  • example.dll を実行ファイルと同じディレクトリなど、見つけやすい場所に置き、DllNotFoundException を回避する。
  • 動的に DLL をロードするシナリオ(LoadLibrary など)では、現状は LibraryImport ではなく従来の DllImport を使う選択肢も検討する。

5.5 ネイティブコード内のエラーハンドリング

  • コールバック呼び出し中にエラーが発生する可能性があるため、C++ 側で try-catch を使う、あるいは戻り値やログ出力などでエラー処理を行うのがおすすめ。
  • C# 側で例外を拾う場合、C# のメソッドに入るまでは例外を扱えないことを理解しておく。

5.6 長期間のコールバックとスレッドの考慮

  • ネイティブコードが別スレッドでコールバックを呼び出す可能性があるなら、スレッドセーフな設計や同期(lock, Mutex など)を考慮する。

5.7 (復習)デリゲート型とは?

デリゲート型とは、C# における 「特定のメソッド シグネチャを表す型」 のことです。C++ の関数ポインタに近い概念ですが、型安全であるため、引数や戻り値の型が明確に定義されます。

  1. メソッドへの参照を持てる

    • デリゲート変数は「メソッド本体」を参照し、動的に呼び出す対象を変更可能。
    • 例:
      public delegate void MyDelegate(int x);
      MyDelegate d = SomeMethod;
      d(100); // SomeMethod(100) が呼び出される
      
  2. コンパイル時の型チェック

    • デリゲート定義時に「引数と戻り値の型」が決まる。
    • これにより、メソッドのシグネチャが合わない場合はコンパイルエラーとなり、安全性が高い。
  3. マルチキャスト機能

    • += 演算子で複数のメソッドを登録し、デリゲート変数を呼び出すと全てのメソッドが実行される。
    • イベント(event)機構でも内部的にデリゲートが活用されている。
  4. C++ との連携にも利用可能

    • Marshal.GetFunctionPointerForDelegate で関数ポインタとして変換し、ネイティブ側に渡せるため、リバース P/Invoke のコールバックに非常に役立つ。

5.8 Action<T>Func<T, TResult> をコールバックで使うメリット

C# に標準で用意されている Action<T>(引数あり・戻り値なし)や Func<T, TResult>(引数あり・戻り値あり)などの汎用デリゲート型を利用すると、コールバック実装がさらにシンプルになります。具体的には以下のような利点があります。

  1. 自前でデリゲート型を定義しなくてよい

    • 例: Action<int, string> は “intstring を引数に取り、戻り値を返さないメソッド” を表す。
    • public delegate void MyCallback(int x, string s); のような独自デリゲートの定義を省けるため、コードがスッキリ。
  2. 型安全 & 開発時にエラーを検出しやすい

    • Action<T> / Func<T, TResult> はコンパイル時に引数や戻り値の型を検証する。
    • 意図しない引数型のミスマッチを早期に発見でき、デバッグ効率が向上。
  3. 複数の引数にも柔軟に対応

    • Action<T1, T2, …, T16> は最大 16 個の引数を扱え、複雑なコールバックでも対応可能。
    • 例: Action<int, double, string> といった形で、一度に複数の型を渡せる。
  4. ラムダ式や匿名メソッドと相性が良い

    • ラムダ式を使うと、インラインでコールバック処理を記述できるため可読性が高い。
    • 例:
      Action<int> callback = x => Console.WriteLine($"Value: {x}");
      
    • メソッドを定義せずにその場で実装できるメリットがある。
  5. 戻り値の有無を切り替えやすい

    • 戻り値が不要なら Action<T>、必要なら Func<T, TResult> を使用するだけでシグネチャを明確化できる。
    • C++ → C# コールバックで結果をネイティブ側に返す場合は Func<T, TResult> が便利。

注意: マーシャリングは別途必要

  • Action<T>Func<T, TResult> はあくまで「C# メソッドシグネチャ」を定義する手段であり、文字列や構造体などの非プリミティブ型をやり取りする場合、[MarshalAs]IntPtr を用いた変換などが依然として必要です。
  • ただし、.NET 9LibraryImport とソースジェネレーターの自動マーシャリング機能により、従来より記述量は少なくなる傾向にあります。

6. まとめ & 全体像の図解

6.1 全体の動作を図解

  1. C# 側でコールバックを関数ポインタ化 (Marshal.GetFunctionPointerForDelegate)
  2. RegisterCallback(P/Invoke)を通じてネイティブに渡す
  3. ネイティブでコールバックを呼び出し、C# に戻る

6.2 重要ポイントのまとめ

  • LibraryImport

    • .NET 7 で登場し、.NET 8、.NET 9 で進化した新機能で、主に「C# → C++」を自動化。
    • 静的に DLL を解決するシナリオにのみ対応し、動的ロードは未対応。
  • リバース P/Invoke

    • 「C++ → C#」の呼び出しには、UnmanagedCallersOnly を付与した C# メソッドを Marshal.GetFunctionPointerForDelegate でポインタ化し、ネイティブへ登録する必要がある。
    • [UnmanagedCallersOnly] メソッドはマネージドコードから直接呼び出せない点に注意。
  • コールバック用ポインタの管理

    • 必要な時だけポインタを生成・渡すやり方がより安全。不要になったら解放し、長期利用する場合は静的フィールドで保持。
  • デリゲート型の概念

    • C# の型安全なコールバック用仕組み。
    • マルチキャストやイベントとの連携が可能。
  • Action<T>, Func<T, TResult> の活用

    • 組み込みのジェネリック デリゲートを使うことで、戻り値の有無や引数の数に応じて柔軟に設計できる。
    • 文字列などは状況に応じてマーシャリングが必要。
  • 呼び出し規約の統一(クロスプラットフォーム)

    • デリゲートの寿命管理、呼び出し規約、ネイティブと C# の環境差異に注意。

6.3 DllImport と LibraryImport の使い分け

コールバック(C++ → C#)という観点だけで見れば、実際には DllImport でも LibraryImport でもやり方はほぼ同じで、[UnmanagedCallersOnly] メソッドと Marshal.GetFunctionPointerForDelegate が必要になる点に変わりはありません。しかしながら、マイクロソフトの方針としては、今後は DllImport よりも LibraryImport の利用を推奨する流れになっています。

  • 理由
    1. ソースジェネレーターによる型安全性・パフォーマンス向上
      ランタイムエラーの発生源を減らし、コンパイル時に不整合を検出しやすくなる。
    2. メンテナンスコストの低減
      P/Invoke 設定を明示的に書かずに済む部分が増え、長期メンテナンス時にも恩恵が大きい。
    3. 今後の開発リソース
      新機能や改善は LibraryImport 側が優先され、DllImport はレガシーに近い扱いとなっていく可能性が高い。

結論
コールバックがある場合も含めて、新規の案件では極力 LibraryImport を使うことが推奨されます。動的ロードなど特殊な要件がなければ、これからは LibraryImport をデフォルトの選択肢と考えた方がよいでしょう。


7. まとめ

LibraryImport によって、従来の DllImport よりも簡潔・高速・安全な P/Invoke が可能になりました。一方、コールバック処理(リバース P/Invoke) を行う際は、以下のポイントを押さえておきましょう。

  1. C# 側コールバックメソッドへの [UnmanagedCallersOnly] (※マネージド側から直接呼べない)
  2. Marshal.GetFunctionPointerForDelegate でデリゲートを関数ポインタ化
  3. ガベージコレクション対策(静的フィールド保持など)
  4. 必要なときだけポインタを渡す設計(推奨)
    • スコープを限定し、長期間の参照を残さないようにして安全性を高める。
    • 長期利用する場合は静的保持などで回収を防ぐ。
  5. デリゲート型を理解し、Action<T> / Func<T, TResult> を活用する
    • コールバックを簡潔・型安全に記述できる。
    • マーシャリング(特に文字列や構造体)には注意。
  6. 呼び出し規約の統一Cdecl, StdCall など、クロスプラットフォーム対応も含む)
  7. ネイティブコードのエラーハンドリングやスレッド安全性の考慮
  8. DllImport との使い分け
    • コールバックには両者で大差ないが、全体的には .NET 9LibraryImport を推奨。
    • ただし、動的ロードなどの場面では従来の DllImport が必要になる場合がある。
  9. .NET 9 の自動マーシャリング機能
    • ソースジェネレーターにより、文字列や構造体の受け渡しが簡略化・最適化されている。

これらを理解することで、C/C++ DLL ↔ C# 間の双方向通信をスムーズかつ安全に実装できます。特にコールバックを多用するシナリオや高パフォーマンスが必要な状況で、LibraryImport は大きな力を発揮するでしょう。


以上

0
1
0

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
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?