Programming Field

VB で機械語を実行する

VB では便利な関数が多く定義されており、簡単、かつ安心にコードを書くことができますが、反面複雑なコードや関数を使う場合は、それらでは足りないこともあります。

例えば、C(C++) でいう「cdecl 呼び出し規約」の関数は、VB の Declare ステートメントで宣言しても正しく呼び出すことはできません。(場合によっては呼び出せますが、かなり危険です。)

そこで、VB でも正しい機械語を書いて、cdecl 呼び出し規約の関数を呼び出してしまおうというのが今回の目的です。

機械語について

今回は、32ビット Intel 系(互換)のプロセッサが読む機械語を扱います。機械語は、命令が特定の数値とほぼ1対1で対応しているので、実行したい命令に合わせて数値の羅列を作ることで機械語のコードが完成します。その作ったコードは、一定の手順を使って関数(など)として呼び出すことにより、実行することができます。

なお、以下では機械語のコードを「アセンブリコード」と呼んでいます。

cdecl 呼び出し規約について

「cdecl 呼び出し規約」は、C 言語の関数で多く用いられている物で、関数の引数が機械語でどのようにして渡されるかを定めています。cdecl 呼び出し規約では、関数の引数がいくつでも大丈夫なようになっています。

対して、Win32API や VB の関数で使用されているのは「stdcall 呼び出し規約」で、こちらは関数の引数を最初から固定しています。(※ 引数に ParamArray のある関数は、結果的に配列のポインタを渡していることになるので stdcall 呼び出し規約です。)

両者は、機械語において関数の引数をスタックに積んだとき、そのスタックを後処理する方法に違いがあります。cdecl 呼び出し規約の関数は引数をいくつでも受け付けられるので、逆に引数がいくつ渡されたかわかりません。そのため、その関数を呼び出す側でスタックを元に戻さないといけません。逆に stdcall 呼び出し規約の関数は引数がいくつか知っているので、関数が終わるときにスタックを戻します。(そうすると関数を呼び出す側でその処理を書く必要はないため、コードのサイズがちょっとだけ減ります。)

VB の Declare ステートメントでは stdcall 呼び出し規約の関数にしか対応していないため、以下の手順を使って上手く呼び出さないといけません。

今回作るもの

ということで今回は「cdecl 呼び出し規約の関数を呼び出す関数」を作ります。

まずは、cdecl 呼び出し規約の関数「foo」(引数は 32 ビットの整数 2 つ)に、変数「val1」と「val2」の値を引数として機械語で呼び出す例を以下に示します。

※ 完全なアセンブリコードではありません。

    mov   ecx, val2
    push  ecx
    mov   ecx, val1
    push  ecx
    call  foo
    add   esp, 8

ここで重要なのは太字で示した「add esp, 8」というコードで、上記で挙げたスタックの後処理を行っています。この後処理は、引数の総バイト数([32 ビット = 4 バイト] x 2 = 8)だけスタックのポインタに加算するものです。なぜ加算するかというと、「push」命令でスタックに積むとき、スタックのポインタは減算されるので、単純にそれを戻すためです(「対」を考えるなら push に対して「pop」も可能)。

では、実際に VB で関数を作ってみます。今回は汎用性も考え、「任意個の 32 ビット型整数を引数に取れ、それを cdecl 呼び出し規約の関数に渡して呼び出す関数」を作ります。

(※ 今回は、戻り値が 32 ビットではない関数は考慮していません。)

先にアセンブリコードの概要を示します。このコードは仮の関数として「stdcall 呼び出し規約で、32 ビット型整数を 1 つ引数に取る」ようになっています。各行の先頭に書かれているのは対応するバイナリです。

                ; ecx を一時保存
51              push  ecx

;;;;;;;;;;;;;;;;; 引数をスタックに積む処理
                ; Case1: 引数が 256 未満 → サイズが節約できるコード
6A XX           push  XX
                ; Case2: 引数が 256 以上 → 真面目に32ビット値を渡すコード
B9 XX XX XX XX  mov   ecx, XXXXXXXX
51              push  ecx
;;;;;;;;;;;;;;;;; これらの処理を引数の数だけ行う

                ; 関数のアドレスを設定
B9 XX XX XX XX  mov   ecx, Address
                ; 関数を呼び出す
FF D1           call  ecx
                ; スタックを戻す
83 C4 XX        add   esp, count

                ; 戻り値を受け取るの変数を設定
B9 XX XX XX XX  mov   ecx, ret-ptr
                ; 戻り値を設定
89 01           mov   dword ptr[ecx], eax
                ; 仮の関数の戻り値を 0 にする
33 C0           xor   eax, eax
                ; ecx を元に戻す
59              pop   ecx
                ; 関数の終了 (stdcall 呼び出し規約なのでここでスタック調整)
C2 04           ret   4

バイナリが「XX」となっている部分は可変の数値やポインタのアドレスなどが入ります。このアセンブリコードのバイナリを用いて書いた VB の関数を以下に示します。

2008/05/31 「push XX」で、XX は符号付きの値として扱われるため、それに合わせて条件分岐を修正しました。これを指摘してくださったpizzさんありがとうございます。

[VB 6.0]

' メモリ上に関数を作るために使用する関数
Public Declare Sub CopyMemory Lib "kernel32.dll" Alias "RtlMoveMemory" _
    (ByRef Destination As Any, ByRef Source As Any, _
    ByVal Length As Long)
Private Declare Function FlushInstructionCache Lib "kernel32.dll" _
    (ByVal hProcess As Long, ByVal lpBaseAddress As Long, _
    ByVal dwSize As Long) As Long
Private Declare Function GetCurrentProcess Lib "kernel32.dll" () As Long
Private Declare Function VirtualAlloc Lib "kernel32.dll" _
    (ByVal lpAddress As Long, ByVal dwSize As Long, _
    ByVal flAllocationType As Long, ByVal flProtect As Long) As Long
Private Declare Function VirtualFree Lib "kernel32.dll" _
    (ByVal lpAddress As Long, ByVal dwSize As Long, _
    ByVal dwFreeType As Long) As Long

' メモリ上に作った関数を呼び出させるために使う関数
' 新たにスレッドを作って実行する
Private Declare Function CreateThread Lib "kernel32.dll" _
    (ByRef lpThreadAttributes As Any, ByVal dwStackSize As Long, _
    ByVal lpStartAddress As Long, ByVal lpParameter As Long, _
    ByVal dwCreationFlags As Long, ByRef lpThreadId As Long) As Long
Private Declare Function CloseHandle Lib "kernel32.dll" _
    (ByVal hObject As Long) As Long
Private Declare Function WaitForSingleObject Lib "kernel32.dll" _
    (ByVal hHandle As Long, ByVal dwMilliseconds As Long) As Long

Private Const INFINITE As Long = &HFFFFFFFF

Private Const PAGE_EXECUTE_READWRITE As Long = &H40&
Private Const MEM_COMMIT As Long = &H1000&
Private Const MEM_RELEASE As Long = &H8000&

' cdecl 呼び出し規約の関数を呼び出す関数
'   Address: 関数のアドレス
'   Arguments(): 引数となる 32 ビット値の配列
'                (添え字が小さい順に第1引数、第2引数…)
'   戻り値: Address で指定した関数の戻り値
Public Function CallCdeclFunction(ByVal Address As Long, ByRef Arguments() As Long)
    Dim buff() As Byte, ln As Long, ret As Long
    Dim l As Long, i As Long, v As Variant
    Dim c As Long, hThread As Long

    ' 引数の設定に必要なコードのサイズ(ln)と、
    ' 引数を積むのに使うスタックのサイズ(ret)を計算
    ln = 0
    ret = 0
    For Each v In Arguments
        l = CLng(v)
        If (l And &HFFFFFF80) = 0 Or (l And &HFFFFFF80) = &HFFFFFF80 Then
            ' push XX → 2バイト
            ln = ln + 2
        Else
            ' mov ecx, XXXXXXXX / push ecx
            ' → 6バイト
            ln = ln + 6
        End If
        ret = ret + 4
    Next v
    ' Address が設定されていないか、引数が 256 バイト
    ' 以上のときはエラーにする
    If Address = 0 Or ret >= 256 Then
        Call Err.Raise(13)
        Exit Function
    End If

    ' サイズ + 1 を c にキープ
    c = ln + 1
    ' コードのバイナリはさらに 23 バイト必要
    ln = ln + 23
    ' バイナリを配列に入れるため、領域を確保する
    ReDim buff(ln)

    ' 以下、コードのバイナリを設定していく
    buff(0) = &H51       ' push  ecx

    ' 引数は後ろから順に push するので
    ' 引数を積むコードを後ろから書いていく
    i = c - 1
    ' ret: 引数を積むのに使うスタックのサイズ
    ret = 0
    For Each v In Arguments
        l = CLng(v)
        If (l And &HFFFFFF80) = 0 Or (l And &HFFFFFF80) = &HFFFFFF80 Then
            buff(i - 1) = &H6A ' push  XX
            buff(i) = CByte(l)
            i = i - 2
        Else
            buff(i - 5) = &HB9 ' mov   ecx, XXXXXXXX
            ' 32 ビットの値をバイナリに変える
            Call CopyMemory(buff(i - 4), l, 4)
            buff(i) = &H51 ' push  ecx
            i = i - 6
        End If
        ret = ret + 4
    Next v
    i = c
    buff(i) = &HB9       ' mov   ecx, Address
    Call CopyMemory(buff(i + 1), Address, 4)
    buff(i + 5) = &HFF   ' call  ecx
    buff(i + 6) = &HD1
    buff(i + 7) = &H83   ' add   esp, ret
    buff(i + 8) = &HC4
    buff(i + 9) = CByte(ret)
    ' 変数 ret のポインタ アドレスを取得する
    l = VarPtr(ret)
    buff(i + 10) = &HB9  ' mov   ecx, ret-ptr
    Call CopyMemory(buff(i + 11), l, 4)
    buff(i + 15) = &H89  ' mov   dword ptr[ecx], eax
    buff(i + 16) = &H1
    buff(i + 17) = &H33  ' xor   eax, eax
    buff(i + 18) = &HC0
    buff(i + 19) = &H59  ' pop   ecx
    buff(i + 20) = &HC2  ' ret   4
    buff(i + 21) = &H4
    buff(i + 22) = 0

    ' 関数の実行ができるメモリを割り当てる
    l = VirtualAlloc(0, ln, MEM_COMMIT, PAGE_EXECUTE_READWRITE)
    ' メモリに関数コードのバイナリをコピーする
    Call CopyMemory(ByVal l, buff(0), ln)
    ' メモリに写したデータがシステムに反映されるようにする
    Call FlushInstructionCache(GetCurrentProcess(), l, ln)

    ret = 0
    ' スレッドを作成し、作成した関数を呼び出させる
    hThread = CreateThread(ByVal 0&, 0, l, 0, 0, c)
    ' スレッドが終了するまで待機
    Call WaitForSingleObject(hThread, INFINITE)
    ' スレッドのハンドルを閉じる
    Call CloseHandle(hThread)

    ' メモリを解放する
    Call VirtualFree(l, 0, MEM_RELEASE)
    ' 関数の戻り値が ret に入っているはずなのでそれを返す
    CallCdeclFunction = ret
End Function

ここでのキーポイントは VirtualAllocCreateThread です。

VirtualAlloc は、通常は大きなサイズのメモリを割り当てるときに使いますが、コードをコピーして実行することが可能なメモリを割り当てるときにも使えます。通常のメモリでは「実行」の属性が無いので、場合によってはエラーが発生しますが、この関数を、PAGE_EXECUTE_READWRITE などの保護モードで呼び出せば欲しいメモリのアドレスがもらえます。

CreateThread は、本来は「スレッド」を作成し、メインのコードを実行している傍ら別の作業を行う、というときなどに使います。ここでは、単にメモリ上にあるコードを呼び出すという目的にのみ使っています。これは、VB にはアドレスを指定して関数を呼び出す(Quick BASIC でいう CALL ABSOLUTE 命令)が無いためで、CreateThread 以外の関数を使うなら EnumWindows 関数など、引数にコールバック関数を指定でき、且つ他の動作に影響を与えない関数を使用してください。

注意点は、アセンブリコードとバイナリの対応は自分で調べる必要があるということです。方法としては、アセンブラを入手し、アセンブリコードを書いてアセンブルし、出力ファイルをバイナリエディタで見るのがいいかもしれません。

CallCdeclFunction の使用例

これでかなり多くの関数を VB から呼び出せるようになりました。ということで CallCdeclFunction の使用例として、msvcrt.dll (Microsoft の C 言語用ランタイムライブラリ) に定義されている「swprintf」関数を呼び出してみます。

※ 似た関数である wvsprintf の呼び出しについては、次のページで別のアプローチを紹介しています。→ 「VB6.0 で wvsprintf を使ってみる」

[VB 6.0]

' DLL の関数アドレスを取得するのに使用
Private Declare Function LoadLibrary Lib "kernel32.dll" Alias "LoadLibraryA" _
    (ByVal lpLibFileName As String) As Long
Private Declare Function FreeLibrary Lib "kernel32.dll" _
    (ByVal hLibModule As Long) As Long
Private Declare Function GetProcAddress Lib "kernel32.dll" _
    (ByVal hModule As Long, ByVal lpProcName As String) As Long
' CopyMemory は CallCdeclFunction で使っているため省略

' Double 型を 2 つの 32 ビット型整数に分ける処理
Private Sub SplitDoubleToTwoLong(ByVal d As Double, _
    ByRef LoDWord As Long, ByRef HiDWord As Long)
    Dim l(0 To 1) As Long
    Call CopyMemory(l(0), d, 8)
    LoDWord = l(0)
    HiDWord = l(1)
End Sub

' Single (float) 型を 1 つの 32 ビット型整数に変換する処理
Private Function FloatToLong(ByVal f As Single) As Long
    Call CopyMemory(FloatToLong, f, 4)
End Function

' MyWPrintF: swprintf (sprintf) の VB 版(Unicode が使える)
'   Format: 書式付き文字列 (%d は数値に置き換わる、など)
'   Arguments(): 書式に対応する引数
'   戻り値: 書式を適用した後の文字列
'   ※ Arguments に構造体などが渡された場合はエラーが起こる
'   ※ 関数を呼び出すところ以外はほぼ wvsprintf のときと同じコード
Public Function MyWPrintF(ByVal Format As String, _
    ParamArray Arguments() As Variant) As String
    Dim Buffer As String, i As Long, c As Long, v As Variant
    Dim Pointers() As Long, Strings() As String
    Dim hDLL As Long

    ' 出力先バッファのサイズを計算(i)
    ' Len でも問題ないが、敢えて多めに取るつもりで LenB を使う
    i = LenB(Format)
    ' c では Pointers の要素数を計算する
    c = 0
    For Each v In Arguments
        ' 長さの見当をつける(文字列としての長さを追加する)
        i = i + Len(CStr(v)) + 3
        If VarType(v) = vbDouble Then
            ' Double 型は 8 バイト必要
            c = c + 1
        End If
        c = c + 1
    Next v
    ' 念のため追加で 20 増やす
    i = i + 20
    ' バッファを割り当てる(NULL 文字で埋める)
    Buffer = String$(i, 0)
    If c > 0 Then
        ' Buffer と Format も引数なのでその分を増やす
        ' (1 しか増やさないのは「0 To c」に関連)
        c = c + 1
        ' Pointers 配列を設定する
        ' Strings 配列は ANSI 文字列が破棄されない
        ' ようにするための配列(念のため)
        ReDim Pointers(0 To c), Strings(0 To c)
        c = 2
        For Each v In Arguments
            Select Case VarType(v)
                Case vbString
                    ' swprintf は Unicode 関数なので変換する必要が無い
                    Strings(c) = CStr(v)
                    Pointers(c) = StrPtr(Strings(c))
                Case vbSingle
                    Pointers(c) = FloatToLong(CSng(v))
                Case vbDouble
                    Call SplitDoubleToTwoLong(CDbl(v), _
                        Pointers(c), Pointers(c + 1))
                    c = c + 1
                Case Else
                    Pointers(c) = CLng(v)
            End Select
            c = c + 1
        Next v
    Else
        ' Buffer と Format のみが引数
        ReDim Pointers(0 To 1)
    End If
    ' Buffer と Format を引数リストに追加
    Pointers(0) = StrPtr(Buffer)
    Pointers(1) = StrPtr(Format)
    ' DLL をロードする
    hDLL = LoadLibrary("msvcrt.dll")
    ' GetProcAddress で swprintf の関数アドレスを取得して
    ' CallCdeclFunction を呼び出す
    Call CallCdeclFunction(GetProcAddress(hDLL, "swprintf"), Pointers)
    ' DLL を解放する
    Call FreeLibrary(hDLL)
    ' NULL 文字を除去して戻り値とする
    i = InStr(Buffer, Chr$(0))
    MyWPrintF = Left$(Buffer, i - 1)
End Function

この関数の使用例としては、

  MyWPrintF("Result: %d, %1.3lf", 12, CDbl(1.245))

と呼び出すと「Result: 12, 1.25」という文字列を得ることができます。