コンテンツにスキップ

関数呼び出し・コールスタック・呼出規約・レジスタ

一言で言うと

関数呼び出しは「戻り先を記録してジャンプ」する仕組みで、その記録場所+ローカル変数の置き場がコールスタックです。引数と戻り値の受け渡しルール(呼出規約)はABIの一部で、デバッガのスタックトレースはこの構造を読んでいます。

レジスタ — CPUの手元メモリ

  • CPU内部の超高速な記憶場所(x64では汎用16本: rax, rbx, rcx, ..., 各64bit + SIMD用など)。アクセスは実質0サイクル、メインメモリは数百サイクル——この差を埋める中継ぎがレジスタとキャッシュ
  • コンパイラの重要な仕事=レジスタ割付(どの変数をレジスタに住まわせるか)。ローカル変数が「メモリに存在しない」(レジスタだけで一生を終える)ことは最適化ビルドでは日常(→ デバッグビルドとの挙動差の一因: Debug/Release)

関数呼び出しで起きること(x64の典型)

int CalcDamage(int atk, int def) {
    int base = atk * 2 - def;
    return base < 1 ? 1 : base;
}
// 呼ぶ側: int d = CalcDamage(50, 30);
1. 引数をレジスタへ:  atk→ecx(またはedi)、def→edx(esi)   ← 呼出規約が決める
2. call命令:        「次の命令のアドレス」をスタックにpushして、CalcDamageへジャンプ
3. (必要なら)プロローグ: 呼び先がスタックに自分のフレームを確保
4. 本体の計算(レジスタ上で)
5. 戻り値をraxへ
6. ret命令:         スタックから戻り先をpopしてジャンプ
7. 呼んだ側はraxからdを受け取る

コールスタックの構造

高アドレス
│ mainのフレーム       [mainのローカル変数]
│ ─────────────
│ 戻り先(mainの中)     ← Battle()を呼んだcallが積んだ
│ Battleのフレーム     [Battleのローカル変数]
│ ─────────────
│ 戻り先(Battleの中)   ← CalcDamage()を呼んだcallが積んだ
│ CalcDamageのフレーム
▼ 低アドレスへ成長
  • デバッガのスタックトレースは、この「戻り先」の連鎖を遡って表示している
  • スタックバッファオーバーフローでローカル配列からはみ出すと「戻り先」を上書きできる——古典的なセキュリティ攻撃(リターンアドレス書き換え)と、「リターンした瞬間に変な場所へ飛ぶ」バグの正体(→ buffer overflow)
  • 再帰が深すぎるとフレームが積み上がりstack overflow

呼出規約 (calling convention)

「引数はどのレジスタ/スタックか」「戻り値はどこか」「どのレジスタは呼び先が保存すべきか」の取り決め。ABIの一部です。

  • x64ではプラットフォームで統一: Windows x64規約(rcx, rdx, r8, r9+スタック / 戻りrax)、System V(Linux/Mac: rdi, rsi, rdx, rcx, r8, r9)。この違いが「WindowsとLinuxのバイナリ非互換」の一因
  • 32bit時代の __cdecl / __stdcall / __fastcall の混在は現代x64ではほぼ過去のもの(DLLの古いヘッダーで見かける程度)
  • 大きい構造体の受け渡し: レジスタに収まらない値はメモリ経由(隠しポインタ渡し)になる——「大きい型はconst参照渡し」の低レベルな根拠(→ 値渡しか参照渡しか)

呼び出しのコスト感覚

呼び出し 目安のコスト
インライン展開済み 0(呼び出し自体が消滅)
直接call 数サイクル(予測が当たれば)
仮想関数(間接call) +数サイクル+インライン化阻害(→ vtable)
DLL境界の呼び出し +間接ジャンプ(IAT経由)
C#⇔ネイティブ(P/Invoke) 数十〜数百サイクル(マーシャリング込み → 第9部)

「関数を分けると遅くなる?」への答え: 小さい関数はインライン化されるので設計上は分けてよい。呼び出しコストを理由に関数をまとめるのは、計測で示されたホットスポットだけ(→ 早すぎる最適化)。

C#との対応

  • C#(JIT後)も同じCPU上で同じ構造(スタック・レジスタ・call)で動く。ILのスタックマシンは仮想的な表現で、JITが実レジスタに割り付ける
  • スタックトレースがC#で「いつでも綺麗に取れる」のはランタイムがフレーム情報を保持しているから。C++の最適化ビルドでは、インライン化・フレームポインタ省略でトレースが欠けることがある(PDB/シンボルで補う → ビルド構成)

Unity開発者が誤解しやすい点

  1. 「スタック=Stackのようなデータ構造」— コールスタックはハードウェアとABIが定める実行の仕組みで、pushしているのは戻り先とローカル変数
  2. 「ローカル変数は必ずメモリにある」— 最適化ビルドではレジスタだけのことが多い(デバッガで「値が見えない」理由)
  3. 値渡し=常にコピーで遅い、でもない(小さい型はレジスタ渡しで参照より速い)

ゲーム開発での使用例

  • クラッシュダンプ解析: スタックトレースの読み方、壊れたスタック(トレースが無意味な羅列)を見て「スタック破壊系のバグ」と当たりを付ける
  • ホットパスの関数分割判断(インライン化を信じて可読性優先でよい根拠)
  • プラットフォーム移植でのABI差異の理解

理解度チェック

  1. call命令とret命令はスタックに何をし、スタックトレースはそれをどう使っていますか。
  2. 呼出規約とは何の取り決めですか。誰と誰の間の約束ですか。
  3. 「小さい関数に分けると遅い」が大抵誤りである理由は?

演習

godbolt.orgで CalcDamage-O0-O2 でコンパイルし、(a) 引数がどのレジスタで渡るか、(b) -O2でスタックフレーム操作が消えること(または関数ごと消えること)を観察してください。


前: メモリレイアウト | カテゴリ目次 | 次: キャッシュ・分岐予測・SIMD