9 分で読了 #compilers #コード生成
このページの目次

目標コードと ABI

関数呼び出しは数命令の単純な話ではない——どのレジスタを誰が保存するか、引数をどう渡すか、スタックフレームをどう配置するかは、すべて ABI に厳格に定義されており、これを違反すると即座にクラッシュする。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                  ← 被呼び出し関数が使用する場合は保存・復元が必要

fn(a, b, c, d, e, f, g) という呼び出しは、x86-64 System V において次のようになる:

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 引数のための「シャドウスペース (32 バイト)」を事前に確保しなければならない⁠——たとえ使用しなくても、この領域が存在する必要がある。クロスプラットフォームコンパイル時、これは頻繁に陥りやすい落とし穴である。

スタックフレームの解剖

関数スタックフレームの解剖:rbp から始まり、正のオフセットは呼び出し側、負のオフセットは自関数

スタック上位 (呼び出し側)

呼び出し側がプッシュした引数 7+ ← 呼び出し側が call の前に push 戻りアドレス ← call 命令が自動的に push 古い rbp ← push rbp (プロローグ) rbp → ローカル変数 ← sub rsp, N で割り当てられたスタック領域 callee-saved レジスタ ← 被呼び出し側レジスタの保存、例:rbx、r12 spill slots ← レジスタ割り当てのオーバーフロー領域 現在のスタックトップ rsp →

スタック下位

フレームポインタは任意:frameless 関数では rsp のオフセットのみを使用してローカル変数にアクセスし、push/pop の命令を節約するが、デバッグ時にスタックフレームの遡行が困難になる。 デバッグ情報 (DWARF .debug_frame) は、rsp から履歴の rbp への復元ルールを記録している。

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            ; ローカル変数 + spill + 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) が必要である:コードセグメントはメモリの任意の位置にロードでき、コード自体のリロケーション修正は不要(コードセグメントは共有されており、修正すると全プロセスに影響するため)。その代償として、グローバル変数や関数のアクセスには間接参照が1層追加される:

非 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 経由で間接参照される——ロード時にダイナミックリンカが 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