x64 呼び出し規約のシャドウストアは何に使われるのか (Windows)

x64 呼び出し規約には、呼び出し元が確保した 32 バイトのスタック(シャドウストア、シャドウスペース、シャドウ領域、ホームスペース)を呼び出し先が使うというちょっと変わったルールがあります。このシャドウストアは実際に何に使われるのか、いろいろなサンプルプログラムを作って調べてみます。
なお、x64 の呼び出し規約やシャドウストアについては、以前の記事 [1] をご覧ください。

動作確認環境

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

定数を返すだけの関数(最適化なし)

定数 1 を返すだけの関数です。

__int64 func()
{
    return 1;
}

Visual Studio の「x64 Native Tools Command Prompt for VS 2022」を開いてコマンドラインからコンパイルします。「/Od」は最適化なし、「/FAs」はアセンブリリストを生成、「/c」はコンパイルのみ(リンクしない)の意味です。

cl /Od /FAs /c test1.c

生成されたアセンブリリスト「test1.asm」を見てみます。(整形しています。以下同様。)

func PROC
    mov  eax, 1
    ret  0
func ENDP

単にレジスタに戻り値 1 をセットしてリターンしています。シャドウストアは使っていません。

スタックの用途を図示します。

シャドウストアは未使用で、無駄な領域になっています。

定数を返すだけの関数(最適化あり)

同じ関数を、「/O2」の速度最適化オプションを指定してコンパイルします。

cl /O2 /FAs /c test1.c

生成されたアセンブリリストです。

func PROC
    mov  eax, 1
    ret  0
func ENDP

もともと単純なプログラムでもあり、「最適化なし」のときと同じです。

スタックの用途を図示します。

シャドウストアは使われていません。

2 つの引数を合計する関数(最適化なし)

次は、2 つの整数を取り合計を返す関数を試してみます。

__int64 func(__int64 arg1, __int64 arg2)
{
    return arg1 + arg2;
}

最適化なしでコンパイルします。

cl /Od /FAs /c test2.c

生成されたアセンブリリストです。

func PROC
    mov     QWORD PTR [rsp+16], rdx  // 第 2 引数をシャドウストアに保存
    mov     QWORD PTR [rsp+8], rcx   // 第 1 引数をシャドウストアに保存
    mov     rax, QWORD PTR [rsp+16]  // 第 2 引数を参照
    mov     rcx, QWORD PTR [rsp+8]   // 第 1 引数を参照
    add     rcx, rax
    mov     rax, rcx
    ret     0
func ENDP

スタックの用途を図示します。

呼び出し先の関数が、レジスタ経由で受け取った第 1 引数と第 2 引数を、呼び出し元が確保したシャドウストアにいわば越境して書き込んでいます(緑色の鉛筆は、呼び出し先の関数による書き込み、の意味です)。足し算をするだけであれば不要な処理ですが、引数の値がメモリに残るのでデバッグが容易になるというメリットがあります。

2 つの引数を合計する関数(最適化あり)

同じ関数を、速度最適化オプションを指定してコンパイルします。

cl /O2 /FAs /c test2.c

生成されたアセンブリリストです。

func PROC
    lea     rax, QWORD PTR [rcx+rdx]
    ret     0
func ENDP

スタックの用途を図示します。

最適化により、引数のシャドウストアへの保存がなくなりました。レジスタで受け取った引数を足し算して即座にリターンしています。

6 個の引数を合計する関数(最適化なし)

引数を 6 個に増やしてみます。

__int64 func(__int64 arg1, __int64 arg2, __int64 arg3, __int64 arg4, __int64 arg5, __int64 arg6)
{
    return arg1 + arg2 + arg3 + arg4 + arg5 + arg6;
}

最適化なしでコンパイルします。

cl /Od /FAs /c test3.c

生成されたアセンブリリストです。

func PROC
    mov     QWORD PTR [rsp+32], r9   // 第 4 引数をシャドウストアに保存
    mov     QWORD PTR [rsp+24], r8   // 第 3 引数をシャドウストアに保存
    mov     QWORD PTR [rsp+16], rdx  // 第 2 引数をシャドウストアに保存
    mov     QWORD PTR [rsp+8], rcx   // 第 1 引数をシャドウストアに保存
    mov     rax, QWORD PTR [rsp+16]  // 第 2 引数を参照
    mov     rcx, QWORD PTR [rsp+8]   // 第 1 引数を参照
    add     rcx, rax
    mov     rax, rcx
    add     rax, QWORD PTR [rsp+24]  // 第 3 引数を参照
    add     rax, QWORD PTR [rsp+32]  // 第 4 引数を参照
    add     rax, QWORD PTR [rsp+40]  // 第 5 引数を参照
    add     rax, QWORD PTR [rsp+48]  // 第 6 引数を参照
    ret     0
func ENDP

スタックの用途を図示します。

レジスタで渡された第 1, 2, 3, 4 引数が、シャドウストアに保存されています。その下にスタック渡しの第 5, 6 引数があり、6 つの引数がきれいに並んでいます。

6 個の引数を合計する関数(最適化あり)

同じ関数を、速度最適化オプションを指定してコンパイルします。

cl /O2 /FAs /c test3.c

生成されたアセンブリリストです。

func PROC
    lea     rax, QWORD PTR [rcx+rdx]
    add     rax, r8
    add     rax, r9
    add     rax, QWORD PTR [rsp+40]  // 第 5 引数を参照
    add     rax, QWORD PTR [rsp+48]  // 第 6 引数を参照
    ret     0
func ENDP

スタックの用途を図示します。

最適化により、シャドウストアへの引数の保存が行われなくなりました。

変数の定義や関数の呼び出しがある関数(最適化なし)

関数の中で、ローカル変数を定義したり関数を呼び出したりしてみましょう。以下、サンプルプログラムです。

__int64 func1(__int64);
__int64 func2(__int64);

__int64 func(__int64 arg1, __int64 arg2)
{
    __int64 i1 = func1(arg1);
    __int64 i2 = func2(arg2);

    return i1 + i2;
}

最適化なしでコンパイルします。

cl /Od /FAs /c test4.c

生成されたアセンブリリストです。

func PROC
    mov     QWORD PTR [rsp+16], rdx  // 第 2 引数をシャドウストアに保存
    mov     QWORD PTR [rsp+8], rcx   // 第 1 引数をシャドウストアに保存
    sub     rsp, 56
    mov     rcx, QWORD PTR [rsp+64]  // 第 1 引数を参照
    call    func1
    mov     QWORD PTR [rsp+40], rax  // 変数 i1 に代入
    mov     rcx, QWORD PTR [rsp+72]  // 第 2 引数を参照
    call    func2
    mov     QWORD PTR [rsp+32], rax  // 変数 i2 に代入
    mov     rax, QWORD PTR [rsp+32]  // 変数 i2 を参照
    mov     rcx, QWORD PTR [rsp+40]  // 変数 i1 を参照
    add     rcx, rax
    mov     rax, rcx
    add     rsp, 56
    ret     0
func ENDP

スタックの用途を図示します。

レジスタで渡された第 1, 2 引数が、シャドウストアに保存されています。
ローカル変数 i1, i2 は、自身のスタックフレームに割り当てられています(緑色のセルは、呼び出し先の関数が確保したスタック、の意味です)。

変数の定義や関数の呼び出しがある関数(最適化あり)

同じ関数を、速度最適化オプションを指定してコンパイルします。

cl /O2 /FAs /c test4.c

生成されたアセンブリリストです。

func PROC
    mov     QWORD PTR [rsp+8], rbx  // rbx レジスタをシャドウストアに保存
    push    rdi
    sub     rsp, 32
    mov     rdi, rdx
    call    func1
    mov     rcx, rdi
    mov     rbx, rax
    call    func2
    add     rax, rbx
    mov     rbx, QWORD PTR [rsp+48] // rbx レジスタを参照
    add     rsp, 32
    pop     rdi
    ret     0
func ENDP

スタックの用途を図示します。

最適化により、シャドウストアへの引数の保存が行われなくなりました。その代わり、シャドウストアが RBX レジスタの退避に利用されています。
RDI レジスタもシャドウストアに退避すればよさそうなものですが、こちらはなぜか自身のスタックフレームに push されています。

volatile 変数を使う関数(最適化なし)

前記の関数を少し書き換え、ローカル変数の定義に volatile キーワードを付加してみます。

__int64 func1(__int64);
__int64 func2(__int64);

__int64 func(__int64 arg1, __int64 arg2)
{
    volatile __int64 i1 = func1(arg1);
    volatile __int64 i2 = func2(arg2);

    return i1 + i2;
}

最適化なしでコンパイルします。

cl /Od /FAs /c test5.c

生成されたアセンブリリストです。

func PROC
    mov     QWORD PTR [rsp+16], rdx  // 第 2 引数をシャドウストアに保存
    mov     QWORD PTR [rsp+8], rcx   // 第 1 引数をシャドウストアに保存
    sub     rsp, 56
    mov     rcx, QWORD PTR [rsp+64]  // 第 1 引数を参照
    call    func1
    mov     QWORD PTR [rsp+32], rax  // 変数 i1 に代入
    mov     rcx, QWORD PTR [rsp+72]  // 第 2 引数を参照
    call    func2
    mov     QWORD PTR [rsp+40], rax  // 変数 i2 に代入
    mov     rax, QWORD PTR [rsp+32]  // 変数 i1 を参照
    mov     rcx, QWORD PTR [rsp+40]  // 変数 i2 を参照
    add     rax, rcx
    add     rsp, 56
    ret     0
func ENDP

スタックの用途を図示します。

レジスタで渡された第 1, 2 引数が、シャドウストアに保存されています。
ローカル変数 i1, i2 は、自身のスタックフレームに割り当てられています。
volatile なし・最適化なしの場合と基本的に同じです。

volatile 変数を使う関数(最適化あり)

同じ関数を、速度最適化オプションを指定してコンパイルします。

cl /O2 /FAs /c test5.c

生成されたアセンブリリストです。

func PROC
    push    rbx
    sub     rsp, 32
    mov     rbx, rdx
    call    func1
    mov     rcx, rbx
    mov     QWORD PTR [rsp+72], rax  // 変数 i1 をシャドウストアに保存
    call    func2
    mov     QWORD PTR [rsp+64], rax  // 変数 i2 をシャドウストアに保存
    mov     rax, QWORD PTR [rsp+64]  // 変数 i2 を参照
    mov     rcx, QWORD PTR [rsp+72]  // 変数 i1 を参照
    add     rax, rcx
    add     rsp, 32
    pop     rbx
    ret     0
func ENDP

スタックの用途を図示します。

最適化により、シャドウストアへの引数の保存が行われなくなりました。その代わり、メモリの節約のためでしょう、ローカル変数の置き場所が自身のスタックフレームからシャドウストアに移動しています。
x86 呼び出し規約に慣れていると、呼び出し元が確保したスタックに呼び出し先のローカル変数が配置される動作を奇妙に感じます。

可変長引数を取る関数(最適化なし)

最後に、可変長引数を取る関数を書いてみます。

#include <stdio.h>
#include <stdarg.h>

__int64 func(__int64 arg1, ...)
{
    // 【使用例】
    // __int64 i = func(1LL, 2LL, 3LL, 100LL, 200LL, 300LL, -1LL); 
    // ・ 負数(終端子)までの値を足し算する。
    // ・ 即値には LL を付加のこと。
    
    __int64 sum = 0;
    __int64 arg = arg1;

    va_list argptr;
    va_start(argptr, arg1);
    while (0 <= arg)
    {
        sum += arg;
        arg = va_arg(argptr, __int64);
    }
    va_end(argptr);

    return sum;
}

最適化なしでコンパイルします。

cl /Od /FAs /c test6.c

生成されたアセンブリリストです。

func PROC
    mov     QWORD PTR [rsp+8], rcx   // 第 1 引数をシャドウストアに保存
    mov     QWORD PTR [rsp+16], rdx  // 第 2 引数をシャドウストアに保存
    mov     QWORD PTR [rsp+24], r8   // 第 3 引数をシャドウストアに保存
    mov     QWORD PTR [rsp+32], r9   // 第 4 引数をシャドウストアに保存
    sub     rsp, 40
    mov     QWORD PTR [rsp+16], 0    // 変数 sum に代入
    mov     rax, QWORD PTR [rsp+48]  // 第 1 引数を参照
    mov     QWORD PTR [rsp+8], rax   // 変数 arg に代入
    lea     rax, QWORD PTR [rsp+56]  // 第 2 引数のアドレスを計算
    mov     QWORD PTR [rsp], rax     // 作業領域にセット
$LN2@func:
    cmp     QWORD PTR [rsp+8], 0     // 変数 arg と 0 の比較
    jl      SHORT $LN3@func
    mov     rax, QWORD PTR [rsp+8]   // 変数 arg を参照
    mov     rcx, QWORD PTR [rsp+16]  // 変数 sum を参照
    add     rcx, rax
    mov     rax, rcx
    mov     QWORD PTR [rsp+16], rax  // 変数 sum に代入
    mov     rax, QWORD PTR [rsp]     // 作業領域を参照
    add     rax, 8
    mov     QWORD PTR [rsp], rax     // 作業領域にセット
    mov     rax, QWORD PTR [rsp]     // 作業領域を参照
    mov     rax, QWORD PTR [rax-8]   // 次の引数を参照
    mov     QWORD PTR [rsp+8], rax   // 変数 arg に代入
    jmp     SHORT $LN2@func
$LN3@func:
    mov     QWORD PTR [rsp], 0       // 作業領域にセット
    mov     rax, QWORD PTR [rsp+16]  // 変数 sum に代入
    add     rsp, 40
    ret     0
func ENDP

スタックの用途を図示します。

レジスタで渡された第 1, 2, 3, 4 引数が、シャドウストアに保存されています。
ローカル変数 arg, sum は、自身のスタックフレームに割り当てられています。

可変長引数を取る関数(最適化あり)

同じ関数を、速度最適化オプションを指定してコンパイルします。

cl /O2 /FAs /c test6.c

生成されたアセンブリリストです。

func PROC
    mov     QWORD PTR [rsp+8], rcx   // 第 1 引数をシャドウストアに保存
    xor     eax, eax
    mov     QWORD PTR [rsp+16], rdx  // 第 2 引数をシャドウストアに保存
    lea     rdx, QWORD PTR [rsp+16]  // 第 2 引数のアドレスを計算
    mov     QWORD PTR [rsp+24], r8   // 第 3 引数をシャドウストアに保存
    mov     QWORD PTR [rsp+32], r9   // 第 4 引数をシャドウストアに保存
    test    rcx, rcx
    js      SHORT $LN9@func
    add     rdx, -8
    npad    12
$LL2@func:
    add     rax, rcx
    lea     rdx, QWORD PTR [rdx+8]   // 次の引数のアドレスを計算
    mov     rcx, QWORD PTR [rdx]     // 次の引数を参照
    test    rcx, rcx
    jns     SHORT $LL2@func
$LN9@func:
    ret     0
func ENDP

スタックの用途を図示します。

最適化なしの場合と同様、レジスタで渡された第 1, 2, 3, 4 引数が、シャドウストアに保存されています。その後ろにスタック渡しの第 5 引数以降が続くので、すべての引数が等間隔に並ぶことになります。これにより、可変長引数の処理が容易になります。

おわりに

まとめます。

  • 「最適化なし」の場合は、レジスタで渡された第 1 ~ 第 4 引数(もしあれば)がシャドウストアに保存される。これにより、デバッグ・故障解析が容易になる。
  • 「最適化あり」の場合は、レジストリの退避やローカル変数の置き場所にシャドウストアが利用される。これにより、メモリが節約される。
  • ただし、最適化の有無に関係なく、可変長引数を取る関数では、レジスタで渡された第 1 ~ 第 4 引数がシャドウストアに保存される。これにより、可変長引数の処理が容易になる。

作成した関数やコンパイラの種類・バージョンによってはまた違う動きをする場合もあるでしょうが、今回実験した範囲では以上の結果となりました。

参考文献

[1] やや低レイヤー研究所「x64 呼び出し規約をわかりやすく説明してみる (Windows)」
https://yaya.lsv.jp/x64-calling-convention/

[2] Raymond Chen 「Why does the x64 calling convention reserve four home spaces for parameters even for functions that take fewer than four parameters?」
https://devblogs.microsoft.com/oldnewthing/20160623-00/?p=93735