はじめに (対象読者・この記事でわかること)
この記事は、Swiftでアプリを開発しているが「突然アプリがクラッシュして、コンソールにEXC_BAD_INSTRUCTIONとだけ表示される」という謎のエラーに遭遇した開発者向けです。
特に、値型(struct)を多用しているプロジェクトや、C/C++からの移行組、メモリの知識に盲点がある初心者〜中級者が該当します。
読み進めることで以下がわかります。
- なぜ「ただの構造体」でクラッシュするのか
- Swiftの値型がスタックに載る量と、OSが出す制限
- エラーを即座に回避する3つの実装パターン(ヒープ移行、間接参照、分割)
- 今後同様のトラブルを予防するための設計指針
前提知識
- Swiftの基本文法(
struct、class、init、let/var) - プログラムのメモリ領域に「スタック」「ヒープ」があることは聞いたことがあるレベル
- Xcodeでブレークポイントを張れること
Swiftの値型が引き起こす「見えない壁」とは
Swiftは「値型を使おう」という思想が強く、Array、Dictionary、Stringすら全て値型です。
値型はコピー時にスタックに積まれるため、小さければ高速で安全です。しかし、以下のようなコードを書いたとします。
Swiftstruct 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がプロセスをキルするというワケです。
このエラーはfatalErrorやassertとは違い、Swiftランタイムが介入する余地がなく、デバッガも「ここで死にました」だけの情報を出すため、原因特定に時間がかかります。
構造体がクラッシュするまでのメカニズムと3つの回避戦略
1. 値型のコピーがスタックを圧迫する仕組み
Swiftは以下のタイミングで値のコピーを発生させます。
- 変数への代入
- 関数への引き渡し(
inoutでない限り) - クロージャキャプチャ(
@escapingでない限り)
各コピーはスタックフレーム上に逐次展開されます。
例えば、引数で巨大な構造体を受け取る関数が更に別の関数を呼び出すと、コピーが重ねられ、一瞬でスタックが破裂します。
2. ステップ:エラーの再現
PlaygroundやCLIプロジェクトで以下を実行すると即座に再現できます。
Swiftstruct 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)になります。
Swiftfinal class TerrainTile { var heightMap: [Float] ... }
メモリを共有したくない場合は、読み取り専用インタフェースを公開し、内部でcopy-on-writeを実装すれば、値型と同等のセマンティクスを保てます。
4. 回避策B:間接参照(indirect)を使う
Swift 5以降、enumとstructにindirectを付与できます。
これにより値はヒープに格納され、スタックにはポインタだけが残ります。
Swiftstruct WorldMap { indirect var tiles: [[TerrainTile]] }
ただし、indirectはプロパティ単位ではなくコンテナ全体に効くため、パフォーマンス検証が必須です。
5. 回避策C:遅延初期化&分割
iOSのようなメモリ制限の厳しい環境では、一度に全部を載せない設計が最適です。
Swiftstruct 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の最適化ポイント」について掘り下げます。
参考資料
- Swift Official Doc: Memory Safety
- Apple Forums: EXC_BAD_INSTRUCTION on large struct
- 『Swiftプログラミング 第3版』オライリージャパン
