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

 

x64アセンブラ関数の書き方の注意【すごく要注意】

スタックが自由に使えない

32ビットまでのインラインアセンブラでは好き勝手にpush/popしたりして使えていたスタックがx64では厳しい使用制限を受けることになりました。

具体的には以下のような制限です。

  • スタックポインタが動くような操作をしていいのは関数の最初と最後の部分だけ(フレームポインタ(後述)を設定しない場合)。その部分は「prolog」「epilog」と呼ばれ、やっていいことが決まっている。
  • prologが終わった時点でスタックポインタは16の倍数になっていなければならない(中から他の関数を呼ばない場合はこの制限はない)。

ただし、push/popやその他の方法でRSPを動かさない、何も呼び出さない、壊してはいけないレジスタをセーブ(push/popに限らずいかなる方法でも)しない、例外処理をしない、のすべての条件を満たす関数は「leaf」(関数呼び出しツリーの枝の末端の葉っぱ、くらいの意味でしょうか)と呼ばれ、この制限を受けません。前ページのコーディング例にprolog/epilogがないのはそのためです。

prologとepilog

インラインアセンブラの移植で必要になりそうな、「壊してはいけないレジスタ」のpushと、ローカル変数的なものを入れるのに使うスタックスペースの確保に的を絞って書きます。

関数の入り口ではスタックポインタは必ず16の倍数+8になっています(呼び出し元が16の倍数に調整したところへ戻り番地が積まれたため)。他の関数を呼ぶ場合やXMMレジスタをセーブする場合の境界調整には16の倍数+8バイトのデータを上乗せすれば16バイト境界になります。

prologが終わったあとはpush/popはできなくなりますので、中でpush/popを使っているプログラムの移植の場合は、prologの中でレジスタ退避用のワークエリアを確保しておくといいでしょう。でも使えるレジスタが増えてるからあんまり必要ないかもしれません。

面倒なのは、prologで何をしているのかを記述したunwind codeと呼ばれるデータを実行命令とは別に作って専用のセグメントに置いておかなければならないことです。ただし、prolog専用のマクロ命令でソースを書けば、アセンブラが実行命令を作るついでにunwind codeも作って専用セグメントに置いてくれますので、マクロを使って書くのがいいと思います。

実際の書き方を例をあげて説明します。説明は積み上げ式になってるのでケース1から順番に見てください。

ケース1:レジスタ1個を退避するだけのprologとepilog

; マクロ定義の取り込み
include ksamd64.inc     

_TEXT segment

        align  16
sample_1 proc frame      ; prologを使う関数ではproc frameと書く
        ; prolog
        rex_push_reg rsi    ; マクロ命令でrsiをスタックにpush
        .endprolog          ; prologの終わり
        
        ; 関数の処理
        
        ; epilog
        pop     rsi
        ret
sample_1 endp        
        

ポイント:

  • prologを使う場合は、関数をproc frameで始めます。
  • その直後からprologを書きます。prolog内の命令は普通のpush命令ではなく、マクロを使います。
  • マクロでpushできるのは、壊してはいけないレジスタだけ(前ページ参照)です。壊してもいいレジスタをついでにpushしてはいけません。
  • prologが終わったら.endprologと書きます。
  • epilogは普通の命令でOKですが、epilogが始まったらprologの後始末だけを決められた方法で行って、すぐにretします。それ以外の処理を書いてはいけません。

ケース2:レジスタ2個を退避して16バイトの作業エリアを確保するprologとepilog

; マクロ定義の取り込み
include ksamd64.inc     

_TEXT segment

        align  16
sample_2 proc frame     ; prologを使う関数ではproc frameと書く
        ; prolog
        rex_push_reg rsi    ; 最初はrex_push_reg
        push_reg     rdi    ; 2番目以降はrexなしのpush_reg
        alloc_stack(16)      ; sub rsp,16
        .endprolog          ; prologの終わり
        
        ; 関数の処理
        
        ; epilog
        add     rsp, 16
        pop     rdi
        pop     rsi
        ret
sample_2 endp        
        

ポイント:

  • 「rex_push_reg」はprologの最初の命令がpushである場合に、最初のpushにだけ使います。
  • 二番目以降のpushはrex_のない「push_reg」を使います。マクロを使い分けるのは、prologの最初の命令が長さ1バイトの機械語命令になるのを避けるためです(なんか都合があるらしいです)。
  • prologでスタックポインタから定数を引くにはalloc_stackマクロを使います。epilogではadd rsp,nを書きます。
  • alloc_stackとpushがある場合pushを先に書きます。
  • 4096バイト以上のエリアをスタックに確保する場合はただrspから引いただけではだめですのでご注意ください。もしその必要がある場合は下記のケース5をご覧ください。

ケース3:欠番

ケース4:XMMレジスタをセーブするprologとepilog

XMMレジスタはpush/popできないので先にalloc_stackでスタックにエリアを確保してからそこにセーブします。汎用レジスタもpushの代わりにこの方法でセーブすることもできます。

; マクロ定義の取り込み
include ksamd64.inc     

_TEXT segment

; スタックに積むデータの形の構造体を作っておくとアドレス指定しやすい
S4_STKFRM   struct
xmm6_save   xmmword ?
xmm7_save   xmmword ?
rsi_save    dq      ?
rdi_save    dq      ?
            dq      ? ; 埋め
S4_STKFRM   ends
; サイズが16の倍数+8でないときアセンブルエラーになるようにしておく
.erre (size S4_STKFRM mod 16) eq 8, <S4_STKFRM size must be 16n+8>


        align   16
sample_4 proc frame
        ; prolog
        alloc_stack(size S4_STKFRM)
        save_xmm128 xmm6, S4_STKFRM.xmm6_save    ; セーブエリアのアドレスをRSPの相対番地で指定
        save_xmm128 xmm7, S4_STKFRM.xmm7_save
        save_reg    rsi, S4_STKFRM.rsi_save
        save_reg    rdi, S4_STKFRM.rdi_save
        .endprolog

        ; 関数の処理

        ; epilog
        movaps  xmm6, [rsp + S4_STKFRM.xmm6_save]
        movaps  xmm7, [rsp + S4_STKFRM.xmm7_save]
        mov     rsi, [rsp + S4_STKFRM.rsi_save]
        mov     rdi, [rsp + S4_STKFRM.rdi_save]
        add     rsp, size S4_STKFRM
        ret
sample_4 endp
        

ポイント:

  • 「save_xmm128」、「save_reg」の第2オペランドはレジスタセーブエリアのアドレスをRSPからの相対アドレスで指定します。
  • XMMレジスタのセーブエリアのアドレスは16バイト境界になるようにします。

ケース5:4096バイト以上のエリアをスタックに確保するprologとepilog

4096以上の値をrspから引く場合は、その前に__chkstkというサブルーチンを呼ぶ必要があります。このサブルーチン自体はC/C++形式の関数ではないので呼び出し規約が違います。

  • rspから引く値をraxに入れて呼びます。
  • __chkstkを呼ぶ前にスタックポインタを16バイト境界とかにする必要はありません。
  • r10とr11とフラグだけを壊します(とMSDNには書いてありますがr10とr11は中でセーブリストアしているようです)。
  • raxに入れた値はそのまま帰ってくるのでそのあとの引き算で使えます。
; マクロ定義の取り込み
include ksamd64.inc     

_TEXT segment

; スタックに積むデータの形の構造体を作っておくとアドレス指定しやすい
S5_STKFRM   struct
buf1        db      2048 dup (?) ; ローカルで使うなにかのバッファ 2KB
buf2        db      2048 dup (?) ; ローカルで使うなにかのバッファ 2KB
rsi_save    dq      ?
rdi_save    dq      ?
            dq      ? ; 埋め
S5_STKFRM   ends

        extern  __chkstk:proc            ; 外部のサブルーチン名を宣言

        align   16
sample_5 proc frame
        ; prolog
        mov     eax, size S5_STKFRM      ; rspから引くサイズをeaxに入れてゼロ拡張。mov raxより省スペース
        call    __chkstk
        sub     rsp, rax                 ; ※
        .allocstack size S5_STKFRM       ; ※

        save_reg    rsi, S5_STKFRM.rsi_save
        save_reg    rdi, S5_STKFRM.rdi_save
        .endprolog

        ; 関数の処理

        ; epilog
        mov     rsi, [rsp + S5_STKFRM.rsi_save]
        mov     rdi, [rsp + S5_STKFRM.rdi_save]
        add     rsp, size S5_STKFRM
        ret
sample_5 endp
        

※alloc_stackマクロは定数しか引けないので、sub命令でrspからraxの値を引きます。引いていることを記述したunwind codeを作成するために、.allocstackディレクティブを直後に書きます(alloc_stackマクロとは違い、.allocstackディレクティブは実際の引き算はしません。引いたことを記述するunwind codeを作るだけです。alloc_stackマクロは実はsub命令と.allocstackディレクティブの組み合わせです)。

※の2行の代わりに「alloc_stack(size S5_STKFRM)」でもかまわないと思いますが上のほうが効率的です。

ケース6:中から他の関数を呼ぶ関数のprologとepilog

関数を呼ぶ時点でスタックの一番上にパラメータ用のエリアが置かれている必要がありますが、呼ぶ時になってからスタックにパラメータエリアを確保することはできませんので、prologの時点でパラメータ用のエリアを確保しておく必要があります。

homeのエリアは16バイト境界に置く必要がありますので、スタックに上乗せするサイズは16の倍数+8バイトにする必要があります。太線が16バイト境界です。

このエリアは中から呼ぶすべての関数で共通に利用することになりますので、中から呼んでいる関数のうち最大の引数の数の分のエリアが必要です。引数4個以下の関数しか呼ばない(パラメータはすべてレジスタ渡しになる)場合であってもhomeのエリアは最低4個分(32バイト)必要です。5個以上の引数を取る関数を呼ぶ場合はその数だけ必要です。

ケース7 動的にスタックにエリアを確保する関数

フレームポインタというメカニズムを使えばprologより後にRSPを動かして実行時に決まるサイズのメモリをスタックに確保できるようになります。

壊してはいけないレジスタのうちひとつをフレームポインタレジスタに決めて、alloc_stack(n)のあとにRSP+適当な定数のアドレスを設定します。unwindに使うレジスタがRSPからフレームポインタレジスタに移ったのを教えるためには当然unwind codeが必要です。アドレス設定はset_frameマクロで記述するのがよいでしょう。もちろんLEA命令+.setframeディレクティブでもかまいません。これにより関数本体ではRSPは動かせるようになりますが代わりにフレームポインタレジスタは動かせなくなります。epilogでは「lea rsp, (n-適当な定数)[フレームポインタレジスタ]」でRSPを復元します。

詳しくはMSDNの「x64 Software Conventions」と「ksamd64.inc」の項目をご覧ください。

おわりに

ここに書いてある以上のことはMSDNの「x64 Software Conventions」に詳しく書いてありますので検索してご覧ください。

x64命令セットの詳細については「Intel 64 instruction set」でぐぐると出てくるIntelのマニュアル(PDFファイル・英文)がよろしいかと思います。AMDとの違いはWikipediaの「x64」の項目が参考になると思います。

 

お詫び:他の関数を呼ばない関数はleafでなくてもスタックの16バイト境界調整は必要ありません。旧版を見て余計なalloc_stackを入れてしまった方すみませんでした。