このページの目次
目標コードと ABI
関数呼び出しは数命令の単純な話ではない——どのレジスタを誰が保存するか、引数はどう渡すか、スタックフレームはどのように配置するかは、すべて ABI(Application Binary Interface) で厳密に定義されており、これを違反すると即座にクラッシュする。x86-64 System V と Win64 の違いは、クロスプラットフォームコンパイルで最も頻繁に陥る落とし穴である。
概要
命令選択は、IR(中間表現)の操作をターゲットマシンの命令にマッピングする。しかし、関数呼び出しは数命令の単純なマッピングではない——どのレジスタを誰が保存するか、引数はどう渡すか、スタックフレームはどのように配置するか、戻りアドレスはどこにあるかといったことが関わる。これらの規約はすべて ABI(Application Binary Interface) の仕様書に記されており、OS とコンパイラの両方が従わなければならない。ABI に違反すると、プログラムは即座にクラッシュする——これは論理エラーではなく、スタックが汚染され、戻りアドレスが上書きされるためである。本稿では、x86-64 System V(Unix/Linux/Mac)を主軸とし、Win64 と対比させながら、スタックフレームの各領域を明確に解説する。
呼び出し規約:レジスタとスタックのプロトコル
x86-64 System V における引数渡しのルール:
引数の順序: rdi, rsi, rdx, rcx, r8, r9, [スタック (右詰め)]
戻り値: rax (64-bit 以内), rdx:rax (128-bit 構造体)
呼び出し側保存: rax, rcx, rdx, rsi, rdi, r8-r11 ← call 後に破壊される可能性がある
被呼び出し側保存: rbx, rbp, r12-r15 ← 被呼び出し関数が使用する場合は保存・復元が必要
x86-64 System V における fn(a, b, c, d, e, f, g) の呼び出し例:
mov rdi, a ; 引数 1
mov rsi, b ; 引数 2
mov rdx, c ; 引数 3
mov rcx, d ; 引数 4
mov r8, e ; 引数 5
mov r9, f ; 引数 6
push g ; 引数 7 → スタック (call の前にプッシュ)
call fn
Win64 では異なる:最初の 4 引数は rcx, rdx, r8, r9 を使用し、残りはすべてスタック経由。さらに、呼び出し側はスタック上に最初の 4 引数のための「シャドウスペース (shadow space)」(32 バイト) を事前に確保しなければならない——たとえ使用しなくても、この領域が存在する必要がある。クロスプラットフォームコンパイル時、これは頻繁に陥る落とし穴となる。
スタックフレームの解剖
rbp(フレームポインタ)から正のオフセット方向に戻りアドレスや呼び出し側の引数へ、負のオフセット方向へ自関数のローカル変数がある。フレームポインタは任意である——現代のコンパイラは frameless をよく使用する:rsp のオフセットのみを使用してアクセスし、rbp を push しない(命令数を節約できるが、デバッグ時にスタックフレームを遡るのが困難になる)。デバッグ情報(DWARF .debug_frame)には、rsp から履歴の rbp への復元ルールが格納されている。
Red zone(x86-64 System V)
rsp より下位 128 バイトは red zone であり、関数はこの領域を rsp を動かさずに使用できる。これは、その関数が他の関数を呼び出さない場合(リーフ関数)のみ有効である。Win64 には red zone がない——Windows 上では、rsp より下位がシグナルハンドラや割り込みによって破壊される保証がない。
プロローグ / エピローグ
各関数は、エントリ(プロローグ)と出口(エピローグ)に標準的なシーケンスを挿入する:
プロローグ:
push rbp ; フレームポインタを保存
mov rbp, rsp ; 新しいフレームポインタを設定
sub rsp, N ; ローカル変数 + スピル + callee-saved 用にスタック領域を割り当て
push rbx (etc.) ; 被呼び出し側レジスタを保存(本関数が使用する場合)
エピローグ:
pop rbx (etc.) ; 被呼び出し側レジスタを復元
mov rsp, rbp ; スタック領域を解放
pop rbp ; 以前のフレームポインタを復元
ret
コンパイラは、本関数がどのレジスタを使用しているかを知っている——rbx を使用しないなら、push rbx は行わない。フレームレイアウト(どのレジスタがスタック上のどの位置にあるか)はコンパイル時に決定されており、プロローグ/エピローグにおける push/pop とオフセットは定数である。
可変長引数関数 (Variadic Function)
printf(fmt, a, b, c, ...) において、引数の数と型はコンパイル時に不明である。x86-64 ABI では、可変長引数関数の引数渡しは通常関数と同じ(最初の 6 個はレジスタ、残りはスタック)と規定されているが、al レジスタに浮動小数点引数に使用された XMM レジスタの数が記録される——これが printf が内部で可変長引数を検索するための鍵となる。
コンパイラへの要件:可変長引数呼び出しポイントにおいて、コンパイラは、XMM レジスタに渡される可能性のある引数がすべて対応する va_list の位置に保存されていることを保証する必要がある。va_start/va_arg の実装は、完全に ABI のレジスタおよびスタック引数のレイアウトに基づいている。
PIC / PIE:位置独立コード
共有ライブラリと現代の実行ファイルは、PIC(Position-Independent Code:位置独立コード) が必要である:コードセグメントはメモリの任意の位置にロードでき、コード自体のリロケーション修正は不要である(コードセグメントは共有されており、修正するとすべてのプロセスに影響するため)。その代償として、グローバル変数や関数のアクセスには間接参照が一段追加される:
非 PIC: PIC:
mov rax, [addr_of_x] mov rax, [rip + offset_to_GOT_entry]
call fn call [rip + offset_to_GOT_entry_for_fn]
; GOT = Global Offset Table、データセグメント内に配置
コンパイラが PIC を生成する際、すべての「外部シンボルのアドレス」は GOT(Global Offset Table)経由で間接参照される——ロード時にダイナミックリンカが GOT のエントリを埋める。rip + offset は PC 相対アドレス指定(絶対アドレスを必要としない)であり、コードセグメントとアドレスが独立していることを保証する。
参考
- System V AMD64 ABI: https://gitlab.com/x86-psABIs/x86-64-ABI — 権威ある仕様書
- Microsoft x64 ABI: https://learn.microsoft.com/en-us/cpp/build/x64-software-conventions
- Agner Fog: "Calling Conventions" — 各プラットフォームの ABI のエンジニアリング的なまとめ
Keywords: ABI, calling convention, System V, Win64, stack frame, prologue, epilogue, frame pointer, red zone, caller-saved, callee-saved, register passing, shadow space, variadic, va_list, AL register, PIC, PIE, GOT, PLT, PC-relative addressing, stack alignment