x64 呼び出し規約をわかりやすく説明してみる (Windows)

マイクロソフトのサイト [1] に Windows の x64 呼び出し規約 (calling convention) の公式説明がありますが、網羅性と正確さを重視したためか、機械翻訳が使われているためか、少々わかりにくいところがあります。そこで、ポイントを絞ってできるだけわかりやすく説明してみたいと思います。

動作確認環境

  • Windows 11 Home 23H2
  • Visual Studio Community 2022 (Visual C++)
  • Rust 1.81.0

ルール 1. 関数呼び出し時、スタックを 16 バイト境界に整列する

関数呼び出し時、スタックを 16 バイト境界に整列 (alignment)しておく必要があります。言い換えると、アセンブリ言語の call 命令を実行する直前、スタックポインタ(RSP レジスタ)の値を 16(= 0x10)の倍数にしておく必要があります。

たとえば、スタックポインタの値が 16 進数で xxxxxxxx`xxxxxxx8 のときや

xxxxxxxx`xxxxxxx4 のときに関数を呼び出してはいけません。

xxxxxxxx`xxxxxxx0 のように末尾 1 桁が 0 であれば、関数を呼び出せます。

これは x64 CPU の制限ではなく、呼び出し規約としてのルールです。

ルール 2. 関数呼び出し時、スタックの最上位に 32 バイトのシャドウストアを確保する

関数呼び出し時、スタックの最上位に 32 バイト(0x20 バイト)の空き領域を確保しておく必要があります。この空き領域のことを、シャドウストア、シャドウスペース、シャドウ領域、ホームスペースなどと呼びます。中の値は何でも構いません。

ルール 1 の「16 バイト境界に整列」も考慮すると、関数呼び出し時のスタック構造は次のようになります。

以下はルール 1 の「16 バイト境界に整列」を満たしていないので NG です。

シャドウストアは、呼び出す関数の引数の個数に関係なく、無条件に確保します。
確保するのは呼び出し元ですが、利用するのは呼び出し先です。呼び出し先の関数が、デバッグ用に第 1 ~ 第 4 引数の値をセットしたり、一時的な作業領域に使ったりします。

ルール 3. 第 4 引数まではレジスタで渡す

関数呼び出し時、最初の 4 個までの引数はレジスタで渡します。
使用するレジスタは、左から順(前から順)に RCX, RDX, R8, R9 です。
(小数を扱う場合は XMM0, XMM1, XMM2, XMM3 レジスタを使うことがありますが、簡単のため省略します。)

たとえば、引数が 2 個の関数 funcA(101, 102) を呼び出す場合、RCX レジスタ内に第 1 引数の 101 を RDX レジスタ内に第 2 引数の 102 をセットするとともに、スタックにシャドウストアを確保して関数を呼び出します。このとき、R8 レジスタと R9 レジスタは使われません。

引数が 4 個の関数 funcB(201, 202, 203, 204) を呼び出す場合は、RCX, RDX, R8, R9 レジスタに各引数の値をセットするとともに、スタックにシャドウストアを確保して関数を呼び出します。

ルール 4. 第 5 引数以降はスタックで渡す

関数呼び出し時、最初の 4 個までの引数はレジスタで渡しますが、5 個目以降の引数は 8 バイト単位でスタックに積んで渡します。積む順序は右から左(後ろから前)です。
さらにシャドウストアも積む必要があるため、結局、関数呼び出し時のスタックの構成は次のようになります。

引数が 7 個の関数 funcC(301, 302, 303, 304, 305, 306, 307) を呼び出す際のレジスタとスタックの状態を以下に示します。

ルール 5. 戻り値は RAX レジスタで受け渡す

呼び出し先から呼び出し元への戻り値は、RAX レジスタで受け渡します。
(XMM0 レジスタを使うこともありますが、簡単のため省略します。)

たとえば 2 個の整数を加算する関数 add(401, 402) を呼び出した場合、計算結果 803 が RAX レジスタ内にセットされて返ってきます。

戻り値がない関数を呼び出した場合の RAX レジスタの値は不定です。

ルール 6. RAX、RCX、RDX、R8-R11、XMM0-XMM5 は揮発性

RAX、RCX、RDX、R8-R11、XMM0-XMM5 レジスタは揮発性 (volatile) です。つまり、関数呼び出しから戻ってくると、これらのレジスタの値は変化している可能性があります。

一方、RBX、RDI、RSI、RBP、RSP、R12-R15、XMM6-XMM15 レジスタは不揮発性 (non-volatile) です。つまり、関数呼び出し前後で、これらのレジスタの値は変わりません。

関数を実装する側からすると、

  • 揮発性レジスタの値は、関数内で自由に書き換えてよい。
  • 不揮発性レジスタの値は、保持しなければならない。書き換える場合は、事前にその値をスタックなどに退避し、利用後は元の値に戻さなければならない。

というルールが課されることになります。

ルール 7. 呼び出された関数は引数のクリーンアップをしない

従来の x86 __stdcall 呼び出し規約では、スタックに積まれた引数の後始末を、呼び出された側の関数が行っていました。具体的には、「ret imm16」命令 (imm16 > 0) を使って、関数からのリターンとスタックポインタの加算を同時に行っていました。

x64 呼び出し規約では、呼び出された側の関数は引数のクリーンアップを行いません。単に「ret」命令(=「ret 0」命令)で関数からリターンします。

いま、スタックが次の状態にあったとします。

関数を呼び出すと、具体的には call 命令を実行すると、スタックに 8 バイト(64 ビット)のリターンアドレスが積まれ、目的の関数にジャンプします。

呼び出し先の関数でさまざまな処理が行われ、最後にリターンすると、スタックポインタは関数呼び出し前の状態に戻ります。引数もスタック内に残ったままです。

ルール 6 にある「RSP が不揮発性」から導かれる話ではあります。

関数呼び出し時のスタックの動き

ルールそのものについての説明は以上です。
それでは、関数呼び出し時のスタックの動きを追ってみましょう。

いま、スタックが次の状態にあったとします。

call 命令を実行して関数を呼び出すと、スタックに 8 バイト(64 ビット)のリターンアドレスが積まれ、目的の関数にジャンプします。

呼び出し先の関数は、不揮発性レジスタの値をスタックに積んで退避させたり、ローカル変数や作業用のエリアをスタック上に確保したりします。サイズは関数次第ですが、レジスタの退避はなし、ローカル変数や作業用に 24 バイト(0x18 バイト)を確保したとすると、スタックの状態は次のようになります。

この関数が、引数が 7 個の関数 funcD(401, 402, 403, 404, 405, 406, 407) を呼び出すとします。
最初の 4 個の引数はレジスタで渡せますが(ルール 3)、5 個目以降の引数はスタックに積まなければなりません(ルール 4)。さらに、32 バイトのシャドウストアも積む必要があります(ルール 2)。単純に積んでいくと、スタックは次の状態になります。

しかしこれでは関数を呼び出せません。ルール 1 の「16 バイト境界に整列」に違反するためです。

ルールを満たすには、最初に 8 バイトのパディング用データを積んでから、3 個の引数とシャドウストアを積む必要があります。

これで関数を呼び出せます。call 命令を実行すると、スタックに 8 バイトのリターンアドレスが積まれ、目的の関数にジャンプします。

と、ここまで書きましたが、実はこの節で書いた動作は、Visual C++ が生成するオブジェクトの動作とは異なります

関数呼び出し時のスタックの動き(実際)

Visual C++ は、より効率的なオブジェクトを生成します。
ポイントは、「必要に応じてスタックに値を積む(push する)」のではなく、「あらかじめ十分なサイズのスタックを確保しておき、ピンポイントでスタック内に値を書き込む」点にあります。動作を見てみましょう。

いま、スタックが次の状態にあったとします。

call 命令を実行して関数を呼び出すと、スタックに 8 バイトのリターンアドレスが積まれ、目的の関数にジャンプします。

呼び出し先の関数は、必要に応じてレジスタの値をスタックに積んで退避させた後、この関数が必要とする十分なサイズのスタックを一気に確保します。
十分なサイズとは、

  • ローカル変数や作業用の領域
  • 引数の置き場所(さらに関数呼び出す場合)
  • シャドウストア(さらに関数を呼び出す場合)
  • 8 バイトのパディング(必要に応じて)

を合計したサイズ(以上)になります。

上記「引数の置き場所」について補足説明します。
引数の置き場所のサイズは、「この関数が呼び出す子関数のうち引数の個数が最も多いものに対応できるだけのサイズ」です。たとえば、引数が「2 個」「7 個」「6 個」の子関数を呼び出す場合、引数の個数が最も多いものは「7 個」で、そのうち最初の 4 個はレジスタ渡しになるため(ルール 3)、残りの 3 個分の置き場所を確保すれば足りることになります。具体的には、8 バイト × 3 個 = 24 バイトです。

話を戻して、呼び出し先の関数が、レジスタの退避は必要とせず、ローカル変数や作業用に 24 バイト、引数の置き場所に 24 バイト、シャドウストアに 32 バイト、パディングに 8 バイトを必要としたとすると、スタックの状態は一気に次のようになります。

この関数が、引数が 7 個の関数 funcE(501, 502, 503, 504, 505, 506, 507) を呼び出す場合、最初の 4 個の引数の値をレジスタにセットするとともに、第 5 ~ 第 7 引数の値を確保済みスタック内の所定の箇所に直接書き込んで(push しません)、

call 命令を実行します。

呼び出し先の関数からリターンしてきて、今度は引数が 6 個の関数 funcF(601, 602, 603, 604, 605, 606) を呼び出す場合、最初の 4 個の引数の値をレジスタにセットするとともに、第 5, 第 6 引数の値を確保済みスタック内の所定の箇所に直接書き込んで(push しません)、

call 命令を実行します。

関数呼び出しの都度、引数をスタックに積んだり、シャドウストアを積んだり、16 バイト境界の整列を考慮したりする必要がないので効率的です。

Visual C++ でのコンパイル結果

最後に、Visual C++ の出力を見てみましょう。

次のソースコードを「c:\tmp\func.c」として用意します。ローカル変数を 3 個持ち、引数が 7 個の関数と引数が 6 個の関数を呼び出す関数です。

extern __int64 funcE(__int64, __int64, __int64, __int64, __int64, __int64, __int64);
extern __int64 funcF(__int64, __int64, __int64, __int64, __int64, __int64);

__int64 func()
{
    __int64 retE;
    __int64 retF;
    __int64 ret;

    retE = funcE(501, 502, 503, 504, 505, 506, 507);
    retF = funcF(601, 602, 603, 604, 605, 606);
    ret = retE + retF;

    return ret;
}

x64 版の Visual C++ でビルドします。「/c」はコンパイルのみでリンクをしない、「/Od」は最適化抑止、「/FAsc」はソースコード付き・マシンコード付きでアセンブリリストを出力する、の意味です。

C:\tmp> cl /c /Od /FAsc func.c
Microsoft(R) C/C++ Optimizing Compiler Version 19.39.33523 for x64
Copyright (C) Microsoft Corporation.  All rights reserved.

生成されたアセンブリリスト「func.cod」を編集した結果を以下に示します。

; {
sub     rsp, 104                    ; スタックポインタ減算

; retE = funcE(501, 502, 503, 504, 505, 506, 507);
mov     QWORD PTR [rsp+48], 507     ; 第 7 引数
mov     QWORD PTR [rsp+40], 506     ; 第 6 引数
mov     QWORD PTR [rsp+32], 505     ; 第 5 引数
mov     r9d, 504                    ; 第 4 引数
mov     r8d, 503                    ; 第 3 引数
mov     edx, 502                    ; 第 2 引数
mov     ecx, 501                    ; 第 1 引数
call    funcE                       ; funcE 関数呼び出し
mov     QWORD PTR [rsp+72], rax     ; ローカル変数 retE

; retF = funcF(601, 602, 603, 604, 605, 606);
mov     QWORD PTR [rsp+40], 606     ; 第 6 引数
mov     QWORD PTR [rsp+32], 605     ; 第 5 引数
mov     r9d, 604                    ; 第 4 引数
mov     r8d, 603                    ; 第 3 引数
mov     edx, 602                    ; 第 2 引数
mov     ecx, 601                    ; 第 1 引数
call    funcF                       ; funcF 関数呼び出し
mov     QWORD PTR [rsp+64], rax     ; ローカル変数 retF

; ret = retE + retF;
mov     rax, QWORD PTR [rsp+64]     ; ローカル変数 retF
mov     rcx, QWORD PTR [rsp+72]     ; ローカル変数 retE
add     rcx, rax
mov     rax, rcx
mov     QWORD PTR [rsp+80], rax     ; ローカル変数 ret

; return ret;
mov     rax, QWORD PTR [rsp+80]     ; 本関数の戻り値

; }
add     rsp, 104                    ; スタックポインタ加算
ret

スタックの構造を示します。

前節の説明とほぼ一致していますが、関数先頭のスタックポインタ減算は 88 バイトで足りると思われるのに、104 バイトを引いて少し余裕をもたせている(8 バイト × 2 の未使用領域がある)理由はわかりませんでした。

ちなみに Rust で同等の関数を含むプログラムを書き、

……

fn func() -> i64
{
    let mut ret_e: i64 = 1; // オブジェクト操作のため意図的に mut にしている
    let mut ret_f: i64 = 2;
    let mut ret:   i64 = 3;

    ret_e = func_e(501, 502, 503, 504, 505, 506, 507);
    ret_f = func_f(601, 602, 603, 604, 605, 606);
    ret = ret_e + ret_f;

    return ret;
}

……

コンパイルして(rustc –codegen opt-level=0 –codegen llvm-args=-x86-asm-syntax=intel –emit asm main.rs)アセンブリリストを見たところ、想定通り 88 バイトが減算されていました。

; {
sub     rsp, 88                     ; スタックポインタ減算

; let mut ret_e: i64 = 1;
mov     QWORD PTR [rsp+64], 1       ; ローカル変数 ret_e

; let mut ret_f: i64 = 2;
mov     QWORD PTR [rsp+72], 2       ; ローカル変数 ret_f

; let mut ret:   i64 = 3;
mov     QWORD PTR [rsp+80], 3       ; ローカル変数 ret

; ret_e = func_e(501, 502, 503, 504, 505, 506, 507);
mov     ecx, 501                    ; 第 1 引数
mov     edx, 502                    ; 第 2 引数
mov     r8d, 503                    ; 第 3 引数
mov     r9d, 504                    ; 第 4 引数
mov     QWORD PTR [rsp+32], 505     ; 第 5 引数
mov     QWORD PTR [rsp+40], 506     ; 第 6 引数
mov     QWORD PTR [rsp+48], 507     ; 第 7 引数
call    func_e                      ; func_e 関数呼び出し
mov     QWORD PTR [rsp+64], rax     ; ローカル変数 ret_e

; ret_f = func_f(601, 602, 603, 604, 605, 606);
mov     ecx, 601                    ; 第 1 引数
mov     edx, 602                    ; 第 2 引数
mov     r8d, 603                    ; 第 3 引数
mov     r9d, 604                    ; 第 4 引数
mov     QWORD PTR [rsp+32], 605     ; 第 5 引数
mov     QWORD PTR [rsp+40], 606     ; 第 6 引数
call    func_f                      ; func_f 関数呼び出し
mov     QWORD PTR [rsp+72], rax     ; ローカル変数 ret_f

; ret = ret_e + ret_f;
mov     rax, QWORD PTR [rsp+64]     ; ローカル変数 ret_e
add     rax, QWORD PTR [rsp+72]     ; ローカル変数 ret_f
mov     QWORD PTR [rsp+56], rax     ; 作業用
<中略>
mov     rax, QWORD PTR [rsp+56]
mov     QWORD PTR [rsp+80], rax     ; ローカル変数 ret

; return ret;
mov     rax, QWORD PTR [rsp+80]     ; 本関数の戻り値

; }
add     rsp, 88                     ; スタックポインタ加算
ret

Rust のスタック構造を示します。Visual C++ と比べると無駄がありません。

おわりに

Windows の x64 呼び出し規約について、ざっくり説明しました。
64 ビット未満の値のレジスタへの格納方法、小数や構造体の受け渡し方、可変長引数の扱い方等、さらに詳しく知りたい場合は、参考文献 [1] などをご覧ください。

※ 追記: シャドウストアについて「x64 呼び出し規約のシャドウストアは何に使われるのか (Windows)」の記事を書きました。

参考文献

[1] マイクロソフト「x64 での呼び出し規則」
https://learn.microsoft.com/ja-jp/cpp/build/x64-calling-convention

[2] Matt Pietrek「Everything You Need To Know To Start Programming 64-Bit Windows Systems」
https://learn.microsoft.com/en-us/archive/msdn-magazine/2006/may/x64-starting-out-in-64-bit-windows-systems-with-visual-c