x86-64の関数呼び出しとABI実践
System VとWindows x64の引数、保存レジスタ、スタック整列、アンワインド
x86-64の関数呼び出しとABI実践
本ブログの記事は講義を目的として作成しています。 ここでは一般的なSystem V AMD64とMicrosoft x64 ABIを比較します。kernel、syscall、vectorcallなどは対象環境の資料を別途確認してください。
CPUのcall命令は復帰アドレスを保存して制御を移すだけである。引数、戻り値、保存レジスタ、スタック整列、unwindを決めるのはABIである。
ISA、calling convention、ABI
- ISAは命令とレジスタのハードウェア動作を定義する。
- calling conventionは引数、戻り値、保存状態、stack責任を定義する。
- ABIはさらに型layout、object format、symbol、relocation、unwindを含む。
extern "C"はC++のlanguage linkageとname manglingを制御するが、Windows関数をSystem V規約に変えるものではない。
主要な比較
| 項目 | System V AMD64 | Microsoft x64 |
|---|---|---|
| 整数引数 | rdi,rsi,rdx,rcx,r8,r9 | 位置1〜4にrcx,rdx,r8,r9 |
| FP引数 | 主にxmm0〜xmm7 | 位置1〜4にxmm0〜xmm3 |
| 整数戻り値 | rax | rax |
| FP戻り値 | xmm0 | xmm0 |
| caller領域 | shadow spaceなし | 32バイトshadow space |
| red zone | 条件を満たすuser-space leafに128バイト | なし |
| 主な非volatile GPR | rbx,rbp,r12〜r15,rsp | 加えてrdi,rsi |
| 非volatile XMM | なし | xmm6〜xmm15の下位128ビット |
aggregate、vector、variadic、hidden return pointerは追加規則が必要である。
caller-savedとcallee-saved
volatileレジスタはcall後に変化してよく、必要ならcallerが保存する。nonvolatileレジスタをcalleeが使用した場合は元へ戻す。
push rbx
mov rbx, rcx
; use rbx
pop rbx
ret
保存は必ずpushするという意味ではない。使わなければ保存不要で、stack slotへmovしてもよい。
System VとWindowsの呼び出し
System V:
mov edi, 10
mov esi, 20
mov edx, 30
call combine
; rax = result
Windows x64:
sub rsp, 40
mov ecx, 10
mov edx, 20
mov r8d, 30
call combine
add rsp, 40
32バイトはcallerが用意するshadow spaceで、追加8バイトは復帰アドレスを考慮した整列の代表例である。引数が4個未満でもshadow spaceを予約する。
Cのlongは一般的なSystem V LP64では64ビット、Windows LLP64では32ビットである。「x64だからlongも64ビット」とは限らない。
スタック整列の時点
次の時点を分ける。
- callerの
call直前 - 復帰アドレスが積まれたcallee入口
- prologue後
- nested
call直前
ある時点の剰余規則を別の時点へ適用すると8バイトの混乱が起きる。Windows x64はprologue/epilogue外でrspの16バイト整列を要求する。
red zoneとunwind
System Vのuser-space leafは条件付きでrsp下128バイトのred zoneを使える。nested call、Windows、kernel、red zone無効ビルドには適用しない。
Windowsのnon-leaf関数は例外処理とstack walkのため実際のprologueとunwind metadataを一致させる。
Work PROC FRAME
push rbx
.pushreg rbx
sub rsp, 32
.allocstack 32
.endprolog
; body
add rsp, 32
pop rbx
ret
Work ENDP
directiveは実行命令の代わりではなく、それを記述するmetadataである。不一致は通常のretが成功しても例外やprofilerを壊す。
stack引数とFP引数
Windowsの5番目以降の位置はstackに置かれる。ただしoffsetは「callee入口」か「prologue後」かで変わる。[rsp+40]を時点なしで暗記しない。
void f(int a, double b, int c, float d);
Windowsでは概ねa -> rcx、b -> xmm1、c -> r8、d -> xmm3となる。FP引数だけを数えてbをxmm0へ置いてはいけない。
variadicと構造体
printfなどでは追加規則がある。
- System Vはvector register引数に関する情報を
alで伝える。 - Microsoft x64はvariadic FP値を対応するinteger registerにも複製する。
- Cのdefault argument promotionで
floatはdoubleになる。
System Vはaggregateをeightbyte単位で分類し、可能ならinteger/SSEレジスタへ分割する。Windowsは特定サイズを値として、それ以外をcaller側temporaryへのpointerで渡すことがある。handwritten ASM境界では固定幅scalar、単純なextern "C" free function、sizeof/alignof/offsetofのassert、callerとcallee両方の逆アセンブリを優先する。
大きな構造体戻り値にはhidden return pointerが追加され、明示引数が一つずつずれたように見えることがある。
tail call
wrapper:
; target用に引数を配置
jmp target
ABI条件が合えばcompilerは最後のcall+retをjmpにし、targetが元のcallerへ直接戻る。
デバッグ順序
- callerとcalleeのarchitecture/ABIは同じか。
- symbolとlanguage linkageは一致するか。
call直前の引数位置と型は正しいか。- stack offsetはどの時点の
rsp基準か。 - alignmentとWindows shadow spaceは正しいか。
- 変更したnonvolatileを全経路で復元するか。
- Windows frame directiveは実際のprologueと一致するか。
- 戻り値を正しい幅の
raxまたはxmm0から読むか。
まとめ
callとretは骨格にすぎない。System Vは6本の主な整数引数レジスタとred zoneを使い、Microsoft x64は4つの位置ベースslot、32バイトshadow space、非volatile XMM、厳格なunwind規則を使う。表を暗記するだけでなく、caller/calleeの逆アセンブリ、境界テスト、stack walkで契約を証明する。