引数を渡す(上)

本題に入る前に、利便性を考慮して、前回設計した Invoke を改良します。Delegate という構造体を作り、それを引数にとるようにします。

struct Delegate
{
    void *obj;
    void *funct;
};

__declspec( naked ) void __stdcall Invoke( Delegate *pDelegate )
{
    __asm
    {
        mov  eax, dword ptr[ esp + 4 ] ; EAX = pDelegate
        mov  ecx, dword ptr[ eax ]     ; ECX = pDelegate->obj;
        call dword ptr[ eax + 4 ]      ; obj->funct();
        ret  4                         ; return;
    }
}

Invoke を構造体のメンバ関数にするのが最善と思われますが、残念ながらメンバ関数では __declspec( naked ) を使用できないため、グローバル関数のままにしておきます。

さて今回は、引数の決まっているメンバ関数を Invoke で呼び出すことを目標にします。

#include <iostream>
using namespace std;

class ClassZ
{
public:
    void FunctZ( int i )
    {
        cout << "ClassZ::FunctZ with " << i << endl;
    }
};

int main( void )
{
    ClassZ z;
    
    Delegate dlgt = { &z, ConvertMethod( &ClassZ::FunctZ ) };
    
    Invoke( &dlgt, 100 ); // equals z.FunctZ( 100 );
    
    return 0;
}

ConvertMethod は前回のままで問題ありません。テンプレートの機能のおかげです。一方 Invoke に関しては書き換える必要があり、それには二つの異なった設計が考えられます。一つは、引数の異なるメンバ関数ごとに、オーバーロードの機能を利用して別々の Invoke を定義する方法です。もう一つは、あらゆる引数に対応できる唯一の Invoke を定義する方法です。

私はこの実験を始めた当初は、最初の方法が良いと考えていました。それは、引数が異なる場合は別の Invoke を定義しなければならないという点が、マネージ拡張におけるデリゲートの仕組みと似ているからです。技術的に考えても、こちらのほうが簡単です。しかし、本物のデリゲートは宣言するだけでよい(定義はコンパイラが暗黙的に行なう)のですが、この場合は定義も変更する必要が生じます。そうなると、Invoke の使用者にはアセンブリ言語の知識が要求されます。二つ目の方法では、引数がタイプセーフでないという問題はありますが、Invoke をオーバーロードする必要がなくなるため、使用者の側からすると便利です。

以上のことから、私は二つ目の「あらゆる引数に対応できる唯一の Invoke を定義する」という方法を実現してみることにしました。まず Invoke の宣言は次のようになります。

void Invoke( Delegate *pDelegate, ... );

__stdcall が消えていますが、これは省略可能な引数をもつ関数は、必ず C 呼び出し規約を使用することになっていて、Pascal 呼び出し規約は使えないからです。さて宣言は簡単ですが、どうやって定義すればいいのでしょうか。もちろん、基本原則は前回述べた通りです。以下に再掲します。

1, 引数を、右から左の順に PUSH していく
2, ECX レジスタに、オブジェクトのアドレスを代入する
3, CALL 命令を使って、メンバ関数にジャンプする

これに従えば、Invoke の内部で次の手順を踏めばよいということになります。ここでは 32bit の引数を 2 つもつメンバ関数の呼び出しを行なう場合の例を示します。

in Invoke
                              [ RA(main) ][ pDelegate ][ arg1 ][ arg2 ]

Invoke が呼び出された直後のスタックの状態は上のようになっています。普通、スタックの図というものは要素を上下に並べて描くのですが、スタックの変化を見るためにわざと右左に並べて描きます。なお、[ ] はそれぞれ 32bit です。この状態から、arg1 と arg2 をそのままコピーする形で再び PUSH します。

in Invoke
              [ arg1 ][ arg2 ][ RA(main) ][ pDelegate ][ arg1 ][ arg2 ]
              ^^^^^^^^^^^^^^^^                         ^^^^^^^^^^^^^^^^

このあと ECX にオブジェクトのアドレスを代入し、メンバ関数のアドレスをオペランドとして CALL を呼び出すと、スタックは次のようになります。

in funct
[ RA(Invoke) ][ arg1 ][ arg2 ][ RA(main) ][ pDelegate ][ arg1 ][ arg2 ]
              ^^^^^^^^^^^^^^^^                         ^^^^^^^^^^^^^^^^

これが出来ればよいのですが、現実には困難です。正直なところ、不可能だと思います。なぜなら、Invoke の引数は「省略可能」であり、Invoke には ... の部分のサイズがわからないため、「そのままコピー」といっても何バイトをコピーすればよいかわからないからです。図で表すなら、次のような状態です。

in funct
           [ RA(Invoke) ] ... [ RA(main) ][ pDelegate ] ...
                         ^^^^^                         ^^^^^

... の部分のサイズがわからない以上、コピーのしようがありません。そこで私はこの方法をあきらめ、「引数を渡す別の手段」を発見するべく、雨の日も風の日もデバッガのメモリ ウィンドウばかりを眺めて暮らしていましたが(冗談です)、あるとき「 pDelete さえなかったら、リターン アドレスは変になるが呼び出せる」ことに気がつきました。そして次のようにすればよいと考えました。

in Invoke
[ RA(main) ][ pDelegate ] ...
Invoke の呼び出し時点で、スタックは上のようになっています。そこでアセンブリ命令を駆使して次のように改造します。
in Invoke
            [ RA(main)  ] ...

これだけです。引数をコピーする必要は全くありません。なぜこれでよいのかを説明すると、コンパイラが関数を翻訳する際、呼び出し時点で ESP + 4 の位置から引数が始まっているものと想定して処理を進めます。逆に言えば、引数が ESP + 4 から始まってさえいれば、誰がスタック領域に値をコピーしようが関係ないということです。早い話が、main がスタックにコピーした引数をそのまま「流用」したのです。また、リターン アドレスは本来 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;
    }
}

funct の呼び出しに JMP を使っていて、最後にあるべき RET 命令がないことは、Invoke が funct を呼び出すためのサンクとして動作することを意味します。つまり、JMP のあとで Invoke に戻ってくることはありません。funct からは直接 main に戻ります。