ARM Cortex-MにおけるHardFault_Handlerの詳細な仕組みとデバッグ手法

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)

td>
ビット 名前 意味
16 UNDEFINSTR 未定義命令を実行
17 INVSTATE 状態遷移が不正(例:Thumbモードでの例外)
18 INVPC 戻り時のPC[0]が不正(Thumbではない)
19 NOCP 未有効化のコプロセッサを使用
24 UNALIGNED

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

分析プロセス:

  1. `CFSR=0x100` → `IBUSERR=1` かつ `PRECISERR=1` に対応
  2. `PRECISERR=1` → BFARが有効
  3. `BFAR=0x20008000` → エラーのアドレスがSRAMの末尾付近にある
  4. `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は、貴重な診断の機会である

それは、普段は決して見ることのできない光景——プログラムが最後の瞬間に何を経験したのか——をあなたに見せてくれます。

この分析手法をマスターすれば、あなたは単なるバグ修正者ではなく、灰の中から真実を再構築できる「組み込み探偵」になることができます。

次にチップが突然停止したら、リセットを急ぐ前に、まず尋ねてみてください:「どうして死んだの?」

そして、それが最後の言葉を言い終えるのを聞いてください。

タグ: ARM Cortex-M ハードフォルト マイコン デバッグ

5月18日 03:27 投稿