はじめに (対象読者・この記事でわかること)

この記事は、Swiftでアプリを開発しているが「突然アプリがクラッシュして、コンソールにEXC_BAD_INSTRUCTIONとだけ表示される」という謎のエラーに遭遇した開発者向けです。
特に、値型(struct)を多用しているプロジェクトや、C/C++からの移行組、メモリの知識に盲点がある初心者〜中級者が該当します。

読み進めることで以下がわかります。

  • なぜ「ただの構造体」でクラッシュするのか
  • Swiftの値型がスタックに載る量と、OSが出す制限
  • エラーを即座に回避する3つの実装パターン(ヒープ移行、間接参照、分割)
  • 今後同様のトラブルを予防するための設計指針

前提知識

  • Swiftの基本文法(structclassinitlet/var
  • プログラムのメモリ領域に「スタック」「ヒープ」があることは聞いたことがあるレベル
  • Xcodeでブレークポイントを張れること

Swiftの値型が引き起こす「見えない壁」とは

Swiftは「値型を使おう」という思想が強く、ArrayDictionaryStringすら全て値型です。
値型はコピー時にスタックに積まれるため、小さければ高速で安全です。しかし、以下のようなコードを書いたとします。

Swift
struct TerrainTile { // 1タイル 48 byte var heightMap: [Float] // 32 byte var material: UInt32 // 4 byte var flags: UInt8 // 1 byte var padding: UInt8 = 0 // 3 byte } struct WorldMap { var tiles: [[TerrainTile]] // 1024×1024タイル }

単純計算で48 × 1024 × 1024 ≈ 48 MBです。
これをWorldMap()の初期化子で作ろうとすると、スタックサイズの上限(macOS: 8 MB、iOS: 1 MB)を遥かに超え、EXC_BAD_INSTRUCTIONが飛びます。
レジスタに積もうとしたデータが巨大すぎて、CPUが「無効な命令」と判断し、OSがプロセスをキルするというワケです。

このエラーはfatalErrorassertとは違い、Swiftランタイムが介入する余地がなく、デバッガも「ここで死にました」だけの情報を出すため、原因特定に時間がかかります。

構造体がクラッシュするまでのメカニズムと3つの回避戦略

1. 値型のコピーがスタックを圧迫する仕組み

Swiftは以下のタイミングで値のコピーを発生させます。

  1. 変数への代入
  2. 関数への引き渡し(inoutでない限り)
  3. クロージャキャプチャ(@escapingでない限り)

各コピーはスタックフレーム上に逐次展開されます。
例えば、引数で巨大な構造体を受け取る関数が更に別の関数を呼び出すと、コピーが重ねられ、一瞬でスタックが破裂します。

2. ステップ:エラーの再現

PlaygroundやCLIプロジェクトで以下を実行すると即座に再現できます。

Swift
struct Fat { var data: (UInt8, UInt8, ..., UInt8) // 512要素のタプル } func boom(_ value: Fat) { print(value.data.0) } let huge = Fat() boom(huge) // ← ここでEXC_BAD_INSTRUCTION

3. 回避策A:クラス(参照型)への移行

最もシンプルな解決策は値をヒープに移すことです。
classに変更するだけでスタックの負荷はポインタ1本分(8 byte)になります。

Swift
final class TerrainTile { var heightMap: [Float] ... }

メモリを共有したくない場合は、読み取り専用インタフェースを公開し、内部でcopy-on-writeを実装すれば、値型と同等のセマンティクスを保てます。

4. 回避策B:間接参照(indirect)を使う

Swift 5以降、enumstructindirectを付与できます。
これにより値はヒープに格納され、スタックにはポインタだけが残ります。

Swift
struct WorldMap { indirect var tiles: [[TerrainTile]] }

ただし、indirectプロパティ単位ではなくコンテナ全体に効くため、パフォーマンス検証が必須です。

5. 回避策C:遅延初期化&分割

iOSのようなメモリ制限の厳しい環境では、一度に全部を載せない設計が最適です。

Swift
struct WorldMap { private var chunks: [String: [TerrainTile]] = [:] mutating func load(x: Int, y: Int) { let key = "\(x >> 4)_\(y >> 4)" if chunks[key] == nil { chunks[key] = DiskLoader.load(key) } } }

Chunk単位でメモリを確保・解放すれば、スタックの消費量は1 chunk分に抑えられ、クラッシュを回避できます。

ハマった点:エラーメッセージが何も教えてくれない

EXC_BAD_INSTRUCTIONは、ランタイムエラーではなくCPU例外のため、コンソールにスタックトレースが出ないことがあります。
LLDBでbt allと打っても??の羅列、さらにReleaseビルドだと最適化で行番号まで飛ばされるため、原因を特定するのに数日かかりました。

解決策:メモリウィンドウでスタックサイズを監視

XcodeのDebug Navigator → Memoryで「Max Stack Usage」を有効にすると、各スレッドのスタック消費量がリアルタイムで見えます。
この値が1 MBを超えそうな瞬間にブレークポイントを張り、巨大な値型のコピーが走っている箇所を特定できました。

まとめ

本記事では、Swiftの構造体がEXC_BAD_INSTRUCTIONでクラッシュするメカニズムと、それを回避する3つの実装パターンを解説しました。

  • 値型のコピーはスタックに積まれ、サイズ制限を超えるとCPU例外で死ぬ
  • クラス化、indirect、チャンク分割のいずれかでスタックの負荷を減らせる
  • エラー原因の特定には「Max Stack Usage」監視が最速

この知識を活かせば、これから巨大なデータモデルを安全に扱えます。
次回は「値型と参照型を混在させたときのCopy-on-Writeの最適化ポイント」について掘り下げます。

参考資料