HardFault異常の深屌:クラッシュ現場からプログラムの「死亡瞬間」を再現する
現場でデバイスが不可解にリセットし、シリアルポートにログが出力されず、JTAGで接続できない、再現できないという経験はありませんか?コードを隅々まで確認しても明確なエラーは見つからない——そんなある日、逆アセンブルを調べてみると、見た目は無害なポインタ操作が一行見つかりました。それが、静かにシステム全体の安定性を破壊していたのです。
このような「幽霊のような」障害は、往々にして、静かだが致命的な存在、HardFault_Handlerを指します。
ARM Cortex-Mの世界では、これは単なる割り込みではなく、システムが最後に残す「遺言記録装置」です。一度トリガーされると、CPUは回復不能な低レベルのクラッシュに遭遇したことを意味します。しかし、その反面、最も完全な事故現場の証拠を保持しています。
本稿では、組み込み開発で最も重要かつ見過ごされがちなメカニズム、「HardFaultを通じて、『死に証拠がない』システムクラッシュ問題を正確に特定する方法」を解説します。
なぜHardFaultはシステムクラッシュの最終防衛線なのか?
Cortex-Mアーキテクチャにおける例外処理には階層があります。これをピラミッド構造に例えることができます。
- 最下層:メモリ越界(MemManage)、バスアクセス失敗(BusFault)、不正命令(UsageFault)など、様々な具体的なエラー。
- 中間層:マスク可能なシステムコールと割り込み(SVC、PendSV、SysTick)。
- そして頂点に位置するのが、HardFault——最も優先度が高く、無効にできず、必ず応答するもの。
その特別な点は、他の例外がキャッチできなかった重大なエラーは、すべて「昇格」してHardFaultになります。
例を挙げます。
アドレス`0x2000FFF0`を読み出そうとしますが、その領域には物理メモリがありません。
通常であればBusFaultがトリガーされるはずです。
しかし、プロジェクトでBusFault例外の有効化がされていない場合(デフォルト設定でよくある)、このエラーはBusFault Handlerに入らず、直接HardFaultに「上昇」します。
言い換えれば、HardFaultは必ずしも「硬いエラー」ではなく、あくまで「誰も受け取らないエラー」である可能性があります。
これが、多くの開発者がHardFaultに遭遇すると混乱する理由です——まるで巨大なゴミ箱のようで、何でもそこに入れられてしまいます。
しかし、心配しないでください。ゴミ箱の中をどう見るかを知っていれば、真犯人を見つけることができます。
プロセッサが「もう限界だ」と言うとき:HardFaultはどのようにトリガーされるのか?
まず、いくつかの典型的な「自殺行為」を見てみましょう。
// 無効なアドレスへの書き込み → バスエラー
*(int*)0xFFFFFFFF = 0;
// コード領域ではない場所へジャンプ → 不正命令
((void(*)())0x1000)();
// MSPを不正なアドレスに設定 → プッシュ失敗
__set_MSP(0x1FFFF000);
これらの操作は、あなたに「間違っている」と優しく知らせるのではなく、CPUを例外フローに直接突入させます。
トリガー条件一覧
| エラーの種類 | 例 |
|---|---|
| アラインされていない命令の実行(一部アーキテクチャのみ) | PCが奇数アドレスを指している |
| 無効なメモリアドレスへのアクセス | NULLポインタのデリファレンスやSRAMの範囲外 |
| スタックオーバーフローによるプッシュ失敗 | MSP/PSPが不正な領域を指している |
| 割り込み戻り時のスタック破損 | 例外戻りフレームが上書きされている |
| 他のFaultが無効な場合に対応するエラーが発生 | UsageFaultが無効化されているにもかかわらず、未定義命令を実行 |
| 多重例外のネストが制御不能に | 例外処理中に再びエラーが発生 |
その中で最も見えにくいのは第5項です:MemManage/BUS/UsageFaultが無効化されている場合、対応するエラーは消えず、HardFaultに「上昇」します。
つまり、あなたのコードに`MemManage_Handler`が書かれていても、初期化で対応する有効化ビットが設定されていなければ、依然としてHardFaultに入ります。
例外の入り口:ハードウェアが自動保存する「臨終の遺書」
HardFaultがトリガーされると、CPUは現在のタスクを直ちに停止し、重要なアクションを開始します:自動プッシュ(Stacking)。
これは、デバッグプロセス全体の核心的前提条件です——なぜなら、次に読むすべての情報が、この瞬間のスナップショットから来るからです。
どのレジスタが自動保存されるのか?
プロセッサは、現在使用しているスタック(MSPまたはPSP)に以下の8つのレジスタを固定順序でプッシュします。
| オフセット | レジスタ | 説明 |
|---|---|---|
| +0 | R0 | パラメータ/一時データ |
| +4 | R1 | 同上 |
| +8 | R2 | 同上 |
| +12 | R3 | 同上 |
| +16 | R12 | 汎用用途 |
| +20 | LR | リンクレジスタ(関数呼び出しの戻りアドレス) |
| +24 | PC | エラー発生命令のアドレス! |
| +28 | xPSR | プログラムステータスレジスタ(フラグとモード情報を含む) |
この32バイトのデータは「例外前のコンテキスト」と呼ばれ、航空機事故前のブラックボックス記録のようなものです。
重要なのは:PCの値が、クラッシュを引き起こした命令のアドレスであること。それを見つけることができれば、どのC言語の行で問題が発生したかを逆算できます。
同時に、LRは呼び出しスタックを再構築する助けとなり、どの関数からここにたどり着いたかを知ることができます。
「遺書」を汚さないために:安全なHardFault Handlerの作成
最大の落とし穴は何でしょうか?
高度な言語で多くのプリントロジックを書き、結果として自身がメモリアクセスを引き起こし、二次クラッシュを引き起こし、最終的にロックアップ(Lockup)状態に陥ることです。
覚えておいてください:HardFault内でコードを1行追加するごとに、リスクは倍になります。
したがって、ベストプラクティスは:極めてシンプル + アセンブリに優しい + スタックを破壊しない。
推奨実装方法(純粋なC、最小限の侵入)
void ハードフォルトハンドラ(void) {
// 割り込みを即時無効化し、状態を固定
__disable_irq();
// 現在のMSP(メインスタックポインタ)を取得
uint32_t *スタックポインタ;
__asm volatile ("MRS %0, MSP" : "=r"(スタックポインタ));
// 例外発生時のコンテキストを抽出
volatile uint32_t レジスタ0 = スタックポインタ[0];
volatile uint32_t レジスタ1 = スタックポインタ[1];
volatile uint32_t レジスタ2 = スタックポインタ[2];
volatile uint32_t レジスタ3 = スタックポインタ[3];
volatile uint32_t レジスタ12 = スタックポインタ[4];
volatile uint32_t リンクレジスタ = スタックポインタ[5];
volatile uint32_t プログラムカウンタ = スタックポインタ[6]; // ← キー!エラー発生命令のアドレス
volatile uint32_t プログラムステータスレジスタ = スタックポインタ[7];
// フォルトステータスレジスタの読み出し
volatile uint32_t cfsr = SCB->CFSR;
volatile uint32_t hfsr = SCB->HFSR;
volatile uint32_t bfar = SCB->BFAR;
volatile uint32_t mmfar = SCB->MMFAR;
// 【オプション】UARTやLEDで診断コードを出力
記録ハードフォルト情報(プログラムカウンタ, リンクレジスタ, cfsr, hfsr, bfar);
while (1) {
// ここで停止し、ウォッチドッグリセットか手動デバッグを待機
}
}
⚠️ 注意:ここで複雑な関数(sprintf、mallocなど)を呼び出したり、局所変数を大量に使用したりしないでください。コンパイラが余分なプッシュ操作を生成し、スタックを破壊する可能性があります。
障害レジスタの解読:真の黒幕は誰か?
HardFaultと名前が付いていますが、真の原因は以下のレジスタの中に隠されている可能性があります。
1. `SCB->CFSR` —— 設定可能な障害ステータスレジスタ(最も重要)
これは3つの部分で構成されています。
MemManage Fault Status (ビット0–7)
| ビット | 名前 | 意味 |
|---|---|---|
| 0 | IACCVIOL | 命令アクセス違反 |
| 1 | DACCVIOL | データアクセス違反 |
| 3 | MSTKERR | プッシュ時にメモリエラーが発生 |
| 4 | MUNSTKERR | ポップ時にメモリエラーが発生 |
👉 `DACCVIOL=1`がセットされている場合、通常、不正なメモリ領域にアクセスしたと確実に判断できます。
BusFault Status (ビット8–15)
| ビット | 名前 | 意味 |
|---|---|---|
| 8 | IBUSERR | 命令バスエラー |
| 9 | PRECISERR | 正確なバスエラー(キー!) |
| 10 | IMPRECISERR | 不正確なバスエラー(遅れて報告される可能性あり) |
| 11 | STKERR | プッシュ時のバスエラー |
| 12 | UNSTKERR | ポップ時のバスエラー |
👉 `PRECISERR=1`は、エラーが非常に明確であり、BFAR内のアドレスが有効であることを示します!
UsageFault Status (ビット16–23)
| ビット | 名前 | 意味 |
|---|---|---|
| 16 | UNDEFINSTR | 未定義命令を実行 |
| 17 | INVSTATE | 状態遷移が不正(例:Thumbモードでの例外) |
| 18 | INVPC | 戻り時のPC[0]が不正(Thumbではない) |
| 19 | NOCP | 未有効化のコプロセッサを使用 |
| 24 | UNALIGNED | td>
2. `SCB->HFSR` —— HardFaultステータスレジスタ
| ビット | 名前 | 意味 |
|---|---|---|
| 30 | FORCED | 1は、他のFaultから「上昇」してきたことを示す(非常に重要!) |
| 1 | VECTTBL | 1はベクタテーブルアクセス失敗を示す(稀) |
👉 FORCED=1は黄金の手がかりです:元々MemManage/BUS/UsageFaultが処理すべきエラーが、無効化されていたために「HardFaultに昇格」したことを示します。
アドレス記録レジスタ(キャプチャ機能が有効化されている場合)
| レジスタ | 条件 | 役割 |
|---|---|---|
| `SCB->BFAR` | BFARVALID=1(CFSR[9]から) | BusFaultを引き起こしたアドレスを記録 |
| `SCB->MMFAR` | MMARVALID=1(CFSR[7]から) | メモリ管理エラーのアドレスを記録 |
💡 小技:これらのレジスタはデフォルトで有効化されていません!初期化で積極的に有効化する必要があります。
// アドレスキャプチャ機能を有効化
SCB->CCR |= SCB_CCR_STKALIGN_Msk; // スタックを8バイト境界に強制
SCB->SHCSR |=
SCB_SHCSR_MEMFAULTENA_Msk | // MemManage Faultを有効化
SCB_SHCSR_BUSFAULTENA_Msk; // BusFaultを有効化
そうしないと、不正なアドレスにアクセスしたとしても、`BFAR`は空の可能性があります。
実践ケース:HardFaultログからDMA競合を特定する
ある産業コントローラが頻繁にリセットし、現場にログがなく、遠隔保守のコストが非常に高かった。
診断コードを接続したところ、HardFaultで以下の情報をキャプチャしました。
PC = 0x080043A8
LR = 0x08003F20
CFSR = 0x00000100 → ビット8 (IBUSERR) とビット9 (PRECISERR) がセット
BFAR = 0x20008000
分析プロセス:
- `CFSR=0x100` → `IBUSERR=1` かつ `PRECISERR=1` に対応
- `PRECISERR=1` → BFARが有効
- `BFAR=0x20008000` → エラーのアドレスがSRAMの末尾付近にある
- `PC=0x080043A8` → `.map`ファイルや逆アセンブルを確認
ツールを使用して解析:
arm-none-eabi-addr2line -e firmware.elf -a 0x080043A8
出力:
/src/dma_driver.c:127
コード行にたどり着きました。
// DMA転送完了後にチャネルを即時閉じるのを忘れる
while (DMA_GetStatus() == BUSY);
*buffer_ptr = new_data; // ← この時点でDMAがまだ動作中で、バスを競合!
真相大白:DMAとCPUが同じメモリブロックに同時にアクセスし、バス競合を引き起こし、正確なバスエラーを発生させた。
修正案:DMA完了割り込みでチャネルを閉じ、ミューテックス保護を追加する。
本番環境におけるベストプラクティスの推奨
✅ 必須項目
- CFSR/BFAR/MMFARキャプチャ機能を常に有効化する
- HardFaultでPC/LR/CFSR/HFSRを抽出し、ログを固定する
- addr2lineツールチェーンと組み合わせてソースレベルの位置特定を実現する
- LEDコードまたは低消費電力シリアルポートで簡易障害コードを出力する
❌ 禁止項目
- HardFaultで「実行を復旧」を試みる(完全なフォールトトレラント設計がある場合を除く)
- 動的メモリアロケーションや複雑なライブラリ関数を呼び出す
- 局所変数を大量に使用し、余分なプッシュ操作を引き起こす
🛠 推奨される強化機能
void 記録ハードフォルト情報(uint32_t pc, uint32_t lr, uint32_t cfsr, ...) {
// 方法1:DMA+UARTを介した非同期ログ送信(スタックに影響なし)
// 方法2:バックアップSRAMやFlashのログ領域に書き込む
// 方法3:LEDの点滅リズムにコード化(例:赤ランプ3回点滅→BusFault)
}
製品出荷前に「クラッシュ指紋」データベースを事前に埋め込むことで、自動化された障害分類を実現することもできます。
最後に:HardFaultは終点ではなく、出発点
多くの人がHardFaultを「システムが死んだ」という印と見なし、すぐにリセットしてしまう。
しかし、真の専門家は知っています:HardFaultは、貴重な診断の機会である。
それは、普段は決して見ることのできない光景——プログラムが最後の瞬間に何を経験したのか——をあなたに見せてくれます。
この分析手法をマスターすれば、あなたは単なるバグ修正者ではなく、灰の中から真実を再構築できる「組み込み探偵」になることができます。
次にチップが突然停止したら、リセットを急ぐ前に、まず尋ねてみてください:「どうして死んだの?」
そして、それが最後の言葉を言い終えるのを聞いてください。