Visual C++インラインアセンブラをx64に移植する

 

C++からアセンブラの関数を呼び出す方法

プロトタイプ宣言

アセンブラの関数をC++から呼び出せるようにプロトタイプ宣言をします。

C++では同じ名前の関数をオーバーロードできます。これを実現するために、コンパイラは、ソースに書いてある関数名と、引数型などの情報をエンコードした文字列をくっつけて、実際の関数名を生成します。 この形でアセンブラで関数を書こうとすると、C++コンパイラがどんな名前に変換するかを調べてから、その名前でアセンブラ側の関数を書く、というようなことをする必要があります。これは結構面倒です。

Cタイプの関数としてプロトタイプ宣言をしてしまうと簡単です。これならC++でもアセンブラでもソース内は同じ関数名でいけます。ただしオーバーロードはもちろんできなくなりますしC++固有の機能(int&型を引数に取るとか)も使えなくなります。

extern "C" {
    // a*b+cを128ビットで計算して、結果の下位64ビットを*pLoに、上位64bitを*pHiに返す。
    void muladd128(ULONGLONG a, ULONGLONG b, ULONGLONG c, ULONGLONG* pHi, ULONGLONG* pLo);
}

あとはふつうにC++からこの関数を呼び出せます。

アセンブラの関数定義

アセンブラ側では以下の形で関数定義します。

_TEXT   segment

        public muladd128
        align  16
muladd128  proc
        
        ; (関数の処理)
        
        ret
muladd128  endp

_TEXT   ends
        end

procからendpまでが関数です。両方に同じ名前を書く必要があります。

retは必須です。retを書かないとリターンせずにendpを越えて下に進んでしまうので忘れないようご注意ください。endpではリターンしません。 endでもしません。

align 16は、次の行を16バイト境界に配置するようにアセンブラに指示します。callやジャンプの飛び先のアドレスが16の倍数になっていると、実行速度が少し速くなります。関数の入り口やループの頭など、上から降りてくるよりもよそから飛んでくる回数が多い場所のラベルの前にはこれを書くといいと思います。

「_TEXT segment」と「_TEXT ends」は、その間のコードを_TEXTセグメントに配置するという指定です。.asmの中に実行命令だけを書いてC/C++とリンクするなら.asmファイルの最初と最後に固定でこれを書けばいいと思います。ファイルの最後はendで締めます。

ひとつの.asmファイルの中にいくつも関数(proc~endp)を書けます。

//や/* */のコメントは使えません。各行のセミコロン以降がコメントになります。

パラメータの引渡し

関数が呼び出されたときのスタックの状態はこうなっています(この図の上下が逆だと思う方はすみませんが逆に見てください)。すべてのパラメータはひとつ8バイトです。1,2,4,8バイトのパラメータはこの方法で渡せます。それ以外のサイズのものはポインタにして渡します。ちなみにRSPはESPの64bit版です。

パラメーターを格納するためのスタック領域はhomeと呼ばれ、呼び出し側で確保、解放されます。ただし、第1~第4パラメータのhomeは留守です。中にはゴミが入っているだけです。実際のパラメータはレジスタで渡されます。呼ばれた側で必要ならパラメータを格納して使えるようにhomeの領域だけが確保されています。

第1~第4パラメータは以下のレジスタに入ってきます。

  整数型・ポインタ 実数型
第1パラメータ RCX XMM0 ※
第2パラメータ RDX XMM1 ※
第3パラメータ R8 XMM2 ※
第4パラメータ R9 XMM3 ※

RCXとRDXはECX、EDXの64ビット版です。R8とR9はx64で追加された、ふつうに演算とかに使える64ビットレジスタです。R8D、R8W、R8Bのような名前にすれば下位32/16/8ビットを使うことができます。このあたりの話はググればいくらでも見つかると思いますので詳細は割愛します。

第5パラメータ以降はスタックに入ってきます。

※ XMMレジスタひとつで渡されるのは実数ひとつだけです(double型は下位64ビット、float型は最下位32ビット)。__m128d型などはポインタで渡されます。

戻り値

関数の戻り値がある場合はRAXに格納して返します。RAXはEAXの…ってもういいですよね。実数型を返す場合はXMM0に返します。

壊していいレジスタ

下図で白地のレジスタ(RAX、RCX、RDX、R8~R11、XMM0~5)は呼び出された側で壊してかまいません。

下図で色付きのレジスタは壊してはいけないレジスタです。

フラグについては特に記述はないようですがセーブしろという記述もないのでふつうに演算で変わるやつは変えていいんだと思います。

コーディング例

上でプロトタイプ宣言した関数の中身を書いてみます。

_TEXT   segment

; a*b+cを128ビットで計算して、結果の下位64ビットを*pLoに、上位64bitを*pHiに返す。
; void muladd128(ULONGLONG a, ULONGLONG b, ULONGLONG c, ULONGLONG* pHi, ULONGLONG* pLo);
; RCX = a
; RDX = b
; R8 = c
; R9 = pHi
; [rsp+40] = pLo

        public muladd128
        align  16
muladd128   proc
pLo = 40
        mov     rax, rdx         ; rax = b
        mul     rcx              ; rdx:rax = a*b
        add     rax, r8          ; rdx:rax = a*b+c
        adc     rdx, 0           ; 桁上り
        mov     r10, qword ptr [rsp+pLo]      ; r10 = pLo
        mov     qword ptr [r9], rdx           ; *pHi = 上位64bit
        mov     qword ptr [r10], rax          ; *pLo = 下位64bit
        ret
muladd128   endp

_TEXT   ends
        end

64bit命令セット

基本的には32bit命令が素直に64ビット化されているので32bitと同じような要領でコーディングできると思います。32/16/8ビットデータの操作は32ビット版と同じように書けます(一部なくなった命令があります)。qword ptr で64ビットのメモリデータを参照できます。インデックスレジスタには*8が使えますので64ビットデータの配列参照などに便利です。

ポインタを64bitにするのをお忘れなく。見落としがあるとあとあとやっかいです。

64bit固有の特徴として、64bitレジスタの下位32bitだけを変更する命令を書くと、上位32ビットが自動的・強制的に0になる、という点が挙げられます。movや演算命令などで、たとえばeaxをデスティネーションに指定すると、raxの上位32ビットがゼロになります。ゼロ拡張の命令をわざわざ書く必要がないので便利ですが、上位をそのままにして下位だけを変えたいときには注意が必要です。自動的にゼロ拡張されるのは、下位32ビットに値をセットしたときに上位32ビットが0になる、というパターンだけです。下位8ビットに値を入れると上位56ビットがゼロになる、というわけではないので、もしそうしたい場合はmovzxなどが必要なのは32ビットと変わりません。

なーんだカンタンじゃん!?

と思ったら大間違いでした。【すごく要注意】なのが次に控えてます。ここで読むのをやめて実装を始めたりしないでくださいね。