引数を渡す(下)

前回は仮の Invoke を定義しました。参考のため、下に Invoke のコードを再掲します。

__declspec( naked ) void Invoke( Delegate *pDelegate, ... )
{
    __asm
    {
        mov eax, dword ptr[ esp + 4 ] ; EAX = pDelegate;
        mov edx, dword ptr[ esp ]     ; EDX = RA(main);
        mov dword ptr[ esp + 4 ], edx ; pDelegate = RA(main);
        add esp, 4                    ; ESP += 4;
        mov ecx, dword ptr[ eax ]     ; ECX = old_pDelegate->obj;
        jmp dword ptr[ eax + 4 ]      ; goto old_pDelegate->funct;
    }
}

この前後のスタック状態の変化を見ると、メンバ関数から戻ってきたあとで問題が発生することがわかります。その様子を順番に見ていくことにしましょう。ここでは、32bit の引数を二つとるメンバ関数を呼び出すために、main で次のようなコードを書いた場合について説明します。

Invoke( pDelegate, arg1, arg2 );

コンパイラは、これを次のように翻訳します。

push arg2
push arg1
push pDelegate
call Invoke
add  esp, 12

Invoke の呼び出しを展開し、要約すると次のようになります。

push arg2                         ( +4 )
push arg1                         ( +4 )
push pDelegate                    ( +4 )
{
    mov eax, dword ptr[ esp + 4 ]
    mov edx, dword ptr[ esp ]
    mov dword ptr[ esp + 4 ], edx
    add esp, 4                    ( -4 )
    mov ecx, dword ptr[ eax ]
    {
        .
        .
        .
        ret 8                     ( -8 )
    }
}
add esp, 12                       ( -12 )

スタックの収支を計算すると -12 になり、12 バイト余計にクリアされていることがわかります。だからといって最後の ADD 命令をなくすことはできません。Invoke が C 呼び出し規約を使って呼び出されるため、main にスタックをクリアする義務があるからです。そのほか、どこを見ても調整できる余地はなさそうです。

そこで別の方法を考えました。具体的には、Invoke の中からメンバ関数に JMP するときに、リターン アドレスを「 main が期待している状態にスタックを修正するサンク」のアドレスに書き換え、メンバ関数終了後にサンクを実行させる方法です。サンクでは、以前に Invoke の中で保存しておいた ESP の値を MOV 命令で復元し、JMP 命令を使って本来のリターン アドレスに戻ります。

さて、その ESP の値とリターン アドレスを、どこに保存するのがよいでしょうか。少なくともサンクの内部から参照可能で、動的に容量を拡張でき、さらにスレッドごとに独立しているという三つの条件を満たすような場所が必要です。さらに、せっかくこれまで利便性を考慮して設計してきたわけですから、使用者に何の強制もしないことも条件です。

スレッド ローカル ストレージを使うのが無難そうですが、値の保存のための配列やリストの初期化を使用者が行なう(スレッドが開始された時点でコンストラクタを呼び出し、さらにスレッドの終了時にデストラクタを呼び出す)必要があるため煩雑です。しかし他に良い場所も見つからず、仕方なしに「サンクに必要な情報を提供する関数を動的に生成する」という特殊な方法を採ることにしました(あくまで利便性を考慮したのです)。つまり、次のようなコードをバイト列で表現し、それを仮想メモリに書き込んで実行させるわけです。

__asm
{
    push return_address
    push old_esp_val
    push my_address
    jmp  Thunk
}

これを実行すると、サンクを次のようにして呼び出したのと同じことになります。

Thunk( my_address, old_esp_val, return_address );

サンクはこれらの引数を元に ESP の値を復元し、確保された仮想メモリを解放し(これを忘れてはなりません!呼び出すたびに仮想メモリが浪費されることになります)、main のしかるべき位置に JMP します。

これ以降は実際のコードの紹介が続くので、少し短めですが今回はここまでとします。次回、最終的なプログラムを公開して、この企画を終了したいと思います。