呼び出し規約
呼び出し規約は、引数と戻り値をどこへ置くか、どのレジスターを保持するか、スタックを誰が解放するかを定める約束です。ここではMicrosoftのネイティブC/C++の標準的な規約を扱います。LinuxのSystem V ABI、ARM64EC、__vectorcall、CLRやJIT内部の独自規約は区別して調べる必要があります。
x86の主な規約
以下は一般的な整数・ポインター引数の概要です。構造体や浮動小数点の渡し方には追加の規則があります。
| 規約 | 引数 | 引数領域の解放 | 代表的な用途 |
|---|---|---|---|
__cdecl | 右から左の順にスタックへ配置 | 呼び出し側 | 通常のC関数、可変長引数 |
__stdcall | 右から左の順にスタックへ配置 | 呼び出し先 | 多くのWin32 API |
__fastcall | 先頭から対象となる2個までのDWORD以下の引数をecx・edx、残りをスタックへ配置 | 呼び出し先 | 明示的に指定した関数など |
__thiscall | thisをecx、明示的な引数を右から左にスタックへ配置 | 通常は呼び出し先 | 可変長引数ではないC++非staticメンバー関数 |
32ビット整数とポインターの戻り値は主にeaxです。64ビット整数はedx:eax、通常の浮動小数点戻り値はx87のst(0)を使います。構造体の戻り値にはサイズや型の性質による違いがあり、隠しポインターが追加される場合があります。
cdeclとstdcallの違い
次のコードを共通サンプルでビルドし、x86版CDBで確認します。
int add_values(int a, int b) { return a + b; }
int __stdcall add_stdcall(int a, int b) { return a + b; }
int call_pair()
{
int total = add_values(10, 20);
return add_stdcall(total, 3);
}
0:000> uf sample!call_pair
sample!call_pair:
003f1040 6a03 push 3
003f1042 6a14 push 14h
003f1044 6a0a push 0Ah
003f1046 e8d5ffffff call sample!add_values (003f1020)
003f104b 83c408 add esp,8
003f104e 50 push eax
003f104f e8bcffffff call sample!add_stdcall (003f1010)
003f1054 c3 ret
0:000> uf sample!add_stdcall
sample!add_stdcall:
003f1010 55 push ebp
003f1011 8bec mov ebp,esp
003f1013 8b4508 mov eax,dword ptr [ebp+8]
003f1016 03450c add eax,dword ptr [ebp+0Ch]
003f1019 5d pop ebp
003f101a c20800 ret 8
add_valuesの後は呼び出し側のadd esp,8で2引数分を解放しています。一方、add_stdcallはret 8で引数分も解放するため、呼び出し側に対応する解放命令がありません。
最初のpush 3は、次のadd_valuesではなく、その後のadd_stdcallの第2引数を先に配置したものです。pushを直後の呼び出しだけに機械的に対応付けず、各時点のスタック位置を追う必要があります。
宣言と実装で規約が一致しなければ、スタック位置がずれてクラッシュする可能性があります。特にコールバックや関数ポインターの型を確認するとよいでしょう。
fastcallとthiscall
__fastcallは「必ず第1・第2引数がレジスターに入る」という単純な規則ではありません。引数を左から調べ、レジスター渡しの対象になる型を選びます。大きな構造体などは別の扱いです。
__thiscallでは、関数入口のecxがthisを指します。ただし、可変長引数のメンバー関数は__cdeclとなり、thisもスタックで渡されます。
Windows x64
x64では、通常の__cdecl・__stdcall・__fastcall・__thiscall指定は個別のx86規約を作らず、共通のx64規約を使用します。
| 引数の位置 | 整数・ポインター | 浮動小数点 |
|---|---|---|
| 第1引数 | rcx(32ビット整数ならecxなど) | xmm0 |
| 第2引数 | rdx | xmm1 |
| 第3引数 | r8 | xmm2 |
| 第4引数 | r9 | xmm3 |
| 第5引数以降 | スタック | スタック |
レジスターは引数の位置で選ばれます。たとえば次の関数では、整数引数が2個でもcountはedxではなくr8dです。
void inspect(int id, double value, int count);
id:ecxvalue:xmm1の下位64ビットcount:r8d
通常の整数・ポインター戻り値はrax、float・doubleはxmm0です。大きな構造体の戻り値では、呼び出し側が確保した出力先への隠しポインターが追加される場合があります。メンバー関数のthisも含め、ソース上の引数番号と実際の引数配置がずれることがあります。
シャドウスペースと第5引数
呼び出し側は、レジスター引数が4個未満でも32バイトのシャドウスペースを確保します。呼び出し先入口でまだrspを変更していないなら、戻りアドレスが[rsp]、第5引数が[rsp+28h]です。
ただし、シャドウスペースに引数が自動保存されるわけではありません。関数途中のrspや、初期化されていないシャドウスペースを使って引数を読まないことが重要です。具体的な配置はスタック操作と関数呼び出しで説明します。
可変長引数
Windows x64の可変長引数呼び出しでは、先頭4引数位置の浮動小数点値を、対応する汎用レジスターにも複製して渡します。
たとえばprintf("%f", 1.5)では、書式文字列がrcx、doubleのビット表現がxmm1とrdxに渡されます。浮動小数点値を整数へ数値変換するわけではありません。floatは可変長引数の既定の昇格でdoubleになります。
Windows ARM64
通常の可変長引数でない関数では、整数・ポインターと浮動小数点のレジスター割り当てをそれぞれ進めます。
| 種類 | 主な配置 |
|---|---|
| 整数・ポインター引数 | x0~x7。32ビット整数は対応するw0~w7部分 |
| 浮動小数点引数 | v0~v7の対応する部分(s0、d0など) |
| レジスターに収まらない引数 | スタック。サイズ・アラインメントの追加規則あり |
| 単純な整数・ポインターの戻り値 | x0 / w0 |
float・doubleの戻り値 | s0・d0 |
先ほどのinspect(int id, double value, int count)なら、idがw0、valueがd0、countがw1です。x64のように引数位置を共有しません。
整数またはポインターだけの単純な9引数関数なら、第1~第8引数がx0~x7、第9引数が関数入口の[sp]に配置されます。blは戻り先をLRに保存するため、スタック先頭に戻りアドレスを自動では追加しません。x64のような必須の32バイトシャドウスペースもありません。
構造体・ベクトルには別の分類規則があります。たとえば、同種の浮動小数点メンバーで構成されるHFAや、間接的な構造体戻り値に使われるx8などです。C++の型の性質によっては別の隠し引数配置になるため、「構造体は必ずポインター1個」「x8は常に戻り値ポインター」とは判断しません。
ARM64の可変長引数
Windows ARM64の可変長引数関数では、通常の浮動小数点・SIMD引数レジスターを使いません。汎用レジスターとスタックを使う規則になるため、通常関数のd0などを前提にした読み方は適用できません。
たとえばprintf("%f", 1.5)は、書式文字列をx0、doubleのビット表現をx1で渡す形になります。Windows x64の「XMMと汎用レジスターへ複製」とも異なります。
呼び出しをまたいで残る値
揮発(volatile)レジスターは、呼び出し先が自由に変更できます。非揮発(nonvolatile)レジスターは、呼び出し先が変更するなら元の値を復元します。このvolatileは、C/C++の型修飾子とは別の意味です。
| 環境 | 主な揮発レジスター | 主な非揮発レジスター |
|---|---|---|
| x86 | eax、ecx、edx | ebx、esi、edi、ebp |
| x64 | rax、rcx、rdx、r8~r11、xmm0~xmm5 | rbx、rbp、rsi、rdi、r12~r15、xmm6~xmm15 |
| ARM64 | x0~x17、v0~v7、v16~v31 | x19~x29、v8~v15の下位64ビット |
スタックポインターには復元・アラインメントの規則があります。ARM64のx18は予約済みで、x30は呼び出しのたびに上書きされるLRです。x64のYMMなどの上位部分にも別の保存規則があるため、XMMの表をそのまま全SIMD幅へ広げないようにします。
ダンプから引数を読む手順
- 対象の命令セットと関数の宣言を確認する
- 入口での引数位置をABIから求める
- プロローグと本体で、引数が別のレジスターやスタックへ移されていないか追う
- 関数呼び出しをまたぐ揮発レジスターの値を、元の引数と断定しない
- 隠し引数、可変長引数、インライン化、末尾呼び出しの有無を確認する
コールスタックに表示された引数も、シンボル情報や最適化、レジスターの復元可否に制約されます。値が復元できない場合は、その制約を残して調査を進める方が確実です。
実測用のコードとダンプを用意する
共通サンプルの次の関数を使用します。各関数は__declspec(noinline)付きです。volatile配列は、説明対象のローカル変数が実際にメモリーへ配置されるようにするためのものです。通常の最適化済みローカル変数がすべてスタック上に残るという意味ではありません。
int frame_probe(int first, int second, int third, int fourth, int fifth)
{
volatile int local[3] = {first + second, third + fourth, fifth};
int total = identity(first + fifth);
local[1] += total;
__debugbreak();
return local[0] + local[1] + local[2];
}
identityは受け取った整数を返す別関数です。サンプルを引数付きで起動すると、mainはframe_probe(11, 22, 33, 44, 55)を呼び出します。引数なしの起動は、配列・文字列などを確認する従来の例です。
cdb.exe -y . sample.exe frame
最初の停止位置で、次の入力により、入口と関数途中の2個のダンプを取得します。未使用のファイル名で実行します。これは入力手順であり、出力の引用ではありません。
bp sample!frame_probe
g
.dump /ma entry.dmp
bc 0
g
.dump /ma middle.dmp
q
この手順は、最初に設定したブレークポイントの番号が0である場合です。入口でそれを解除し、次はソースの__debugbreak()によるブレークポイント例外で停止します。middle.dmpはアクセス違反ではなく、調査用に意図的に停止した状態です。
以下は、保存したファイルをCDBで再度開いて確認した結果です。x86はフレームの基点を明確にするため/O2 /Ob0 /Oy-、x64・ARM64は/O2 /Ob0でビルドしました。共通のビルド手順に対し、x86だけ/Oy-を追加します。
cdb.exe -y . -z entry.dmp
cdb.exe -y . -z middle.dmp
例外ダンプでは.ecxrで例外コンテキストを選びます。ただし、今回取得したARM64上のx64エミュレーションのダンプでは、.ecxrがx64のレジスターを正しく復元できませんでした。 x64の以下の実測は、~0sで保存されたスレッド0を選び、.effmach amd64で読んだスナップショットです。一般のクラッシュダンプで、スレッド0が例外スレッドとは限りません。コンテキストが不正なまま0のレジスターをNULL引数と解釈せず、対象スレッドとコンテキストの種類を確認します。
入口のダンプから読む
x86:戻りアドレスの後ろに引数が並ぶ
entry.dmpを開いて.ecxrを実行した後の出力です。
0:000> dd @esp L6
02effd38 003f11f8 0000000b 00000016 00000021
02effd48 0000002c 00000037
最初の003f11f8が戻りアドレス、その後の0b、16、21、2c、37が、10進数の11、22、33、44、55です。まだプロローグを実行していないので、入口のespをそのまま基準にできます。
x64:先頭4引数はレジスター、第5引数はスタック
entry.dmpで~0sと.effmach amd64を実行した後です。
0:000:AMD64> r rip,rsp,rcx,rdx,r8,r9
rip=00007ff681521140 rsp=0000007b7b57fa48 rcx=000000000000000b rdx=0000000000000016 r8=0000000000000021 r9=000000000000002c
0:000:AMD64> dd @rsp+0x28 L1
0000007b`7b57fa70 00000037
この入口では、ecx = 11、edx = 22、r8d = 33、r9d = 44、[rsp+0x28] = 55と読めます。型は32ビットのintなので、レジスター値もその幅で解釈します。
ARM64:この5引数はすべてレジスター
entry.dmpで.ecxrを実行した後です。コンテキスト表示の補助行は省略しています。
0:000> r pc,sp,x0,x1,x2,x3,x4
pc=00007ff67bef4100 sp=000000757df7fb00 x0=000000000000000b x1=0000000000000016 x2=0000000000000021 x3=000000000000002c x4=0000000000000037
w0~w4が11、22、33、44、55です。x64の第5引数の位置を、そのままARM64の[sp+0x28]へ適用することはできません。
関数途中のダンプでは何が失われるか
middle.dmpの停止位置は、別関数identityを呼び、ローカル変数を更新した後です。
x64
0:000:AMD64> r rip,rsp,rcx,rdx,r8,r9
rip=00007ff68152116c rsp=0000007b7b57fa10 rcx=000000000000008f rdx=0000000000000016 r8=0000000000000021 r9=000000000000002c
0:000:AMD64> dv /t /v
<unavailable> int first = <value unavailable>
<unavailable> int second = <value unavailable>
<unavailable> int third = <value unavailable>
<unavailable> int fourth = <value unavailable>
0000007b`7b57fa70 int fifth = 0n55
0000007b`7b57fa30 int [3] local = int [3]
@eax int total = 0n66
0:000:AMD64> dd @rsp+0x60 L1
0000007b`7b57fa70 00000037
rcxは既に0x8f(143)へ変わっており、元の第1引数11ではありません。ほかのレジスターに入口と同じ値が残っていても、PDBがその位置で引数を復元できるとは限りません。<value unavailable>はNULLや0という意味ではなく、値を取得できないという表示です。
第5引数は元のスタックスロットに残っています。プロローグのsub rsp,38hでrspが0x38減ったため、現在の基点では0x28 + 0x38 = 0x60です。関数途中でも[rsp+0x28]を第5引数として読むと、この例ではローカル配列の要素を誤って引数として読んでしまいます。
x86
今回のフレームポインターを保持したビルドでは、元の引数がスタック上に残り、PDBと生のメモリーの両方から確認できました。
0:000> dd @ebp+8 L5
02effd3c 0000000b 00000016 00000021 0000002c
02effd4c 00000037
これは「x86なら常に元の引数を復元できる」という保証ではありません。引数スロットの再利用や書き換え、フレームポインター省略などがある関数では、実際の命令に基づいて読み直します。
ARM64
0:000> dv /t /v
<unavailable> int first = <value unavailable>
<unavailable> int second = <value unavailable>
<unavailable> int third = <value unavailable>
<unavailable> int fourth = <value unavailable>
<unavailable> int fifth = <value unavailable>
00000075`7df7fae0 int [3] local = int [3]
@w0 int total = 0n66
この時点のw0はidentityの戻り値66です。入口でレジスターに渡された引数について、x64のシャドウスペースに相当する保存先が自動で作られるわけではありません。現在の値やたまたま一致する数値を、元の引数として断定しないことが重要です。
呼び出し元を選んでも、過去の引数レジスターには戻らない
knでフレーム番号を確認し、.frame /r 1などで呼び出し元へ移ることができます。ただし、これは主にスタックと保存された非揮発レジスターを使った復元であり、呼び出し前のrcxやx0を復元する実行履歴ではありません。
値を復元できない場合は、呼び出し元のufで引数を設定した命令、実際に保存されたメモリー、PDBが示す有効範囲などを照合します。それでも確定できなければ「不明」とし、再現環境の入口ブレークポイントやTTDで確認する方法が考えられます。
同じダンプからローカル配列を探す手順は、スタック上のローカル変数を推定するで説明します。