関数呼び出し・コールスタック・呼出規約・レジスタ¶
一言で言うと¶
関数呼び出しは「戻り先を記録してジャンプ」する仕組みで、その記録場所+ローカル変数の置き場がコールスタックです。引数と戻り値の受け渡しルール(呼出規約)は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開発者が誤解しやすい点¶
- 「スタック=Stack
のようなデータ構造」— コールスタックはハードウェアとABIが定める実行の仕組みで、pushしているのは戻り先とローカル変数 - 「ローカル変数は必ずメモリにある」— 最適化ビルドではレジスタだけのことが多い(デバッガで「値が見えない」理由)
- 値渡し=常にコピーで遅い、でもない(小さい型はレジスタ渡しで参照より速い)
ゲーム開発での使用例¶
- クラッシュダンプ解析: スタックトレースの読み方、壊れたスタック(トレースが無意味な羅列)を見て「スタック破壊系のバグ」と当たりを付ける
- ホットパスの関数分割判断(インライン化を信じて可読性優先でよい根拠)
- プラットフォーム移植でのABI差異の理解
理解度チェック¶
- call命令とret命令はスタックに何をし、スタックトレースはそれをどう使っていますか。
- 呼出規約とは何の取り決めですか。誰と誰の間の約束ですか。
- 「小さい関数に分けると遅い」が大抵誤りである理由は?
演習¶
godbolt.orgで CalcDamage を -O0 と -O2 でコンパイルし、(a) 引数がどのレジスタで渡るか、(b) -O2でスタックフレーム操作が消えること(または関数ごと消えること)を観察してください。
前: メモリレイアウト | カテゴリ目次 | 次: キャッシュ・分岐予測・SIMD