LoginSignup
14
9

More than 1 year has passed since last update.

VB6(VBA)からC#製DLLを呼ぶ際のマーシャリング術

Last updated at Posted at 2023-01-06

C++ などから呼べるDLL をC# で作れるDllExport というツールがあります。
この記事は、そのDLL をVB6 やVBA から呼ぶという、極めてマイナーな方法を行う際のマーシャリング方法について記述した個人的な備忘録です。

VB6 とは

1990年代の使われていた言語で、現在はその派生形がOffice 製品のVBA で生き残っている。

特に、最終バージョンのVB6.0 では、ActiveX コンポーネントを組み合わせることでC++ などと比較して高い生産性を確保でき、開発者が確保しやすいことから日本ではVB6.0 を使った業務システムなどが大量に作られた。

継承やマルチスレッド等がないものの、クラスを使ったカプセル化などは行えるので、一応オブジェクト指向っぽい開発も行える。
Visual Basic

DllExport1 とは

C# でC++ などで作られたネイティブDLL のように扱えるDLL を生成できるツール

DllExport

なぜこんなことをしようと思ったのか

前職で10年以上にわたり、20世紀に開発されたVB6 製アプリのメンテをしていたが、さすがにVB6 では辛くなってきたので、マイグレーションしたかったが

  • 動作しない期間を設けられない(常になんらかの機能追加やバグ修正が行われている)
  • 2本のツールをメンテしたくない(携わるのは私一人だけ)

という理由で、機能追加の際にその部分だけをVB6 の代わりにC# で書けるようにして、少しずつ移行したかった。

幸い、アプリのメンテナンスをしているのは私一人だけで、開発ツールや開発手法はすべて個人に委ねられていたため、技術面以外での問題はなかった。

なぜCOM DLL などではダメだったのか

これ以外の方法では、レジストリ登録が必要になってしまう。

対象のVB6 製アプリはEXE 1個で出来ており、少なくともイントーラーなどを使用せず、フォルダーをコピーで動作することが必須条件だった。
side-by-side なども試してみたものの、安定動作させることができなかった。

1.DllExportを使ってVB6から呼べるDLLを作る

1) クラスライブラリのプロジェクトを作成

まず、.Net Framework のクラスライブラリを作成します。
私が作成したときは、Windows XP までサポートする必要があったので.Net Framework 4 にしました。
Windows 7 以降でいいなら、.Net Framework 4.8 を選びましょう。
DllExport 自体は .Net Core もサポートしているらしいですが、まだ試したことがありません。
そのうち実験してみる予定です。

2) NuGet からDllExportを追加

ソリューションの再読み込みが必要なので事前にきちんとソリューションやプロジェクトを保存しておくこと

3) csprojファイルを開き、C#10を使えるように設定

DllExportSample.csproj
  <PropertyGroup>
    <LangVersion>10.0</LangVersion>
  </PropertyGroup>

C#10を使えるようにするのは、DllExportを実装する際、ファイル スコープ namespaceと、モジュール初期化を使えるようにするため。

4) VB6側から呼ぶメソッドを実装

基本形はこんな感じ

DllExport.cs
//ファイル スコープ namespace(ネストが1段減ってすっきりする)
namespace DllExportSample;

//静的クラスとして定義することで、静的メソッドしか定義できないようにします。
public static class DllExport
{
    /// <summary>
    /// モジュール初期化子(プログラムの実行時(正確にはモジュール読み込み時)に1回だけ実行される処理を書きます。
    /// </summary>
    [ModuleInitializer]
    public static void Init()
    {
        //ThreadExceptionイベントハンドラを追加
        Application.ThreadException += new System.Threading.ThreadExceptionEventHandler(Application_ThreadException);

        //UnhandledExceptionイベントハンドラを追加する
        AppDomain.CurrentDomain.UnhandledException += new UnhandledExceptionEventHandler(CurrentDomain_UnhandledException);
        //MessageBox.Show("Initialized");
    }

    //ThreadExceptionイベントハンドラ
    private static void Application_ThreadException(object sender, System.Threading.ThreadExceptionEventArgs e) =>
        //エラーメッセージを表示する
        MessageBox.Show(e.Exception.Message, "エラー");

    //UnhandledExceptionイベントハンドラ
    static void CurrentDomain_UnhandledException(object sender, UnhandledExceptionEventArgs e) =>
        //エラーメッセージを表示する
        Console.WriteLine("エラー: {0}", ((Exception)e.ExceptionObject).Message);

    //int
    //呼び出し規約はStdCall
    //public、staticなメソッドとして定義
    //引数の型については、bittableな型については割と自動的に対応してくれるが、必要に応じてマーシャリングの指定を行い型の違いを吸収する
    [DllExport(CallingConvention = CallingConvention.StdCall)]
    public static int FuncInt(int x, int y) => x + y + 2;
}

.NET FrameworkでC#9.0以降に追加された機能については、ランタイム依存でなければ利用できる2

Attribute.cs
//.Net Frameworkでモジュール初期化子を使う場合は、こんな風に属性を定義してあげると使えるようになります。
namespace System.Runtime.CompilerServices;

[AttributeUsage(AttributeTargets.Method, Inherited = false)]
internal sealed class ModuleInitializerAttribute : Attribute
{
}

2.VB6側のプロジェクトを準備

1) DLLを呼び出すための処理を実装するため、標準EXEでプロジェクトを作成します。

特に注意する点はないので詳細割愛
今回は、FormMain.vbというフォームと、コールバック関数を実装するためのmdlMain.basという標準モジュールを追加しました。

3.いろいろなマーシャリングパターン3

カッコ内はVB6の型です。

1) byte(byte)

FormMain.vb
Private Declare Function FuncByte Lib "DllExportSample.dll" (ByVal x As Byte, ByVal y As Byte) As Byte

Private Sub cmd計算Byte_Click()
On Error GoTo Error
    txt結果.Text = FuncByte(Val(txtX.Text), Val(txtY.Text))
Exit Sub
Error:
    Call MsgBox("[" & Err.Number & "]" & vbCrLf & Err.Description, vbCritical Or vbOKOnly)
End Sub
DllExport.cs
//byte
[DllExport(CallingConvention = CallingConvention.StdCall)]
public static byte FuncByte(byte x, byte y) => (byte)((x + y) & 0xFF);

2) short(Integer)

FormMain.vb
Private Declare Function FuncShort Lib "DllExportSample.dll" (ByVal x As Integer, ByVal y As Integer) As Integer

Private Sub cmd計算Integer_Click()
On Error GoTo Error
    txt結果.Text = FuncShort(Val(txtX.Text), Val(txtY.Text))
Exit Sub
Error:
    Call MsgBox("[" & Err.Number & "]" & vbCrLf & Err.Description, vbCritical Or vbOKOnly)
End Sub
DllExport.cs
//short
[DllExport(CallingConvention = CallingConvention.StdCall)]
public static short FuncShort(short x, short y) => (short)(x + y + 1);

3) int(Long)

FormMain.vb
Private Declare Function FuncInt Lib "DllExportSample.dll" (ByVal x As Long, ByVal y As Long) As Long

Private Sub cmd計算Long_Click()
On Error GoTo Error
    txt結果.Text = FuncInt(Val(txtX.Text), Val(txtY.Text))
Exit Sub
Error:
    Call MsgBox("[" & Err.Number & "]" & vbCrLf & Err.Description, vbCritical Or vbOKOnly)
End Sub
DllExport.cs
//int
[DllExport(CallingConvention = CallingConvention.StdCall)]
public static int FuncInt(int x, int y) => x + y + 2;

4) float(Single)

FormMain.vb
Private Declare Function FuncFloat Lib "DllExportSample.dll" (ByVal x As Single, ByVal y As Single) As Single

Private Sub cmd計算Single_Click()
On Error GoTo Error
    txt結果2.Text = FuncFloat(Val(txtX2.Text), Val(txtY2.Text))
Exit Sub
Error:
    Call MsgBox("[" & Err.Number & "]" & vbCrLf & Err.Description, vbCritical Or vbOKOnly)
End Sub
DllExport.cs
//float
[DllExport(CallingConvention = CallingConvention.StdCall)]
public static float FuncFloat(float x, float y) => x + y;

5) double(Double)

FormMain.vb
Private Declare Function FuncDouble Lib "DllExportSample.dll" (ByVal x As Double, ByVal y As Double) As Double

Private Sub cmd計算Double_Click()
On Error GoTo Error
    txt結果2.Text = FuncDouble(Val(txtX2.Text), Val(txtY2.Text))
Exit Sub
Error:
    Call MsgBox("[" & Err.Number & "]" & vbCrLf & Err.Description, vbCritical Or vbOKOnly)
End Sub
DllExport.cs
//double
[DllExport(CallingConvention = CallingConvention.StdCall)]
public static double FuncDouble(double x, double y) => (x + y) * 2;

6) long(Currency)

FormMain.vb
Private Declare Function FuncLong Lib "DllExportSample.dll" (ByVal x As Currency, ByVal y As Currency) As Currency

Private Sub cmd計算Currency_Click()
On Error GoTo Error
    'Currencyは小数点4桁固定で8バイトなので、C#のlongで10000倍された整数で扱える
    txt結果2.Text = FuncLong(Val(txtX2.Text), Val(txtY2.Text))
Exit Sub
Error:
    Call MsgBox("[" & Err.Number & "]" & vbCrLf & Err.Description, vbCritical Or vbOKOnly)
End Sub
DllExport.cs
//long
[DllExport(CallingConvention = CallingConvention.StdCall)]
public static long FuncLong(long x, long y) => (x + y) + 1;

7) bool(Boolean)

FormMain.vb
Private Declare Function FuncBool Lib "DllExportSample.dll" (ByVal x As Boolean) As Boolean

Private Sub chkBool_Click()
On Error GoTo Error
    chkBool.Caption = FuncBool(CBool(chkBool.Value))
Exit Sub
Error:
    Call MsgBox("[" & Err.Number & "]" & vbCrLf & Err.Description, vbCritical Or vbOKOnly)
End Sub
DllExport.cs
//bool
[DllExport(CallingConvention = CallingConvention.StdCall)]
[return: MarshalAs(UnmanagedType.U1)]
public static bool FuncBool([MarshalAs(UnmanagedType.U1)] bool x) => !x;

8) string(String)4

VB6 のString はBSTR だが、VB6 ではそのまま渡された場合、Unicode → ANSI に変換されてしまうため、StrPtr でString 変数のポインタを渡す。
戻り値は、同様にポインタとしてもらい、String にコピーすることで文字コードの変換を回避

FormMain.vb
Private Declare Function FuncString Lib "DllExportSample.dll" (ByVal x As Long) As Long
'ソース メモリ ブロックの内容をコピー先のメモリ ブロックにコピーし、重複するソース と宛先のメモリ ブロックをサポートします。
'VOID RtlMoveMemory(_Out_ VOID UNALIGNED *Destination, _In_ const VOID UNALIGNED *Source, _In_ SIZE_T Length);
Private Declare Sub RtlMoveMemory Lib "kernel32.dll" (ByVal Destination As Any, ByVal Source As Any, ByVal length As Long)

'新しい文字列を割り当て、渡された文字列をコピーします。BSTR SysAllocString([in, optional] const OLECHAR *psz);
Private Declare Function SysAllocString Lib "OleAut32.dll" (ByVal psz As Long) As Long

Private Sub cmdString_Click()
On Error GoTo Error
    Call MsgBox(PtrToString(FuncString(StrPtr(txt名前.Text))), vbOKOnly)
Exit Sub
Error:
    Call MsgBox("[" & Err.Number & "]" & vbCrLf & Err.Description, vbCritical Or vbOKOnly)
End Sub

Private Function PtrToString(ByRef x As Long) As String
On Error GoTo Error
    Dim L   As Long
    L = SysAllocString(x)
    
    Dim result  As String
    Call RtlMoveMemory(ByVal VarPtr(result), ByVal VarPtr(L), LenB(L))
    PtrToString = result
Exit Function
Error:
    Call MsgBox("[" & Err.Number & "]" & vbCrLf & Err.Description, vbCritical Or vbOKOnly)
End Function
DllExport.cs
    //string
    [DllExport(CallingConvention = CallingConvention.StdCall)]
    [return: MarshalAs(UnmanagedType.LPWStr)]
    public static string FuncString([MarshalAs(UnmanagedType.LPWStr)] StringBuilder x) => $"{x}さん、こんにちは";

9) コールバック関数(関数ポインタ)

コールバック関数として渡すメソッドを標準モジュールに作り、AddressOf で関数ポインタを渡す。

FormMain.vb
Private Declare Sub FuncCallback Lib "DllExportSample.dll" (ByVal callback As Long, ByVal x As Long, ByVal y As Long)

Private Sub cmdCallback_Click()
On Error GoTo Error
    Call FuncCallback(AddressOf callback, 3, 4)
Exit Sub
Error:
    Call MsgBox("[" & Err.Number & "]" & vbCrLf & Err.Description, vbCritical Or vbOKOnly)
End Sub
mdlCallback.bas
Public Function callback(ByVal x As Long, ByVal y As Long) As Long
    callback = x * y
End Function
DllExport.cs
//コールバック関数用デリゲート
[UnmanagedFunctionPointer(CallingConvention.StdCall, CharSet = CharSet.Unicode)]
public delegate int CallBack(int x, int y);

//コールバック関数
[DllExport(CallingConvention = CallingConvention.StdCall)]
public static void FuncCallback([MarshalAs(UnmanagedType.FunctionPtr)] CallBack func, int x, int y) => MessageBox.Show($"func({x}, {y})={func(x, y)}");

10) 可変長配列5

FormMain.vb
Private Declare Function FuncArray Lib "DllExportSample.dll" (ByVal values As Long, ByVal length As Long) As Long

Private Sub cmd配列_Click()
On Error GoTo Error
    Dim 合計        As Long
    Dim values(9)   As Long
    Dim loopIndex   As Long
    '1,2,3,4,5,6,7,8,9,10という値の入った配列を用意
    For loopIndex = 0 To UBound(values)
        values(loopIndex) = loopIndex + 1
    Next
    '全部の合計を求める
    合計 = FuncArray(VarPtr(values(0)), UBound(values) + 1)
    
    Dim results()   As String
    'VB6では、配列が確保されていないときUbound()をすると例外が発生してしまうので、Ubound = -1になるよう配列を初期化
    results = Split(vbNullString, vbNullChar)
    'Longの配列の中身を、Stringの配列に移し替える
    For loopIndex = 0 To UBound(values)
        ReDim Preserve results(UBound(results) + 1)
        results(UBound(results)) = CStr(values(loopIndex))
    Next

    '1 + 2 + 3 + 4 + 5 + 6 + 7 + 8 + 9 + 10=55
    Call MsgBox(Join(results, " + ") & "=" & CStr(合計), vbOKOnly)
Exit Sub
Error:
    Call MsgBox("[" & Err.Number & "]" & vbCrLf & Err.Description, vbCritical Or vbOKOnly)
End Sub
DllExport.cs
//可変長配列
//参考URL
//Default Marshalling for Arrays
//https://learn.microsoft.com/en-us/dotnet/framework/interop/default-marshalling-for-arrays
[DllExport(CallingConvention = CallingConvention.StdCall)]
[System.Diagnostics.CodeAnalysis.SuppressMessage("Style", "IDE0060:未使用のパラメーターを削除します", Justification = "<保留中>")]
public static int FuncArray([MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 1)] int[] array, int length) =>
    //配列の数値を合計した値を返す
    array.Sum();

11) ウィンドウハンドル

FormMain.vb
Private Declare Sub FuncWindowHandle Lib "DllExportSample.dll" (ByVal handle As Long)

Private Sub cmdフォーム_Click()
On Error GoTo Error
    Call FuncWindowHandle(Me.hWnd)
Exit Sub
Error:
    Call MsgBox("[" & Err.Number & "]" & vbCrLf & Err.Description, vbCritical Or vbOKOnly)
End Sub
DllExport.cs
//ウィンドウハンドル
[DllExport(CallingConvention = CallingConvention.StdCall)]
public static void FuncWindowHandle(IntPtr x)
{
    var handle = Control.FromHandle(x);

    using var dialog = new FormMain();
    dialog.ShowDialog(handle);
}

12) double(Date)

Date (日付型)は、IEEE 64 bit(8byte) として格納されているのでdouble で扱える。

FormMain.vb
Private Declare Function FuncDate Lib "DllExportSample.dll" (ByVal x As Date) As Date

Private Sub cmdDate_Click()
On Error GoTo Error
    Call MsgBox("明日は" & Format$(FuncDate(Now()), "yyyy/mm/dd hh:mm:ss") & "です。", vbInformation Or vbOKOnly)
Exit Sub
Error:
    Call MsgBox("[" & Err.Number & "]" & vbCrLf & Err.Description, vbCritical Or vbOKOnly)
End Sub
DllExport.cs
//Date
[DllExport(CallingConvention = CallingConvention.StdCall)]
public static double FuncDate(double x) => DateTime.FromOADate(x).AddDays(1).ToOADate();

13) 構造体

省略(基本的には組み合わせなので下記URLを参考にアライメントを合わせてあげればいけるはず)
C#でレガシーな事をする方向けのまとめ

その他

・マルチスレッドは注意
マルチスレッド処理(または、SeriPort やHTTP 等)を行う場合は、やり取りをUI スレッド上で行うこと。

まとめ

忘れないように書いておこう

  1. C++からC# DLL を直接利用する方法

  2. [メモ]C# 8/9の言語機能を.NET Frameworkで使う

  3. 【Windows/C#】なるべく丁寧にDllImportを使う

  4. VBAの文字列変数とVarPtr,StrPtrについて、関係性を調べた。StrPtrとVB文字列型の正体について。

  5. Default Marshalling for Arrays

14
9
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
14
9