Avance.Lab

技術紹介

第1回 Visual C++で作成したDLL内のクラスをC#で利用する方法

公開日:2024.01.19 更新日:2024.01.19

tag: Windows

こんにちは、ILCです。

Visual C++ (以下 VC++)で作成されたDynamic Link Library (以下 DLL)内に定義されたクラスを、C#.NET (以下 C#)で利用するまでの方法を連載形式にてご紹介したいと思います。

初めに

最近はWindowsアプリケーションにおいて開発効率の良いC#が使われることが多いですが、高速演算が必要な科学技術計算や画像処理、ライブラリ、ゲーム開発など実行速度が求められる場合にはVC++のようなネイティブ言語がまだまだ使われます。

もちろんC#でもアンセーフコードで記述すれば処理速度はネイティブに近づきますが、総合的にはVC++の方が有利でしょう。

GUI開発など生産性を求めるならC#、CPUをフルに速度重視な場合にはVC++が適任かと思います。

そこで、本記事でご紹介するVC++のDLL内にあるクラスをC#で利用するまでの流れについてですが、

  • VC++によって定義されたクラスをDLLとして作成する実装方法。
  • VC++のクラスにプロパティ(C#のようなプロパティ呼び出し)を定義する実装方法。
  • C++/CLIによるDLLをラップしたマネージドDLL(アセンブリ)の実装方法。
  • C#からDLL内のクラスを利用する実装方法。

の内容について簡単に記事にしたいと思います。C#ではC++のヘッダーファイルを直接扱うことは不可能なためC++/CLIのアセンブリ経由で利用する流れですね。

また、目的はC#からのクラス利用ですが、最終的にはC#, C++/CLI, VC++のいずれでもクラスを呼び出すことは可能です。

それでは、まず初めに呼び出す目的のクラスが定義されたVC++のDLLから作成していきましょう。

作成するDLLについて

DLLはプロセスで必要な機能をロードしてリンクされるライブラリですが、そのリンク方法には静的リンク、動的リンク(暗黙的リンク)、任意のタイミングで動的にロード(明示的リンク)するなどいくつかあります。

本記事のVC++で作成されるDLLの取り扱いについては、LoadLibray関数を使用したプログラム実行中に任意のタイミングでロードされリンクされるDLLを念頭にしており、クラスライブラリような利用を想定しています。

また、作成時の開発環境とDLLプロジェクトの構成プロパティは以下となります。

開発環境設定
OSWindows 10 64bit
IDEVisual Studio 2019 Professional
ビルドx64(64bitビルド)
DLL構成プロパティ設定
種類ダイナミックライブラリ
SDKバージョン10.0
C++言語標準ISO C++ 20 標準
MFCの使用共有DLLでMFCを使う
文字セットUnicode文字セットを使用する

DLLの作成(VC++版)

プロジェクトの作成

VC++に新しいプロジェクトとして「MFC ダイナミックリンクライブラリ」のテンプレートを選択しプロジェクトを作成します。この作成されたプロジェクト内にヘッダーファイルやソースファイルを追加することなります。

また、特にMFCライブラリ(Microsoft Foundation Class)を使用する必要がなければ、通常のダイナミックリンクライブラリでも構いません。

公開用ヘッダーファイルの定義

DLL内のクラスを呼び出し側が利用できるようにするには、公開用のヘッダーファイルが必要です。

このヘッダーファイルにクラスや関数などを定義することになります。

まず、DLL作成時はクラスのエクスポート、DLL使用時はクラスのインポートをするため以下のマクロ定義を宣言しましょう。

// エクスポート/インポート定義
#ifdef _DLL_EXPORT
    #define DLL_API   __declspec(dllexport)
#else
    #define DLL_API   __declspec(dllimport)
#endif

作成側(DLL)はプロジェクトの設定でプリプロセッサの定義(_DLL_EXPORT)をすることにより、クラスを外部にエクスポートさせます。逆に呼び出し側(EXE)は何も設定する必要はなく自動でインポートされるようになります。

次は呼び出し側に公開するクラス定義です。

今回定義するクラスは、あくまで呼び出し側で使用する際の雛形となります。実際にはこのクラスから派生させたクラスに実装を組み込み、クラスが作成された際は、この派生されたクラスのインスタンスを渡すことになります。

理由はプラグインのような任意にロードを想定するDLLの場合は、仮にDLL側のクラスをバージョンアップなどの理由により変更が行われるとクラスのサイズが変わってしまうからです。

呼び出し側のEXEではビルドされた時点の公開クラスのサイズしか知らないため、関数呼び出し時のアドレスがズレてしまったりなど不具合が起きる可能性があります。

#pragma once

// クラス定義
class DLL_API CExportSDK1
{
protected:
    // コンストラクタ
    CExportSDK1();

    // デストラクタ
    virtual ~CExportSDK1();

    CExportSDK1(const CExportSDK1&) = delete; 
    CExportSDK1 operator= (const CExportSDK1&) = delete;

public:
    // インスタンス作成/削除
    static CExportSDK1& CreateInstance(INT Version);
    static void DeleteInstance(CExportSDK1& Object);

    // メソッド
    virtual INT GetVersion() const;
    virtual INT GetClassSize();
};

クラス自身ではインスタンスの作成ができないようコンストラクタなどは非公開です。

実際の実装部分については派生先のクラスのためメンバ関数などは純粋仮想関数を定義すれば良いです。今回は後続の記事都合により公開クラスについても空の実装をしていきます。

また、LoadLibraryで動的にロードする場合は特に必要はありませんが、動的リンクにも対応できるよう

インスタンスの作成/削除する静的関数も入れてあります。

このクラスでは、呼び出し確認用にバージョンを取得するGetVersion関数と自身のサイズを取得するGetClassSize関数をサンプルとして定義しています。

非公開用ヘッダーファイルの定義と実装

公開用クラスから派生させたクラスを定義します。

公開先のクラスから実際に呼び出されるメンバ関数や必要なメンバ変数を定義します。

#pragma once

// クラス定義
class CDerivedSDK : public CExportSDK1
{
private:
    INT     m_Version;

public:
    // コンストラクタ
    CDerivedSDK(INT Version);

    // デストラクタ
    virtual ~CDerivedSDK();

    CDerivedSDK(const CDerivedSDK&) = delete; 
    CDerivedSDK operator= (const CDerivedSDK&) = delete;

public:
    // メソッド
    virtual INT GetVersion() const;
    virtual INT GetClassSize();
};

実装部分です。

コンストラクタの引数で取得しているバージョン番号はサンプル程度で特に必須ではありません。将来的にクラスの変更がなされた場合に、下位互換を維持しながらバージョンごとの機能を提供する際の切り分け程度に使えれば良いでしょう。

#include "pch.h"
#include "DllAPI.h"
#include "CDerivedSDK.h"

// コンストラクタ
CDerivedSDK::CDerivedSDK(INT Version) :
m_Version(Version)
{

}

// デストラクタ
CDerivedSDK::~CDerivedSDK()
{

}

// SDKバージョンの取得
INT CDerivedSDK::GetVersion() const
{
    return m_Version;
}

// クラスサイズの取得
INT CDerivedSDK::GetClassSize()
{
    return sizeof(*this);
}

公開用ヘッダーファイルにインスタンス生成関数を定義

公開用クラスから派生された実際の機能部分にあたるクラスの実装が終わりましたので、公開用ヘッダーにオブジェクトのインスタンスを生成または削除する関数などを定義しましょう。

#pragma once

// エクスポート/インポート定義
#ifdef _DLL_EXPORT
    #define DLL_API   __declspec(dllexport)
#else
    #define DLL_API   __declspec(dllimport)
#endif

// バージョン定義
#define EXPORT_SDK_VER_1    1

// 対象バージョン
#define TARGET_SDK_VERSION  EXPORT_SDK_VER_1

#if TARGET_SDK_VERSION <= EXPORT_SDK_VER_1
    #define CExportSDK CExportSDK1
#else
    // Not Implemented
#endif

// クラス定義のインクルード
#include "CExportSDK1.h"

// 関数型 定義
typedef CExportSDK& (WINAPI *CREATEEXPORTSDK)(INT);

// 関数型 定義
typedef void (WINAPI *DELETEEXPORTSDK)(CExportSDK&);

// インスタンス作成
extern "C" DLL_API CExportSDK& WINAPI CreateExportSDK(INT Version);

// インスタンス削除
extern "C" DLL_API void WINAPI DeleteExportSDK(CExportSDK& Object);

今回、ヘッダーに定義されたクラスのバージョン(EXPORT_SDK_VER_1)は1としてます。今後、大きく機能が追加される場合などは、CExportSDK1クラスを継承したCExportSDK2クラスなどを定義し、実態はCDerivedSDKクラスに追加する機能を実装することを想定した形になります。ただし、CDerivedSDKクラスの継承元はCExportSDK1からCExportSDK2に変更する必要はあります。

このようにすることにより、下位互換を維持しながら呼び出し側は、ビルド時にTARGET_SDK_VERSIONで定義されたバージョンの機能が利用できるような仕組みです。

ここでは、defファイル(Definition File)は使用せず、extern “C”を使用してクラスのインスタンス作成用にCreateExportSDK関数をインスタンス削除用にDeleteExportSDK関数を定義しています。削除用の関数が必要な理由は、呼び出し元では実際に確保されたクラスのメモリサイズを知ることができないため、勝手にdelete関数などで削除させないためです。

それでは、最後に残りの実装をしていきましょう。

#include "pch.h"
#include "DllAPI.h"
#include "CDerivedSDK.h"

// コンストラクタ
CExportSDK1::CExportSDK1()
{

}

// デストラクタ
CExportSDK1::~CExportSDK1()
{

}

// インスタンス作成
CExportSDK1& CExportSDK1::CreateInstance(INT Version)
{
    // バージョン確認
    if (Version != EXPORT_SDK_VER_1) {
        AfxThrowNotSupportedException();
    }

    CDerivedSDK* pObject = new CDerivedSDK(Version);

    return *pObject;
}

// インスタンス削除
void CExportSDK1::DeleteInstance(CExportSDK1& Object)
{
    delete (CDerivedSDK*)&Object;
}

// SDKバージョンの取得
INT CExportSDK1::GetVersion() const
{
    AfxThrowNotSupportedException();
}

// クラスサイズの取得
int CExportSDK1::GetClassSize()
{
    AfxThrowNotSupportedException();
}
#include "pch.h"
#include "DllAPI.h"
#include "CDerivedSDK.h"

// インスタンス作成
CExportSDK& WINAPI CreateExportSDK(INT Version)
{
    CDerivedSDK* pObject = new CDerivedSDK(Version);

    return *pObject;
}

// インスタンス削除
void WINAPI DeleteExportSDK(CExportSDK& Object)
{
    delete (CDerivedSDK*)&Object;
}

以上でビルドにより、VC++で作成されたDLLが作成されます。

同時にlibファイルも作成されますが、LoadLibrary関数を使用した方法では必要ありません。

DLLの呼び出し

今回は、DLLの呼び出しを確認できれば良いため、別途コンソールアプリケーションのプロジェクト(TestConsole)を新規作成します。アプリケーションビルド時には、先ほど定義した公開用ヘッダーファイルとDLLファイル(DllLibrary.dllとする)が必要になりますので準備します。

また、参考用として動的リンクを使用した場合のコードも一緒に掲載していますが、その場合はビルド時にlibファイルが必要になります。

以下は、DLLファイルにアクセスするためのクラス定義と実装です。

プリプロセッサまたは#define などで_DYNAMIC_LINKを定義すれば、動的リンクでの呼び出しに変わります。

#pragma once

#ifdef _DYNAMIC_LINK
    #pragma comment(lib, "DllLibrary.lib")
#endif

// クラス定義
class CSDKLibrary
{
private:
    HMODULE             m_hHandle;
    CREATEEXPORTSDK     m_CreateExportSDKFunc;
    DELETEEXPORTSDK     m_DeleteExportSDKFunc;

public:
    // コンストラクタ
    CSDKLibrary();

    // デストラクタ
    virtual ~CSDKLibrary();

    // メソッド
    BOOL Load(CString FileName);
    CExportSDK& CreateSDK();
    void DeleteSDK(CExportSDK& Object);

protected:
    // メソッド
    void ClearHandle();
};
#include "pch.h"
#include "DllAPI.h"
#include "CSDKLibrary.h"

// コンストラクタ
CSDKLibrary::CSDKLibrary() :
m_hHandle(nullptr),
m_CreateExportSDKFunc(nullptr),
m_DeleteExportSDKFunc(nullptr)
{

}

// デストラクタ
CSDKLibrary::~CSDKLibrary()
{
    ClearHandle();
}

// SDKの作成
CExportSDK& CSDKLibrary::CreateSDK()
{
    try {
        // クラスのインスタンスを作成
#ifdef _DYNAMIC_LINK
        return CExportSDK::CreateInstance(TARGET_SDK_VERSION);
#else
        return m_CreateExportSDKFunc(TARGET_SDK_VERSION);
#endif
    }
    catch (...) {
        AfxThrowUserException();
    }
}

// SDKの削除
void CSDKLibrary::DeleteSDK(CExportSDK& Object)
{
    try {
        // クラスのインスタンスを破棄
#ifdef _DYNAMIC_LINK
        CExportSDK::DeleteInstance(Object);
#else
        m_DeleteExportSDKFunc(Object);
#endif
    }
    catch (...) {
        AfxThrowUserException();
    }
}

// DLLのロード
BOOL CSDKLibrary::Load(CString FileName)
{
#ifdef _DYNAMIC_LINK
    return TRUE;
#endif

    // モジュールハンドルの確認
    if (m_hHandle != nullptr) {
        return TRUE;
    }

    // DLLファイルのロード
    m_hHandle = LoadLibrary(FileName);

    // モジュールハンドルの確認
    if (m_hHandle == nullptr) {
        return FALSE;
    }

    // 関数アドレスの取得
    m_CreateExportSDKFunc = (CREATEEXPORTSDK)GetProcAddress(m_hHandle, "CreateExportSDK");
    m_DeleteExportSDKFunc = (DELETEEXPORTSDK)GetProcAddress(m_hHandle, "DeleteExportSDK");

    // 関数アドレスを確認
    if ((m_CreateExportSDKFunc == nullptr) || (m_DeleteExportSDKFunc == nullptr)) {
        // ハンドルの解放
        ClearHandle();
        return FALSE;
    }

    return TRUE;
}

void CSDKLibrary::ClearHandle()
{
    // DLLの解放
    if (m_hHandle != nullptr) {
        FreeLibrary(m_hHandle);
        m_hHandle = nullptr;
    }

    // 関数アドレス初期化
    m_CreateExportSDKFunc = nullptr;
    m_DeleteExportSDKFunc = nullptr;
}

エントリポイントの実装です。ヘッダーファイルは特に中身は無いので割愛します。

#include "pch.h"
#include "framework.h"
#include "DllAPI.h"
#include "TestConsole.h"
#include "CSDKLibrary.h"

using namespace std;

#ifdef _DEBUG
#define new DEBUG_NEW
#endif

// メイン関数
int wmain(int argc, wchar_t* argv[], wchar_t* envp[])
{
    CSDKLibrary SDK;

    // DLLのロード
    if (!SDK.Load(_T("DllLibrary.dll"))) {
        return 1;
    }

    // インスタンス作成
    CExportSDK1& SDKLibrary = SDK.CreateSDK();

    // クラスメソッドの呼び出し
    INT sdkVersion = SDKLibrary.GetVersion();
    INT classSize  = SDKLibrary.GetClassSize();

    wcout << "===== 確認テスト =====" << endl;
    wcout << "SDK Version = " << sdkVersion << endl;
    wcout << "Class Size (インスタンス) = " << classSize << endl;
    wcout << "Class Size (CExportSDK1) = " << sizeof(CExportSDK1) << endl;

    // インスタンス削除
    SDK.DeleteSDK(SDKLibrary);

    system("pause");

    return 0;
}

それでは、実行してみましょう。

===== 確認テスト =====
SDK Version = 1
Class Size (インスタンス) = 16
Class Size (CExportSDK1) = 8

クラスのインスタンス作成後、問題なくメソッドの呼び出しに成功しました。

DLLに定義されたクラスのサイズをインスタンス側と呼び出し側の両方を表示してみましたが、実際に確保されるメモリサイズが異なっていても問題なく使用できることがわかるかと思います。

もし、DLL側に修正を加えDLLファイルを差し替えたとしても、提供されるヘッダーファイルさえ変わらなければ呼び出し側のEXEについてはリビルドする必要はありません。

終わりに

以上、簡単にDLLに定義するクラスの実装と呼び出し方法を記載しました。

もし、DLLから提供される内容がクラスではなく単純な関数であれば比較的簡単に利用できます。

C#であればP/Invokeを利用したDllImportアトリビュートを関数定義と一緒に宣言するだけです。ただし、データ間でのマーシャリングに注意が必要ですが。そういう意味では、クラスを直接呼び出せないのはとても面倒なことですね。

最後に今回記載したソースコードについて、エラーチェックや細かい処理の部分については掲載用に省いてあります。もっと他にも良い方法があるかと思いますが、参考にしていただければ幸いです。

ILC
ILC

Windowsアプリをメインに開発。
自然豊かな場所にドライブや散歩で癒されるのが好き。

関連記事