C++ などから呼べるDLL をC# で作れるDllExport というツールがあります。
この記事は、そのDLL をVB6 やVBA から呼ぶという、極めてマイナーな方法を行う際のマーシャリング方法について記述した個人的な備忘録です。
VB6 とは
1990年代の使われていた言語で、現在はその派生形がOffice 製品のVBA で生き残っている。
特に、最終バージョンのVB6.0 では、ActiveX コンポーネントを組み合わせることでC++ などと比較して高い生産性を確保でき、開発者が確保しやすいことから日本ではVB6.0 を使った業務システムなどが大量に作られた。
継承やマルチスレッド等がないものの、クラスを使ったカプセル化などは行えるので、一応オブジェクト指向っぽい開発も行える。
Visual Basic
DllExport1 とは
C# でC++ などで作られたネイティブDLL のように扱えるDLL を生成できるツール
なぜこんなことをしようと思ったのか
前職で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を使えるように設定
<PropertyGroup>
<LangVersion>10.0</LangVersion>
</PropertyGroup>
C#10を使えるようにするのは、DllExportを実装する際、ファイル スコープ namespaceと、モジュール初期化を使えるようにするため。
4) VB6側から呼ぶメソッドを実装
基本形はこんな感じ
//ファイル スコープ 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
//.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)
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(txt値X.Text), Val(txt値Y.Text))
Exit Sub
Error:
Call MsgBox("[" & Err.Number & "]" & vbCrLf & Err.Description, vbCritical Or vbOKOnly)
End Sub
//byte
[DllExport(CallingConvention = CallingConvention.StdCall)]
public static byte FuncByte(byte x, byte y) => (byte)((x + y) & 0xFF);
2) short(Integer)
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(txt値X.Text), Val(txt値Y.Text))
Exit Sub
Error:
Call MsgBox("[" & Err.Number & "]" & vbCrLf & Err.Description, vbCritical Or vbOKOnly)
End Sub
//short
[DllExport(CallingConvention = CallingConvention.StdCall)]
public static short FuncShort(short x, short y) => (short)(x + y + 1);
3) int(Long)
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(txt値X.Text), Val(txt値Y.Text))
Exit Sub
Error:
Call MsgBox("[" & Err.Number & "]" & vbCrLf & Err.Description, vbCritical Or vbOKOnly)
End Sub
//int
[DllExport(CallingConvention = CallingConvention.StdCall)]
public static int FuncInt(int x, int y) => x + y + 2;
4) float(Single)
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(txt値X2.Text), Val(txt値Y2.Text))
Exit Sub
Error:
Call MsgBox("[" & Err.Number & "]" & vbCrLf & Err.Description, vbCritical Or vbOKOnly)
End Sub
//float
[DllExport(CallingConvention = CallingConvention.StdCall)]
public static float FuncFloat(float x, float y) => x + y;
5) double(Double)
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(txt値X2.Text), Val(txt値Y2.Text))
Exit Sub
Error:
Call MsgBox("[" & Err.Number & "]" & vbCrLf & Err.Description, vbCritical Or vbOKOnly)
End Sub
//double
[DllExport(CallingConvention = CallingConvention.StdCall)]
public static double FuncDouble(double x, double y) => (x + y) * 2;
6) long(Currency)
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(txt値X2.Text), Val(txt値Y2.Text))
Exit Sub
Error:
Call MsgBox("[" & Err.Number & "]" & vbCrLf & Err.Description, vbCritical Or vbOKOnly)
End Sub
//long
[DllExport(CallingConvention = CallingConvention.StdCall)]
public static long FuncLong(long x, long y) => (x + y) + 1;
7) bool(Boolean)
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
//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 にコピーすることで文字コードの変換を回避
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
//string
[DllExport(CallingConvention = CallingConvention.StdCall)]
[return: MarshalAs(UnmanagedType.LPWStr)]
public static string FuncString([MarshalAs(UnmanagedType.LPWStr)] StringBuilder x) => $"{x}さん、こんにちは";
9) コールバック関数(関数ポインタ)
コールバック関数として渡すメソッドを標準モジュールに作り、AddressOf で関数ポインタを渡す。
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
Public Function callback(ByVal x As Long, ByVal y As Long) As Long
callback = x * y
End Function
//コールバック関数用デリゲート
[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
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
//可変長配列
//参考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) ウィンドウハンドル
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(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 で扱える。
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
//Date
[DllExport(CallingConvention = CallingConvention.StdCall)]
public static double FuncDate(double x) => DateTime.FromOADate(x).AddDays(1).ToOADate();
13) 構造体
省略(基本的には組み合わせなので下記URLを参考にアライメントを合わせてあげればいけるはず)
C#でレガシーな事をする方向けのまとめ
その他
・マルチスレッドは注意
マルチスレッド処理(または、SeriPort やHTTP 等)を行う場合は、やり取りをUI スレッド上で行うこと。
まとめ
忘れないように書いておこう