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