目標コードと ABI

このページの目次

ABI(Application Binary Interface)は、バイナリコンポーネント間の規約であり、データレイアウト、呼び出し規約、シンボルおよびオブジェクトフォーマットなどを含みます。関数の境界を挟んで異なるコンパイラによって生成されたコードであっても、引数がどこに配置されるか、どのレジスタが変更されるか、戻り値がどのように表現されるかについて合意する必要があります。

ABI に違反すると、即座にクラッシュするだけでなく、引数を静かに誤って読み込んだり、後で使用する値を破壊したりすることもあります。本稿では、一般的なユーザーランドの x86-64 呼び出しを例に挙げます。システムコール、カーネルコード、および特殊な呼び出し規約については別途考慮する必要があります。

引数はまず分類し、次に位置を割り当てる

System V AMD64 において、一般的な整数またはポインタの最初の 6 個の引数は RDI、RSI、RDX、RCX、R8、R9 を使用します。浮動小数点引数は別の XMM レジスタのルールセットを使用します。複合型(aggregate types)は ABI に従って分類する必要があり、すべての引数を単なる「最初の 6 つ」のリストに詰め込むことはできません。

Windows x64 において、最初の 4 個の引数の位置は、その型に応じて RCX/RDX/R8/R9 または対応する XMM0–3 を使用します。呼び出し側は、レジスタ引数のホーム/シャドウスペースとして少なくとも 32 バイトを予約します。整数と浮動小数点の混在する引数は、2 つの独立した無限の上位 4 位キューではありません。Microsoft x64 呼び出し規約

項目System V AMD64 の一般的なルールWindows x64 の一般的なルール
整数/ポインタレジスタRDI、RSI、RDX、RCX、R8、R9最初の 4 つの位置に RCX、RDX、R8、R9 を使用
スカラー浮動小数点ルールに従って XMM0–7 に割り当て最初の 4 つの位置に対応する XMM0–3
シャドウスペースWindows 形式の強制 4 槽領域なし呼び出し側が 32 バイトを予約
レッドゾーンユーザーランドで約 128 バイトの予約あり同等の保証なし
複合型、可変長引数、ベクトル分類と特殊ルールを参照独立したルールを参照

戻り値も型に応じて分類されます:スカラー整数は通常 RAX、スカラー浮動小数点は通常 XMM0 を使用します。いくつかの複合型は複数のレジスタに分割され、他の複合型は隠し結果ポインタを通じて返されます。「128 ビット構造体を RDX:RAX に統一して配置する」というのは正しくありません。AMD64 ABI

同じ混合引数宣言でも、レジスタ割り当てが異なる理由

可変長引数や複合型を含まない固定プロトタイプ double mix(int a, double b, int c, double d) を観察します。System V では、整数引数シーケンスと浮動小数点引数シーケンスがそれぞれ消費されます:a は EDI、b は XMM0、c は ESI、d は XMM1 を使用します。Windows x64 では、引数の位置に応じてレジスタが選択されます:a は ECX、b は XMM1、c は R8D、d は XMM3 を使用します。

引数System V AMD64Windows x64
a:第 1 引数、intEDIECX
b:第 2 引数、doubleXMM0XMM1
c:第 3 引数、intESIR8D
d:第 4 引数、doubleXMM1XMM3

表内の int は対応する 32 ビット値のみを使用します。宣言から、レジスタの上位ビットが他の引数の渡送に使用できると推論しないでください。以下のソースファイルはプラットフォームヘッダーに依存せず、2 つのターゲットに対してそれぞれアセンブリを生成できます。

/* mix.c */
double mix(int a, double double b, int c, double d) {
    return a + b + c + d;
}
clang --target=x86_64-unknown-linux-gnu -O2 -S -masm=intel mix.c -o mix-sysv.s
clang --target=x86_64-pc-windows-msvc -O2 -S -masm=intel mix.c -o mix-win.s

整数から double への変換命令から 2 つの整数のソースを識別し、加算チェーンをたどって浮動小数点の入力を確認できます。ここではターゲット間でアセンブリを生成するだけで、Windows プログラムを実行する必要はありません。戻り値はどちらも XMM0 ですが、入力レイアウトが異なるため、関数ポインタの強制変換によって差異を解消することはできません。Microsoft 引数渡送

引数の位置と型のキュー、なぜ混在させてはいけないのか?

以下では、単なる固定表の提示ではなく、引数の型が変更されるたびに位置を再割り当てします。System V 側では、int と double がそれぞれ独自のシーケンスを進みます。Windows 側では引数の位置が保持されるため、XMM0 がまだパラメータ伝達に使用されていない場合でも、2 番目の位置の double は XMM1 になります。最初の 4 位を超えると、この例ではスタックスロットが使用されます。これは、まだ空きレジスタがあるかどうかとは無関係です。

図を準備しています
引数の位置と型のキュー、なぜ混在させてはいけないのか?

System V のこの例では、整数と浮動小数点のシーケンスがそれぞれ消費されます。Windows では最初の 4 引数が位置に応じて GPR または XMM を選択し、5 番目の浮動小数点引数を XMM0 に無理やり詰めることはできません。

保存規約:誰が値を呼び出し間で存続させる責任を負うか

Caller-saved(呼び出し側保存)とは、呼び出し先が変更できることを意味し、呼び出し側が古い値をまだ必要とする場合は、事前に保存するか位置を変更する必要があります。Callee-saved(呼び出し先保存)とは、呼び出し先がそのレジスタを使用する場合、戻る前に呼び出し側の値を復元する必要があることを意味します。

System V では、RBX、RBP、R12–R15 は一般的な呼び出し先保存レジスタであり、RSP は呼び出し復帰の規約に従って復元されなければなりません。RAX、RCX、RDX、RSI、RDI、R8–R11 は呼び出しによって破壊される可能性があります。ベクトルレジスタやステータスレジスタにも独自のルールがあり、汎用レジスタのみを確認すればよいわけではありません。

これはレジスタ割り当てに直接影響します:呼び出しをまたいで存続する値は、呼び出しによって破壊される位置に保護なしで置くことはできません。

スタックレイアウトと整列

System V 例:rbp を使用したスタックフレームレイアウトの一種 スタック上で渡される 7 番目の整数引数 rbp + 16 戻りアドレス rbp + 8 呼び出し元の rbp rbp ローカル変数 / スパイル / 保存領域 負のオフセット、レイアウトはコンパイラ次第 現在割り当てられた領域の低アドレス境界 rsp 高アドレス 低アドレス レイアウトの一例です。フレームポインタは省略可能であり、保存領域の順序やローカル変数の位置はこの図によって固定されるわけではありません。

一般的な System V 呼び出しでは、call の前に RSP が 16 バイト境界に整列されており、call によって 8 バイトの戻りアドレスがプッシュされた後、関数エントリでの (RSP+8) がその整列を満たします。スタック上で渡されるベクトルなどの場合、より強い要件がある場合があり、対応する ABI ルールを基準とする必要があります。

したがって、7 つの整数引数を提示する際、現在のスタック状態とパディングを完全に無視して push 第7引数; call と書くことはできません。呼び出し側は、引数領域全体を事前に予約してから書き込むことができ、ソースコードの引数順序に従って個別に push する必要はありません。

ローカル変数は必ずしもスタック上にあるわけではなく、最適化後にはレジスタ内にあるか、定数に置き換えられているか、完全に消えている可能性があります。フレームポインタも省略できます。展開情報は、RSP から古い RBP を探す表を保存するだけでなく、呼び出し元の状態をどのように復元するかを記述します。ELF におけるランタイム展開には、.eh_frame などの情報が関与することがあります。

序文と終文:規約を満たすコードは固定テンプレートではない

序文では、呼び出し先保存レジスタの保存、スタックスペースの割り当て、フレームポインタの設定が行われる可能性があります。終文では対応する状態が復元されます。リーフ関数にはこれらの動作がない場合があり、最適化により保存が必要となるパスに遅らせることもできます。

以下は、Linux System V において外部の twice(long) を呼び出し、1 を加える GNU アセンブリの例です。Intel 構文を使用し、エントリでの RSP を 16 で割った余りが 8 であるため、8 減算して後の call のための整列を整えます。

.intel_syntax noprefix
.text
.globl plus_one_twice
.type plus_one_twice, @function
plus_one_twice:
    .cfi_startproc
    sub rsp, 8
    .cfi_def_cfa_offset 16
    call twice@PLT
    add rsp, 8
    .cfi_def_cfa_offset 8
    add rax, 1
    ret
    .cfi_endproc
.size plus_one_twice, .-plus_one_twice
.section .note.GNU-stack,"",@progbits

最初の long 引数はすでに RDI にあり、戻り値は RAX にあります。この関数は、追加の保存を必要とする RBX などのレジスタを使用していません。CFI はスタックの調整に合わせて CFA を記述し、展開器が呼び出し状態を復元できるようにします。

呼び出し後に元の引数を継続して使用するには、それを保持しなければならない

計算を twice(x) + x に変更します。x は当初 RDI にあり、twice は RDI を合法적으로書き換えることができるため、戻った後に RDI を直接加算するのは誤りです。以下では、まず呼び出し元の RBX をスタックに保存し、RBX を使用して x を保持します。twice は受け取った RBX を復元する必要があり、最後にこの関数が上位の呼び出し元の古い値を復元します。

# keep_x.s:GNU アセンブリ、Linux System V AMD64
.intel_syntax noprefix
.text
.globl keep_x
.type keep_x, @function
keep_x:
    .cfi_startproc
    push rbx
    .cfi_def_cfa_offset 16
    .cfi_offset rbx, -16
    mov rbx, rdi
    call twice@PLT
    add rax, rbx
    pop rbx
    .cfi_def_cfa_offset 8
    .cfi_restore rbx
    ret
    .cfi_endproc
.size keep_x, .-keep_x
.section .note.GNU-stack,"",@progbits

エントリでの RSP を 16 で割った余りが 8 であり、プッシュされた 8 バイトは RBX を保存すると同時に、call 前の RSP を 16 で割った余りを 0 にします。ここでは、前のセクションの sub rsp,8 をそのままコピーしてさらに減算すべきではありません。そうすると、この呼び出しポイントの整列を再び破壊してしまいます。

/* abi-main.c:keep_x.s と一緒にコンパイル */
extern long keep_x(long);
long twice(long x) { return x * 2; }
int main(void) {
    return keep_x(7) != 21 || keep_x(-3) != -9;
}

cc -O2 abi-main.c keep_x.s -o abi-demo を実行して abi-demo を実行すると、正常な終了ステータスは 0 になります。ここでは限られた小さな値のみを使用して、long の乗算または加算の符号付きオーバーフローが ABI の問題を隠さないようにします。

あるコンパイラがたまたま RDI を変更しなかったからといって、誤った書き方が正しいことを証明するものではありません。呼び出し境界のチェックは、呼び出し先がどの状態を破壊できるかに基づいて行わなければならず、単に某回の機械語を観察するだけでは不十分です。実装の変更、インスツルメンテーション、または別の互換定義へのリンクにより、潜伏していたエラーが表面化する可能性があります。

RBX の 2 つの値は異なる呼び出し層に属する keep_x への入り口 RBX = 上位の古い値 プッシュ後に RBX を借用 スタックに古い値を保存;RBX = x twice 復帰後 x を使用して合計し、pop で復元 エントリ rsp % 16 = 8 → プッシュ後 = 0 → call で戻りアドレスをプッシュ 呼び出し先保存レジスタは使用可能ですが、戻る際には呼び出し側が見ていた値を復元しなければなりません。

呼び出しをまたぐ x、どこに保存すれば信頼できるのか?

値が保持されたか、上位のレジスタが復元されたか、呼び出しポイントが整列されているかは、3 つの独立したチェック項目です。以下では、「21 を計算するが RBX を破壊する」または「値はすべて正しいが、余分なスタック減算により整列を失う」という状況を構築できます。数値結果は、例示された呼び出し先の動作に対する記号的推論です。ABI に違反した場合、それが実際の機械実行においてその数値を返すという保証として扱うことはできません。

図を準備しています
呼び出しをまたぐ x、どこに保存すれば信頼できるのか?

RDI は呼び出し先によって書き換え可能です。RBX を使用して x を保持する際は、まず上位の古い RBX を保存する必要があります。今回のプッシュで整列は完了しており、さらに `sub rsp,8` をコピーすると整列を破壊します。

レッドゾーン:一時値を呼び出しをまたいで無保護にしておいてはいけない

System V ユーザーランドでは、RSP 以下に 128 バイトのレッドゾーンが提供されており、シグナル処理などは関連する規約に従う必要があります。関数は RSP を調整せずに、一時データを保存するためにこれを使用できます。特にリーフ関数で一般的です。

しかし、ネストされた呼び出しはより低いアドレスのスタックスペースを使用するため、レッドゾーン内の値が通常の関数呼び出しを安全に跨ぐことを期待できません。非リーフ関数でも、呼び出しをまたいで存続する必要のない一時データを適切な区間で使用できるため、「リーフ関数のみがいかなる意味でも使用できる」というのは極端すぎます。カーネル、割り込み、および他のプラットフォームでは、この保証をそのまま持ち込むことはできません。

可変長引数:呼び出し側は実際の引数を知り、呼び出し先は規約に従って取り出す

printf などの関数宣言にはすべての実引数の型が列挙されていませんが、呼び出しポイントのコンパイラは実際の式を知っており、デフォルトの引数昇格を適用します。例えば、float は double に昇格されます。フォーマット文字列と実際の実引数の一致は、言語およびインターフェースのチェックの問題です。

System V において、可変長引数呼び出しには、ベクトルレジスタ引数の使用数を表す AL レジスタの上限値が使用され、値の範囲は ABI で規定されています。呼び出し先は必要に応じてレジスタ保存領域を構築し、va_list はレジスタ領域とスタック引数領域を追跡します。呼び出し側がすべての XMM 引数を事前に呼び出し先の va_list に書き込むわけではありません。

Windows の可変長引数のルールは異なり、浮動小数点実引数は関連する位置に汎用レジスタに同時に配置する必要がある場合があります。ABI をまたぐ手書きのブリッジでは、これを特別に処理する必要があり、単に引数レジスタの名前を置き換えるだけでは不十分です。

PIC と PIE:絶対アドレス依存の軽減

位置依存コード(Position Independent Code, PIC)は、ローカルデータとコードへのアクセスに PC 相対アドレス指定を頻繁に利用し、動的バインド可能なシンボルには GOT、PLT、または他の規約パスを使用します。すべての PIC シンボルアクセスが必ずしも GOT 間接を 1 回余分に必要とするわけではありません。バインド、可視性、およびリンカの緩和により、直接的な相対アクセスが許可される場合があります。

PIE(Position Independent Executable)は、位置依存実行ファイルの一種であり、ロードアドレスのランダム化と組み合わせて使用されます。特定の再配置が必要かどうか、および再配置がどこで行われるかは、オブジェクトフォーマットとシンボルルールを組み合わせる必要があります。リンクとロード を参照。

言語間境界のクイックリファレンス

関数プロトタイプ、データサイズと整列、複合型の分類、呼び出し規約、例外が境界を跨いで処理できるかどうか、メモリを誰が解放するか、およびシンボル名を確認します。extern "C" などの宣言は、一部のリンクおよび呼び出し規則を指定できますが、すべての言語オブジェクトが互換性のあるレイアウトとライフサイクルを持つように自動的にするものではありません。

参考文献

Keywords: ABI, calling convention, stack alignment, caller-saved, callee-saved, shadow space, red zone, variadic, CFI, PIC, PIE