再現しないバグ(不具合)の追い方
テストでは一度も起きないのに、量産後の現場でだけ発生する。ログを仕込んだ途端に症状が消える(いわゆるハイゼンバグ:観測しようとすると症状が変わる/消える現象)。組み込み開発では、こうした再現しないバグに悩まされる場面が少なくありません。
この記事では、なぜ組み込み特有の再現のしにくさが生まれるのかを整理したうえで、原因をカテゴリ別に切り分ける観点と、再現・観測・保全という実務の進め方を説明します。
なぜ組み込みのバグは「再現しない」のか
再現しないバグの多くは、実行タイミングに依存する非決定性を抱えています。割り込みやタスクの実行タイミングは毎回同じとは限らないため、競合状態(レースコンディション)は特定の順序が揃ったときにしか表面化しない現象です。
さらに、ブレークポイントで実行を止めたり、printfを追加したりすると実行時間の配分が変わり、前述のハイゼンバグが起きます。温度や電源電圧、外来ノイズといった環境要因が絡む点も、机上のソフトウェアとは違う組み込み特有の事情です。
つまり、バグが再現しないのは、発生条件が時間や環境に依存しており条件が揃っていないためだと捉え直すことが出発点になります。
原因カテゴリ別に切り分ける
やみくもに原因を調べるのではなく、代表的な3つのカテゴリのうちどれに近いかを先に見立てると、調査の範囲を絞れます。
競合状態(レースコンディション)
割り込みハンドラとメイン処理が同じ変数を読み書きしている箇所は、真っ先に疑うべき候補です。カウンタのインクリメントや複数バイトの構造体更新は、一見すると1行のコードに見えます。しかし、処理系や対象データ型によっては、機械語レベルで読み出し・加工・書き戻しの複数ステップに分割して実行されます。これは、リード・モディファイ・ライト(RMW:Read-Modify-Write)と呼ばれる処理です。その途中に割り込みが入ると、更新の一部が失われてしまいます。
volatile指定が適切ではないため、コンパイラの最適化により変数の再読み込みが省かれるケースもあります。ただし、volatileは「毎回メモリから読み書きさせたい(最適化で消させない)」ことをコンパイラに伝える指定であり、複数バイト更新の原子性やメモリアクセスの順序(他のタスク/割り込み/他コアから観測される順序)、共有データへの同時アクセス(データ競合)の回避は保証しません。これらは、排他制御やメモリバリアで別途設計する必要があります。
そのため、トラブルの切り分けとしては、疑わしい共有アクセスをクリティカルセクション(排他制御区間。組み込みでは割り込み禁止区間が典型的)で保護し、症状が止まるかを確認する方法が有効です。なお、割り込み禁止区間は必要最小限にとどめます。
メモリ破壊
配列の境界越えやポインタの誤用によるメモリ破壊は、壊した場所と症状が出る場所が離れているため、原因の特定が難しくなります。壊されたデータが実際に参照されるまで症状が現れないため、無関係に見える箇所でクラッシュや誤動作が起きます。
検出には、次のような仕掛けが役立ちます。
- スタックやバッファの末尾にガード値を置き、周期的に書き換わりを検査する
- 未使用領域を特定のパターンで埋めておき、侵食の痕跡を可視化する
- メモリ保護ユニット(MPU:Memory Protection Unit)を搭載するマイコンでは、スタック、重要な制御領域、未使用領域などを保護し、不正アクセスを例外として捕捉することを検討する(ただし、保護可能な領域サイズやアラインメントにはCPU依存の制約がある。例外発生時は、プログラムカウンタ、スタックポインタ、障害ステータスレジスタ、アクセス先アドレスなどを可能な範囲で記録する)
メモリ破壊が発生した時点を捉えられれば、症状が現れた箇所から原因を遡って調査する工数を大幅に減らせます。
初期化不備(未初期化・初期化順序)
未初期化変数の値を読み出すと不定値となり、偶然残っていた値に依存して予期せぬ動作を引き起こすことがあります。
電源投入直後のRAM(Random Access Memory)の内容やビルドによるメモリ配置の違いで挙動が変わるため、コードを1行足しただけで発生したり、消えたりします。モジュール間の初期化順序への暗黙の依存も同じ性質です。
原因の切り分けでは、初期化順序を明示的に固定したうえで、静的解析ツール、コンパイラ警告、コードレビューを組み合わせ、未初期化変数の読み出し経路を洗い出す進め方が有効です。
再現・観測・保全の実務
原因の見立てと並行して、発生の証拠を集める体制を整えます。
再現性を上げる
低頻度のバグを待つのではなく、発生確率を意図的に引き上げます。まず、入力や接続機器などの影響因子を系統的に減らし、発生に必要な最小条件を特定する手順が確実です。そのうえで、通信頻度を上げる、CPU負荷を高める、温度条件を変化させるといった形で条件を強めます。たとえば、数日に1回の事象の発生間隔を数分に1回まで縮められれば、対策の効果検証も現実的な時間で実施できます。
止めずに痕跡を残す観測
タイミング起因のバグには、実行を止めるデバッグ手法では原因にたどり着けないことが少なくありません。printfのような重い出力もタイミングを変えてしまうため、観測の侵襲を抑える工夫が要ります。実務では、イベントIDと時刻など必要最小限の情報を内蔵RAM上のリングバッファへ書き込む軽量ログが有効です。
ただし、リングバッファへ複数のタスクや割り込みハンドラから書き込む場合は、ログ機構自体の競合に注意が必要です。単一書き込み元・単一読み出し元として設計する、書き込みを短いクリティカルセクションで保護する、原子操作を用いるなど、実行コンテキストに応じた同期方式を選択します。マルチコア環境では、データ本体を書き込んだ後に書き込み位置を公開するためのメモリ順序も考慮します。
書き込み処理を軽量に設計すれば挙動への影響を抑えられ、直近の履歴を常に保持できます。汎用入出力(GPIO:General Purpose Input/Output)のトグルをオシロスコープで観測する方法も、実行を止めずに処理の順序や周期の揺らぎを可視化するのに適したアプローチです。
発生時の状態を保全する
発生の瞬間を逃さないために、異常検知と記録の保全を仕込んでおくことが重要です。ウォッチドッグタイマ(WDT:Watchdog Timer)に早期警告割り込み(Early Warning Interrupt。ベンダーによりEarly Wakeup Interruptなどと呼ばれる場合もあります)を利用できる場合は、リセットまでの限られた時間で、障害情報をバックアップRAMや保持RAMなどへ退避できることがあります。不揮発性メモリへ直接書き込む場合は、書き込み時間、電源断、再リセット、書き換え寿命を考慮し、最小限の情報に絞るとともに、巡回冗長検査(CRC:Cyclic Redundancy Check)や世代管理で記録の完全性を確認できるようにします。退避した内容は、再起動後に回収できるよう整えます。
あわせて、想定外の状態を検出するアサーションや不変条件チェックをコードに埋め込んでおくと、破綻の兆候を原因に近い地点で検知してログに残せます。症状が出てから調べるのではなく、発生した瞬間に証拠が残る仕組みをあらかじめ組み込んでおくことが、再現しないバグの調査を効率化するポイントです。


