システム

【C#】DLLをC++から呼び出す方法―P/InvokeとC++/CLIで学ぶ相互運用の実践

C#で作ったDLLをC++から扱うには、クラスライブラリをDLL化し、COM登録やC++/CLIブリッジで関数を公開し、C++側で#importやP/Invoke相当の宣言を用意します。

ビット数と呼び出し規約、文字列などのマーシャリングを合わせるだけで、両言語間を滑らかに連携できます。

前提知識チェック

C#で作成したDLLをC++から呼び出すためには、まず.NETの仕組みとネイティブコードの違い、そしてそれらをつなぐ相互運用技術について理解しておくことが重要です。

このセクションでは、基礎的な知識を整理していきます。

.NETとネイティブコードの違い

.NETはマネージド環境で動作するプラットフォームであり、C#はこの環境で動く代表的な言語です。

一方、C++は主にネイティブコードとしてコンパイルされ、直接OSのAPIやハードウェアにアクセスします。

両者の違いを理解することは、DLLの呼び出し方法を選ぶ際に役立ちます。

マネージドコードとは

マネージドコードは、.NETの共通言語ランタイム(CLR)上で実行されるコードです。

CLRはメモリ管理(ガベージコレクション)、型安全性、例外処理などを提供し、プログラマはこれらの低レベルの管理から解放されます。

C#で書かれたコードはIL(中間言語)にコンパイルされ、実行時にJITコンパイラによってネイティブコードに変換されます。

ネイティブコードとは

ネイティブコードは、CPUが直接実行できる機械語にコンパイルされたコードです。

C++で書かれたコードは通常この形態であり、OSのAPIやハードウェアリソースに直接アクセスできます。

メモリ管理や例外処理はプログラマが明示的に行う必要があります。

両者の違いまとめ

項目マネージドコード (.NET/C#)ネイティブコード (C++)
実行環境CLR(共通言語ランタイム)OS上の直接実行
メモリ管理ガベージコレクションによる自動管理手動管理
型安全性高いプログラマ次第
例外処理CLRが統一的に管理言語やライブラリに依存
実行速度JITコンパイルによる高速化直接機械語で高速
プラットフォーム依存CLRが対応するプラットフォームで動作コンパイル時にプラットフォーム依存

このように、.NETのマネージドコードとC++のネイティブコードは実行環境や管理方法が大きく異なります。

これが、両者を連携させる際に特別な技術が必要になる理由です。

代表的な相互運用技術

C#のDLLをC++から呼び出すには、マネージドコードとネイティブコードの橋渡しをする相互運用技術を利用します。

代表的な方法は以下の3つです。

P/Invoke(Platform Invocation Services)

P/Invokeは、C#などのマネージドコードからネイティブDLLの関数を呼び出すための仕組みですが、逆にネイティブコードからマネージドDLLを呼び出す場合にも工夫して使えます。

C#側でアンマネージ関数としてエクスポートし、C++側でその関数を呼び出す形です。

  • 特徴
    • シンプルな関数呼び出しに向く
    • 呼び出し規約やデータ型のマーシャリングが必要
    • 文字列や構造体の受け渡しに注意が必要

C++/CLIラッパー

C++/CLIは、.NETのマネージドコードとネイティブコードの両方を扱える言語拡張です。

C++/CLIでマネージドDLLをラップし、ネイティブC++からはこのラッパーを通じて呼び出します。

  • 特徴
    • 複雑なオブジェクトやクラスのやり取りに適している
    • マネージドとネイティブのコードを同一プロジェクト内で混在可能
    • パフォーマンスが比較的良好

COM(Component Object Model)

COMはWindowsのコンポーネント技術で、マネージドコードとネイティブコードの相互運用に使われます。

C#でCOMインターフェースを公開し、C++からはCOMオブジェクトとして利用します。

  • 特徴
    • Windows環境で広く使われている
    • インターフェースベースの設計で堅牢
    • 登録や設定が必要でやや複雑
技術名利用シーンメリットデメリット
P/Invoke単純な関数呼び出し実装が比較的簡単複雑な型の受け渡しが難しい
C++/CLI複雑なオブジェクトのやり取りマネージド・ネイティブ混在可能C++/CLIの習得が必要
COMWindowsアプリケーション全般インターフェース設計で堅牢登録や設定が煩雑

これらの技術を理解し、目的や環境に応じて使い分けることが、C#のDLLをC++から呼び出す際の第一歩となります。

C# DLLの公開方法を選ぶ

公開レイヤーごとの比較

C#で作成したDLLをC++から呼び出す際、どのようにDLLの機能を公開するかによって実装方法や運用が大きく変わります。

代表的な公開レイヤーには、P/Invoke向けのエクスポート、COM公開、C++/CLIラッパーの3つがあります。

それぞれの特徴を理解して適切な方法を選びましょう。

P/Invoke向けエクスポート

P/Invokeは、C#のメソッドをアンマネージ関数としてエクスポートし、C++から直接呼び出す方法です。

通常、C#のメソッドはマネージドコードとして動作しますが、DllExportなどのツールや属性を使ってアンマネージ関数としてエクスポートできます。

  • 特徴
    • C#のメソッドをCスタイルの関数としてエクスポート
    • 呼び出し規約(通常はStdCall)を指定可能
    • シンプルな関数呼び出しに向いている
    • 文字列や構造体のマーシャリングが必要
  • メリット
    • 実装が比較的シンプルで、C++側からの呼び出しも直感的
    • 追加のラッパーやCOM登録が不要
  • デメリット
    • 複雑なオブジェクトやクラスの受け渡しが難しい
    • 例外処理の伝搬ができないため、エラー処理が煩雑になることがある

COM公開

COM(Component Object Model)はWindowsの標準的なコンポーネント技術で、C#のクラスをCOMオブジェクトとして公開し、C++からはCOMインターフェースを通じて利用します。

  • 特徴
    • インターフェースベースの設計で堅牢かつ拡張性が高い
    • C#のクラスに[ComVisible(true)]属性を付けて公開
    • RegAsmツールでCOM登録が必要
    • C++側はCoCreateInstanceなどでCOMオブジェクトを生成
  • メリット
    • 複雑なオブジェクトやイベントのやり取りに適している
    • Windowsの標準技術なので広くサポートされている
  • デメリット
    • COM登録やID管理が必要でセットアップが煩雑
    • マルチスレッドやライフサイクル管理に注意が必要

C++/CLIラッパー

C++/CLIは、マネージドコードとネイティブコードの両方を扱える言語拡張です。

C#のDLLをC++/CLIでラップし、ネイティブC++からはこのラッパーを通じて呼び出します。

  • 特徴
    • マネージドとネイティブのコードを同一プロジェクト内で混在可能
    • C#のクラスやメソッドをラップしてネイティブ関数として公開
    • 複雑なデータ構造やクラスの受け渡しが容易
  • メリット
    • 複雑なオブジェクト指向のやり取りに強い
    • パフォーマンスが比較的良好
    • 例外処理やガベージコレクションの恩恵を受けられる
  • デメリット
    • C++/CLIの習得が必要
    • プロジェクト構成が複雑になることがある

選定のポイント

公開方法を選ぶ際には、パフォーマンスやデプロイ性、保守性などの観点から検討することが重要です。

パフォーマンス要件

  • P/Invokeは関数呼び出しのオーバーヘッドが比較的小さいですが、複雑なデータのマーシャリングが多いとパフォーマンスに影響します
  • C++/CLIラッパーはマネージドとネイティブの境界を効率的に橋渡しできるため、複雑な処理でも高速に動作します
  • COMはインターフェース呼び出しのオーバーヘッドがあり、頻繁な呼び出しには向きませんが、堅牢な設計が求められる場合に適しています

デプロイ性

  • P/Invokeは単一のDLLファイルで済むため、デプロイが簡単です
  • COMはレジストリ登録が必要で、環境によっては管理者権限が求められるため、デプロイが複雑になります
  • C++/CLIラッパーはマネージドDLLとネイティブDLLの両方を配布する必要があり、依存関係の管理が重要です

保守性

  • P/Invokeはシンプルな関数呼び出しに向いていますが、複雑な変更が入るとマーシャリングの修正が増え、保守が難しくなることがあります
  • COMはインターフェースベースの設計により、バージョン管理や拡張がしやすい反面、COMの知識が必要です
  • C++/CLIラッパーはコードが一元化されやすく、マネージド・ネイティブ両方のコードを同時に管理できるため、保守性が高いです

これらのポイントを踏まえ、プロジェクトの要件や開発チームのスキルセットに合わせて公開方法を選択してください。

P/Invokeによる呼び出し

C#側の設定

C#のメソッドをネイティブコードから呼び出すためには、アンマネージ関数としてDLLにエクスポートする必要があります。

通常のC#メソッドはマネージドコードとして動作するため、そのままではC++から呼び出せません。

ここでは、代表的な方法であるUnmanagedExportsの利用と、DllExport属性を直接指定する方法を説明します。

UnmanagedExportsの利用

UnmanagedExportsは、NuGetパッケージとして提供されているツールで、C#のメソッドをアンマネージ関数としてエクスポートできるようにします。

これにより、C++から直接呼び出せる関数を作成可能です。

  1. Visual StudioのプロジェクトにUnmanagedExportsパッケージをインストールします。

パッケージマネージャーコンソールで以下を実行します。

Install-Package UnmanagedExports 2. エクスポートしたいメソッドに[DllExport]属性を付けます。

using RGiesecke.DllExport;
using System.Runtime.InteropServices;
public class ExportedFunctions
{
    [DllExport("Add", CallingConvention = CallingConvention.StdCall)]
    public static int Add(int a, int b)
    {
        // 2つの整数を加算して返す
        return a + b;
    }
}

この例では、Addという名前でStdCall呼び出し規約の関数がエクスポートされます。

C++側からはAdd関数として呼び出せます。

  1. ビルドすると、DLLにアンマネージ関数がエクスポートされます。

DllExport直接指定

.NET 5以降や.NET Coreでは、DllExport属性を直接使う方法は標準でサポートされていませんが、サードパーティのツールやビルド時のカスタム処理で実現可能です。

代表的な方法はDllExportというツールを使うことです。

  1. DllExportツールをインストールします。

公式サイトやGitHubから入手可能です。

  1. C#のメソッドに[DllExport]属性を付けます。

例はUnmanagedExportsとほぼ同様です。

  1. ビルド時にツールがアンマネージ関数としてエクスポートする処理を行います。

この方法は環境によって設定が異なるため、プロジェクトの要件に合わせて選択してください。

C++側の宣言方法

C++からC#のDLLにエクスポートされた関数を呼び出すには、関数の宣言を正しく行う必要があります。

特に名前修飾や呼び出し規約に注意が必要です。

extern “C” と名前修飾

C++は関数名に名前修飾(マングリング)を行うため、C#でエクスポートされた関数名と一致しない場合があります。

これを防ぐために、C++側ではextern "C"を使って名前修飾を無効にします。

extern "C" int __stdcall Add(int a, int b);
  • extern "C"で名前修飾を防止
  • __stdcallで呼び出し規約をC#側と合わせる

これにより、C#のAdd関数を正しく呼び出せます。

#pragma comment(lib, …) の活用

C++のソースコード内でDLLのインポートライブラリを指定する場合、#pragma comment(lib, "YourDll.lib")を使うと便利です。

これにより、プロジェクトのリンカ設定を変更せずにライブラリをリンクできます。

#pragma comment(lib, "MyCSharpDll.lib")

ただし、P/InvokeでエクスポートしたDLLは通常インポートライブラリが生成されないため、動的にLoadLibraryGetProcAddressを使う方法が多いです。

データ型ごとのマーシャリング

C#とC++ではデータ型の表現が異なるため、関数の引数や戻り値の型に応じて適切なマーシャリング(データ変換)が必要です。

プリミティブ型

整数型や浮動小数点型は基本的に互換性があります。

例えば、intはC#のint(32bit符号付き整数)とC++のintで同じサイズです。

C#側

[DllExport("Multiply", CallingConvention = CallingConvention.StdCall)]
public static int Multiply(int x, int y)
{
    return x * y;
}

C++側

extern "C" int __stdcall Multiply(int x, int y);

文字列とCharSet

文字列はC#のstringとC++のconst char*wchar_t*で表現が異なります。

P/Invokeで文字列を渡す場合、CharSetを指定してマーシャリング方法を制御します。

  • CharSet.Ansi:ANSI文字列const char*として扱う
  • CharSet.Unicode:Unicode文字列const wchar_t*として扱う

C#側の例:

[DllExport("PrintMessage", CallingConvention = CallingConvention.StdCall, CharSet = CharSet.Ansi)]
public static void PrintMessage(string message)
{
    System.Console.WriteLine("受け取ったメッセージ: " + message);
}

C++側の宣言例:

extern "C" void __stdcall PrintMessage(const char* message);

配列と構造体

配列や構造体はメモリレイアウトが異なるため、正確なマーシャリングが必要です。

  • 配列はポインタとして渡し、サイズを別途渡すことが多いです
  • 構造体はStructLayout属性でレイアウトを制御し、C++側と同じメモリ配置にします

C#側の構造体例:

[StructLayout(LayoutKind.Sequential)]
public struct Point
{
    public int X;
    public int Y;
}
[DllExport("GetPointSum", CallingConvention = CallingConvention.StdCall)]
public static int GetPointSum(Point p)
{
    return p.X + p.Y;
}

C++側の対応構造体:

struct Point
{
    int X;
    int Y;
};
extern "C" int __stdcall GetPointSum(Point p);

エラー処理と例外伝搬

P/InvokeでC#のメソッドを呼び出す場合、C#側で例外が発生してもそのままC++に伝搬しません。

例外はマネージドコード内でキャッチし、エラーコードや戻り値で通知する必要があります。

C#側の例:

[DllExport("Divide", CallingConvention = CallingConvention.StdCall)]
public static int Divide(int numerator, int denominator, out int result)
{
    try
    {
        result = numerator / denominator;
        return 0; // 成功
    }
    catch (DivideByZeroException)
    {
        result = 0;
        return -1; // エラーコード
    }
}

C++側の呼び出し例:

extern "C" int __stdcall Divide(int numerator, int denominator, int* result);
int main()
{
    int res;
    int err = Divide(10, 0, &res);
    if (err != 0)
    {
        printf("エラーが発生しました\n");
    }
    else
    {
        printf("結果: %d\n", res);
    }
    return 0;
}

デバッグのコツ

P/Invokeでの呼び出しは、マネージドとネイティブの境界をまたぐため、デバッグが難しいことがあります。

以下のポイントを押さえると効率的にデバッグできます。

  • DLLのエクスポート関数名を確認する

dumpbin /exports YourDll.dllDependency Walkerでエクスポート関数名を確認し、C++側の宣言と一致しているかチェックします。

  • 呼び出し規約の不一致に注意

StdCallCdeclの違いでスタック破壊が起こることがあるため、C#側とC++側で呼び出し規約を必ず合わせます。

  • 文字列のマーシャリングミスを防ぐ

文字列のCharSet指定やポインタの扱いを間違えるとアクセス違反が起こるため、慎重に設定します。

  • Visual Studioの混合モードデバッグを活用

マネージドとネイティブの両方のコードを同時にデバッグできるため、問題の切り分けがしやすくなります。

  • 例外処理はマネージド側で完結させる

例外がネイティブ側に伝わらないため、C#側で例外をキャッチし、エラーコードで通知する設計にします。

これらのポイントを意識して開発すると、P/InvokeによるC# DLLの呼び出しがスムーズに進みます。

C++/CLIラッパーでの呼び出し

マネージドラッパープロジェクトの作成

C++/CLIを使ってC#のマネージドDLLをラップするには、Visual Studioで「C++/CLIクラスライブラリ」プロジェクトを作成します。

このプロジェクトはマネージドコードとネイティブコードの両方を扱えるため、C#のクラスをラップしてネイティブC++から呼び出せる形に変換します。

  1. Visual Studioで新規プロジェクトを作成し、「C++ CLR クラスライブラリ」を選択します。
  2. プロジェクトのプロパティで「共通言語ランタイムサポート」を有効にします(通常はデフォルトで有効)。
  3. C#のDLLを参照に追加します。
  • ソリューションエクスプローラーで「参照の追加」からC# DLLを選択
  1. マネージドラッパークラスを作成します。以下はC#のクラスをラップする例です。
// ManagedWrapper.h
#pragma once
using namespace System;
using namespace YourCSharpNamespace;
public ref class ManagedWrapper
{
private:
    YourCSharpClass^ managedInstance;
public:
    ManagedWrapper()
    {
        managedInstance = gcnew YourCSharpClass();
    }
    int Add(int a, int b)
    {
        return managedInstance->Add(a, b);
    }
};

このように、C#のクラスをgcnewでインスタンス化し、メソッドを呼び出すラッパーを作成します。

C++アプリからの利用

ネイティブC++アプリケーションからC++/CLIラッパーを利用するには、ラッパーDLLをリンクし、ラッパークラスのインスタンスを作成してメソッドを呼び出します。

  1. C++アプリのプロジェクトにC++/CLIラッパーDLLのインポートライブラリをリンクします。
  2. ヘッダーファイルをインクルードし、ラッパークラスを使います。
#include "ManagedWrapper.h"
int main()
{
    ManagedWrapper^ wrapper = gcnew ManagedWrapper();
    int result = wrapper->Add(3, 5);
    printf("結果: %d\n", result);
    return 0;
}

注意点として、C++/CLIのコードはマネージドコードなので、ネイティブC++アプリで使う場合はCLRサポートを有効にする必要があります。

Visual Studioのプロジェクト設定で「共通言語ランタイムサポート」を有効にしてください。

リファレンス型とポインタ型の相互変換

C++/CLIでは、マネージドオブジェクトは^(ハット)記号で示すリファレンス型として扱い、ネイティブポインタは*で示します。

マネージドとネイティブの間でデータをやり取りする際には、これらの型の変換が必要です。

  • マネージドリファレンス型(^)

ガベージコレクションの対象で、gcnewで生成します。

例:YourCSharpClass^ obj = gcnew YourCSharpClass();

  • ネイティブポインタ型(*)

通常のC++ポインタ。

例:YourNativeClass* ptr;

変換例

マネージド文字列System::String^をネイティブ文字列const char*に変換する例:

#include <msclr/marshal_cppstd.h>
using namespace msclr::interop;
void PrintNativeString(System::String^ managedStr)
{
    std::string nativeStr = marshal_as<std::string>(managedStr);
    printf("ネイティブ文字列: %s\n", nativeStr.c_str());
}

逆に、ネイティブ文字列をマネージド文字列に変換する場合:

System::String^ ConvertToManagedString(const char* nativeStr)
{
    return gcnew System::String(nativeStr);
}

このように、msclr::interop名前空間のmarshal_asを使うと簡単に変換できます。

性能チューニング

C++/CLIラッパーはマネージドとネイティブの橋渡しを効率的に行えますが、パフォーマンスを最大限に引き出すためにはいくつかのポイントに注意が必要です。

  • 境界越えの呼び出し回数を減らす

マネージドとネイティブの間の呼び出しはオーバーヘッドがあるため、頻繁に小さな呼び出しを繰り返すよりも、一度にまとめて処理する設計が望ましいです。

  • データコピーを最小限にする

大きな配列や構造体を渡す際は、コピーを避けるために参照やポインタを使うか、共有メモリを活用します。

  • ガベージコレクションの影響を考慮する

マネージドオブジェクトの寿命管理に注意し、不要なオブジェクト生成を避けることでGCの負荷を軽減します。

  • 例外処理の境界を明確にする

マネージド例外がネイティブコードに伝わらないため、例外はマネージド側で処理し、エラーコードなどでネイティブ側に通知します。

  • ビルド設定の最適化

リリースビルドで最適化オプションを有効にし、不要なデバッグ情報を削減します。

これらのポイントを踏まえて設計・実装すると、C++/CLIラッパーを使ったC# DLLの呼び出しで高いパフォーマンスを実現できます。

COMインターフェース経由の呼び出し

C#でのCOM公開手順

C#のクラスをCOMコンポーネントとして公開するには、いくつかの手順を踏みます。

COMはWindowsの標準的なコンポーネント技術で、インターフェースベースの設計により言語やプロセスの壁を越えた相互運用を可能にします。

  1. COM公開用の属性を付与する

C#のクラスに[ComVisible(true)]属性を付けて、COMからアクセス可能にします。

また、[Guid]属性でクラスやインターフェースに一意の識別子を割り当てます。

using System;
using System.Runtime.InteropServices;
[ComVisible(true)]
[Guid("D4E6F8A1-1234-4B56-9CDE-7890ABCDEF12")]
[InterfaceType(ComInterfaceType.InterfaceIsIDispatch)]
public interface ICalculator
{
    int Add(int a, int b);
}
[ComVisible(true)]
[Guid("A1B2C3D4-5678-90AB-CDEF-1234567890AB")]
[ClassInterface(ClassInterfaceType.None)]
public class Calculator : ICalculator
{
    public int Add(int a, int b)
    {
        return a + b;
    }
}
  • InterfaceType属性はCOMインターフェースの種類を指定します。InterfaceIsIDispatchは遅延バインディング用のIDispatchインターフェースを生成します
  • ClassInterfaceType.Noneは明示的にインターフェースを実装することを意味し、推奨される設定です
  1. アセンブリ情報の設定

AssemblyInfo.cs[assembly: ComVisible(true)]を追加して、アセンブリ全体をCOMに公開可能にします。

  1. ビルド

プロジェクトをビルドすると、COM対応のアセンブリが生成されます。

REGASMとマニフェスト

COMコンポーネントを利用可能にするためには、Windowsのレジストリに情報を登録する必要があります。

これを行うのがregasm.exeツールです。

  • regasm.exeの実行

コマンドプロンプトで以下のように実行します。

regasm YourAssembly.dll /codebase /tlb
  • /codebaseオプションは、DLLのパスをレジストリに登録し、グローバルアセンブリキャッシュ(GAC)に登録しない場合に必要です
  • /tlbオプションはタイプライブラリ(.tlbファイル)を生成します。これにより、C++などのクライアントが型情報を利用できます
  • マニフェストファイルの利用

COM登録を不要にするために、アプリケーションのマニフェストにCOMアセンブリの情報を記述する方法もあります。

これにより、レジストリを汚さずにCOMコンポーネントを利用可能です。

ただし、設定が複雑になるため、一般的にはregasmを使うことが多いです。

C++からのスマートポインタ活用

C++からCOMオブジェクトを扱う際は、CComPtr_com_ptr_tなどのスマートポインタを使うとメモリ管理が楽になります。

これらはCOMの参照カウントを自動的に管理し、リソースリークを防ぎます。

  • CComPtrの例
#include <atlbase.h>
#include <iostream>
// インターフェースのUUIDを定義
// {D4E6F8A1-1234-4B56-9CDE-7890ABCDEF12}
struct __declspec(uuid("D4E6F8A1-1234-4B56-9CDE-7890ABCDEF12")) ICalculator : IDispatch
{
    virtual HRESULT __stdcall Add(int a, int b, int* result) = 0;
};
int main()
{
    HRESULT hr = CoInitialize(nullptr);
    if (FAILED(hr))
    {
        std::cerr << "COM初期化失敗" << std::endl;
        return -1;
    }
    CComPtr<ICalculator> spCalculator;
    hr = spCalculator.CoCreateInstance(__uuidof(Calculator));
    if (SUCCEEDED(hr))
    {
        int result = 0;
        hr = spCalculator->Add(3, 5, &result);
        if (SUCCEEDED(hr))
        {
            std::cout << "計算結果: " << result << std::endl;
        }
    }
    CoUninitialize();
    return 0;
}
  • CoInitializeでCOMライブラリを初期化し、CoUninitializeで解放します
  • CoCreateInstanceでCOMオブジェクトを生成し、CComPtrが参照カウントを管理します
  • メソッド呼び出しはHRESULTを返し、エラー処理が可能です

IDLとタイプライブラリ

IDL(Interface Definition Language)はCOMインターフェースを定義するための言語で、タイプライブラリ(.tlbファイル)を生成します。

タイプライブラリはCOMクライアントがインターフェースの情報を取得するために使います。

  • IDLファイルの例
import "oaidl.idl";
import "ocidl.idl";
[
    object,
    uuid(D4E6F8A1-1234-4B56-9CDE-7890ABCDEF12),
    dual,
    nonextensible,
    pointer_default(unique)
]
interface ICalculator : IDispatch
{
    HRESULT Add([in] int a, [in] int b, [out, retval] int* result);
};
[
    uuid(A1B2C3D4-5678-90AB-CDEF-1234567890AB),
    version(1.0),
]
library CalculatorLib
{
    importlib("stdole2.tlb");
    [
        uuid(A1B2C3D4-5678-90AB-CDEF-1234567890AB)
    ]
    coclass Calculator
    {
        [default] interface ICalculator;
    };
};
  • C#のregasmで生成されるタイプライブラリは、このIDLに相当する情報を含みます
  • C++の#importディレクティブでタイプライブラリを読み込むと、インターフェースやクラスのラッパーが自動生成されます
#import "Calculator.tlb" raw_interfaces_only
using namespace CalculatorLib;
int main()
{
    CoInitialize(nullptr);
    ICalculatorPtr spCalculator;
    HRESULT hr = spCalculator.CreateInstance(__uuidof(Calculator));
    if (SUCCEEDED(hr))
    {
        int result = 0;
        spCalculator->Add(3, 5, &result);
        printf("結果: %d\n", result);
    }
    CoUninitialize();
    return 0;
}

このようにIDLとタイプライブラリを活用すると、COMインターフェースの利用が容易になり、型安全な呼び出しが可能です。

共通の落とし穴と対処法

32bitと64bitの不一致

C#で作成したDLLとC++アプリケーションのビルドターゲット(32bitまたは64bit)が一致していないと、呼び出し時にエラーやクラッシュが発生します。

これは、プロセスのアドレス空間やデータ型のサイズが異なるためです。

  • 問題の例
    • 32bit DLLを64bitアプリから呼び出すとロードエラーになります
    • 64bit DLLを32bitアプリから呼び出すと同様に失敗します
  • 対処法
    • C#プロジェクトの「プラットフォームターゲット」をC++アプリと合わせる(x86またはx64)
    • AnyCPUターゲットの場合は、C++アプリのビルド設定に注意し、必要に応じて明示的にプラットフォームを指定します
    • 両者のビルド構成を統一し、同じアーキテクチャで動作させます

呼び出し規約の誤設定

C#とC++間で関数を呼び出す際、呼び出し規約(Calling Convention)が一致していないと、スタックの破壊や予期しない動作が起こります。

  • 代表的な呼び出し規約
    • StdCall:Windows APIでよく使われます。呼び出し側がスタックをクリア
    • Cdecl:C言語の標準。呼び出された側がスタックをクリア
  • 問題の例
    • C#側でCallingConvention.StdCallを指定しているのに、C++側で__cdeclを使うとスタック不整合が発生
    • 逆も同様
  • 対処法
    • C#のDllExportDllImportで呼び出し規約を明示的に指定します
    • C++側の関数宣言で同じ呼び出し規約を使う(例:extern "C" int __stdcall Func())
    • 呼び出し規約が不明な場合は、ツール(dumpbin /exportsなど)で確認します

GCによるオブジェクト破棄

C#のマネージドオブジェクトはガベージコレクション(GC)によって自動的にメモリ管理されますが、ネイティブコードから参照している場合、GCによるオブジェクト破棄が問題になることがあります。

  • 問題の例
    • C++側でポインタを保持しているが、C#側でオブジェクトがGCにより破棄されてしまい、アクセス違反が発生
    • マネージドオブジェクトの寿命管理が不十分
  • 対処法
    • C#側でGCHandleを使い、オブジェクトを固定(pin)してGCから保護します
    • C++/CLIラッパーを使い、マネージドオブジェクトの寿命をラッパークラスで管理します
    • 参照カウントやイベントの解除など、オブジェクトのライフサイクルを明確に設計します

スレッドモデルの違い

マネージドコードとネイティブコードではスレッドの扱いが異なるため、スレッドモデルの不一致が原因で問題が発生することがあります。

  • 問題の例
    • C#のマネージドコードはスレッドプールや同期コンテキストを利用するが、C++側は独自のスレッド管理をしています
    • COMのスレッドモデル(STA/MTA)が適切に設定されていないため、COMオブジェクトの呼び出しでデッドロックや例外が発生
  • 対処法
    • COMを使う場合は、CoInitializeExで適切なスレッドモデル(STAまたはMTA)を指定します
    • マネージドコードのスレッドコンテキストを理解し、必要に応じてSynchronizationContextを利用します
    • スレッド間で共有するデータは排他制御(ロック)を適切に行います
    • C++/CLIラッパーを使う場合は、マネージドとネイティブのスレッド間の橋渡しを意識します

これらの落とし穴を理解し、適切に対処することで、C# DLLとC++間の相互運用を安定して行えます。

セキュリティとサンドボックス

アンマネージコード実行許可

C#で作成したDLLをC++から呼び出す際、多くの場合アンマネージコード(ネイティブコード)との相互運用が発生します。

アンマネージコードは直接OSやハードウェアにアクセスできるため、セキュリティ上のリスクが伴います。

そのため、.NET環境ではアンマネージコードの実行を制限する仕組みが存在します。

  • コードアクセスセキュリティ(CAS)

.NET Frameworkでは、コードアクセスセキュリティ(CAS)により、アンマネージコードの実行権限を制御していました。

アンマネージコードを呼び出すには、SecurityPermissionUnmanagedCodeフラグが必要です。

  • 実行許可の設定方法
    • アプリケーションの設定ファイルやグループポリシーでアンマネージコードの実行を許可します
    • フルトラスト環境で実行します
    • .NET Coreや.NET 5以降ではCASは廃止されており、アンマネージコードの実行は基本的に許可されていますが、OSのセキュリティ機構(UACやサンドボックス)に依存します
  • 注意点
    • アンマネージコードを呼び出すDLLは信頼できるソースから入手すること
    • 不正なアンマネージコードはシステムの脆弱性を突く可能性があるため、実行環境のセキュリティポリシーを遵守します
    • サンドボックス環境(例:ClickOnceや一部のWebアプリケーション)ではアンマネージコードの呼び出しが制限されることがあります

Unsafeコードの扱い

C#では、unsafeキーワードを使うことでポインタ操作などのアンマネージコードに近い低レベルの操作が可能になります。

これによりパフォーマンス向上やネイティブコードとの連携が容易になりますが、同時に安全性のリスクも伴います。

  • unsafeコードの特徴
    • ポインタを使ったメモリアクセスが可能です
    • ガベージコレクションの管理外でメモリを操作できます
    • 型安全性が低下し、バッファオーバーフローやメモリ破壊のリスクがあります
  • コンパイル設定
    • プロジェクトのビルド設定で「unsafeコードの許可」を有効にする必要があります
    • unsafeブロックやメソッドを明示的に宣言します
  • 利用シーン
    • ネイティブコードとの高速なデータ共有
    • パフォーマンスクリティカルな処理
    • P/Invokeでの複雑なマーシャリング回避
  • セキュリティ上の注意
    • unsafeコードは信頼できるコード内でのみ使用します
    • 外部からの入力を直接unsafeコードに渡す場合は、十分な検証とサニタイズを行います
    • unsafeコードのバグはアプリケーションのクラッシュやセキュリティホールにつながる可能性があります
  • 例:unsafeコードでのポインタ操作
unsafe public static void CopyMemory(byte* src, byte* dest, int length)
{
    for (int i = 0; i < length; i++)
    {
        dest[i] = src[i];
    }
}

このように、アンマネージコードの実行許可とunsafeコードの扱いは、C#とC++間の相互運用において重要なセキュリティ上のポイントです。

適切な権限設定と安全なコーディングを心がけてください。

自動化とビルド統合

MSBuildでのDLLエクスポート

C#で作成したDLLをC++から呼び出すために、P/Invoke向けのアンマネージ関数としてエクスポートする場合、ビルドプロセスにエクスポート設定を組み込むことが重要です。

MSBuildを活用すると、ビルド時に自動的にDLLエクスポートの設定や必要なツールの実行を行えます。

MSBuildでの設定例

  1. UnmanagedExports(DllExport)を使う場合

NuGetパッケージとして導入したUnmanagedExportsは、ビルド時に自動的にアンマネージ関数をエクスポートします。

MSBuildのターゲットに組み込まれているため、特別な設定は不要です。

  1. カスタムターゲットの追加

独自のエクスポート処理やツールを使う場合は、.csprojファイルにカスタムターゲットを追加してビルドプロセスに組み込みます。

<Target Name="AfterBuild">
  <Exec Command="path\to\DllExportTool.exe $(TargetPath)" />
</Target>

この例では、ビルド完了後にDllExportTool.exeを実行し、生成されたDLLにエクスポート情報を付加します。

  1. ビルド構成の分岐

Debug/Releaseやプラットフォームごとに異なる設定を行う場合は、条件付きでターゲットを分けることも可能です。

<Target Name="AfterBuild" Condition="'$(Configuration)' == 'Release'">
  <Exec Command="path\to\DllExportTool.exe $(TargetPath)" />
</Target>
  1. 依存関係の管理

エクスポートツールの実行前にビルドが完了していることを保証するため、ターゲットの依存関係を適切に設定します。

メリット

  • ビルドの自動化により手動ミスを防止できます
  • CI/CD環境でも安定してDLLを生成可能です
  • 複数プロジェクト間で一貫したビルド設定を共有できます

CMakeとマルチプラットフォーム

C++プロジェクトでC# DLLを呼び出す場合、特にクロスプラットフォーム開発や複数のビルド環境を扱う際には、CMakeを使ったビルド管理が有効です。

CMakeはWindowsだけでなくLinuxやmacOSにも対応しており、マルチプラットフォームでのビルド統合を支援します。

CMakeでのC# DLL連携のポイント

  1. C#プロジェクトのビルドをCMakeから呼び出す

CMakeのadd_custom_commandadd_custom_targetを使い、MSBuildやdotnet CLIを呼び出してC# DLLをビルドします。

add_custom_command(
  OUTPUT MyManaged.dll
  COMMAND dotnet build path/to/ManagedProject.csproj -c Release -o ${CMAKE_BINARY_DIR}/Managed
  WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}
  COMMENT "ビルド中: C#マネージドDLL"
)
add_custom_target(ManagedDll ALL DEPENDS MyManaged.dll)
  1. C++プロジェクトでDLLをリンクまたは動的ロード
  • P/Invokeの場合はDLLのパスを指定し、LoadLibraryGetProcAddressで動的に呼び出します
  • C++/CLIラッパーを使う場合は、CMakeでラッパープロジェクトもビルド対象に含める
  1. プラットフォームごとの設定分岐

Windows以外の環境ではC# DLLの呼び出しが制限されるため、条件付きでビルド設定を切り替えます。

if(WIN32)

  # Windows固有の設定

else()
  message(WARNING "C# DLLの呼び出しはWindowsのみサポートされています。")
endif()
  1. 依存関係の管理

C# DLLのビルドが完了してからC++側のビルドを開始するように依存関係を設定し、ビルド順序を保証します。

メリット

  • 複数プラットフォームで一貫したビルド環境を構築できます
  • C#とC++のビルドを統合し、手動操作を減らせます
  • CI/CDパイプラインでの自動化が容易になります

これらの方法を活用して、C# DLLのエクスポートとC++からの呼び出しをビルドプロセスに組み込み、効率的かつ安定した開発環境を実現してください。

テスト戦略

ネイティブ単体テスト

C++からC# DLLを呼び出す際、ネイティブ側の単体テストは非常に重要です。

特にP/InvokeやC++/CLIラッパーを介して呼び出す場合、ネイティブコードの動作確認を独立して行うことで、問題の切り分けが容易になります。

  • テストフレームワークの選択
    • Google Test(gtest)やCatch2など、C++向けの単体テストフレームワークを利用します
    • Visual Studioのネイティブテストプロジェクトも活用可能です
  • テスト内容の例
    • C# DLLの関数を呼び出し、期待通りの戻り値が得られるか検証
    • 文字列や構造体のマーシャリングが正しく行われているか確認
    • エラーコードや例外処理の動作をテスト
    • 境界値や異常系の入力に対する挙動を検証
  • モックの活用
    • C# DLLがまだ完成していない場合、ネイティブ側でモック関数を用意し、呼び出しインターフェースのテストを行います
    • 逆にC#側のモックを作成し、ネイティブコードの動作を検証することも可能です
  • テストの自動化
    • ビルド後に自動でテストを実行し、結果をレポートする仕組みを整備します
    • テスト失敗時にビルドを停止する設定も有効です

マネージドユニットテスト

C#側のDLLはマネージドコードであるため、豊富なユニットテストフレームワークを活用して品質を担保できます。

  • 代表的なフレームワーク
    • MSTest
    • NUnit
    • xUnit.net
  • テストのポイント
    • 各公開メソッドの正常系・異常系の動作を網羅的にテスト
    • 文字列や構造体のマーシャリングに関わるコードの検証
    • 例外処理やエラーコードの返却が正しく行われているか確認
    • マルチスレッド環境での動作テストも検討
  • テストプロジェクトの構成
    • C# DLLプロジェクトとは別にテストプロジェクトを作成し、DLLを参照
    • CI/CD環境で自動実行できるように設定
  • コードカバレッジの活用
    • テストの網羅率を測定し、未テストのコードを特定
    • カバレッジツール(Visual Studioのカバレッジ機能やCoverletなど)を利用

継続的インテグレーションとCI/CD

C# DLLとC++アプリケーションの連携を安定させるためには、継続的インテグレーション(CI)と継続的デリバリー/デプロイ(CD)の導入が効果的です。

  • CI/CDの構成例
  1. ビルド
  • C# DLLのビルド(MSBuildやdotnet CLI)
  • C++プロジェクトのビルド(MSBuildやCMake)
  1. テスト実行
  • マネージドユニットテストの自動実行
  • ネイティブ単体テストの自動実行
  1. 成果物のパッケージング
  • DLLや実行ファイルのアーティファクト化
  1. デプロイまたは配布
  • テスト環境やステージング環境への自動デプロイ
  • 必要に応じて本番環境へのリリース
  • ツールの例
    • Azure DevOps Pipelines
    • GitHub Actions
    • Jenkins
    • TeamCity
  • ポイント
    • ビルドとテストの自動化により人的ミスを減らす
    • 早期に問題を検出し、修正コストを低減
    • 複数プラットフォームや構成のビルドを並行して実行可能です
    • バージョン管理と連携し、変更履歴を追跡
  • 注意点
    • ネイティブとマネージドのビルド環境を整合させます
    • 依存関係の管理(C# DLLのビルド完了を待ってC++ビルドを開始など)を明確にします
    • テスト結果の可視化と通知設定を充実させます

これらのテスト戦略を組み合わせることで、C# DLLとC++間の相互運用を高品質かつ安定的に保つことができます。

既存Cライブラリは再利用できる?

既存のCライブラリは、C#のDLLから呼び出すことが可能で、再利用が広く行われています。

C#ではP/Invoke(Platform Invocation Services)を使って、ネイティブのC関数を直接呼び出せるため、既存のCライブラリをそのまま活用できます。

  • 利用方法
    • C#側で[DllImport]属性を使い、Cライブラリの関数を宣言します
    • 関数のシグネチャに合わせて適切なマーシャリングを設定します
    • C++/CLIラッパーを作成し、CライブラリをラップしてC#から呼び出す方法もあります
  • 注意点
    • Cライブラリのビルドターゲット(32bit/64bit)とC#アプリケーションのプラットフォームを合わせる必要があります
    • 呼び出し規約cdeclstdcallを正しく指定しないとスタック破壊が起こることがあります
    • ポインタや構造体の扱いに注意し、正確なマーシャリングを行うことが重要です
  • まとめ

既存のCライブラリはC#から問題なく再利用可能であり、P/InvokeやC++/CLIを活用することで効率的に統合できます。

マーシャルコストはどの程度?

マーシャリングとは、マネージドコードとネイティブコード間でデータを変換・コピーする処理のことです。

マーシャルコストは呼び出しの頻度やデータの複雑さによって変わります。

  • コストが低いケース
    • プリミティブ型(intfloatなど)の単純な引数や戻り値
    • ポインタや参照を使い、データコピーを最小限に抑えた場合
  • コストが高いケース
    • 文字列(特にUnicodeとANSI間の変換)
    • 配列や複雑な構造体のコピー
    • 大量のデータを頻繁にやり取りする場合
  • パフォーマンスへの影響
    • マーシャリングは呼び出しごとに発生するため、頻繁な呼び出しはパフォーマンス低下の原因になります
    • 可能な限り呼び出し回数を減らし、一度にまとめてデータを渡す設計が望ましいです
  • 最適化のポイント
    • unsafeコードやC++/CLIラッパーを使い、マーシャリングを減らす
    • 共有メモリやバッファを使ってデータのコピーを回避します
    • 文字列はStringBuilderや固定長バッファを活用します

配布時の依存関係は?

C# DLLをC++から呼び出す場合、配布時にはいくつかの依存関係に注意が必要です。

  • .NETランタイム
    • C# DLLは.NETランタイム上で動作するため、対象環境に対応した.NET Frameworkや.NET Core/.NET 5以降のランタイムがインストールされている必要があります
    • ランタイムのバージョンが合わないと動作しないことがあります
  • ネイティブDLL
    • P/Invokeで呼び出すネイティブDLLがある場合は、それらも一緒に配布し、適切なパスに配置する必要があります
    • 依存するネイティブライブラリ(例:VC++ランタイムなど)も考慮します
  • COM登録
    • COM公開した場合は、regasmによるレジストリ登録が必要です
    • レジストリ登録を不要にするためにマニフェストを利用する方法もありますが、環境によっては管理者権限が必要です
  • プラットフォームの整合性
    • 32bit/64bitの不一致を避けるため、DLLと呼び出し元アプリケーションのビルド構成を合わせることが重要です
  • 配置場所
    • DLLは実行ファイルと同じフォルダか、システムのPATHに含まれるフォルダに配置する必要があります
    • サイドバイサイド配置やアプリケーションローカル配置を検討すると管理が楽になります

これらの依存関係を正しく管理することで、C# DLLをC++アプリケーションに安全かつ確実に配布できます。

まとめ

この記事では、C#で作成したDLLをC++から呼び出すための主要な方法であるP/Invoke、C++/CLIラッパー、COM公開について詳しく解説しました。

各手法の特徴や設定方法、注意すべきポイントを理解することで、相互運用の実装やトラブルシューティングがスムーズになります。

また、ビルド自動化やテスト戦略、セキュリティ面の配慮も含め、実践的な開発環境構築に役立つ知識を得られます。

関連記事

Back to top button