スタックオーバーフローの検出

「数日に一度だけ落ちる」「触っていない変数がいつの間にか壊れている」。組み込みで最も厄介なバグの代表が、スタックオーバーフローです。本記事では、スタックサイズの設計(既存記事「組込みのスタック設計入門」)の続編として、スタックオーバーフローを実行時に検出する4つの手法を解説します。RTOSに固有の機能については、代表的なRTOSの一つであるFreeRTOSを例に説明します。本文中のFreeRTOSやCortex-Mは説明のための例示であり、特定ベンダ・製品の採用を推奨する意図はありません。なお、FreeRTOSはMITライセンスで提供されるオープンソースのRTOSです。

何が起きているのか:壊す場所と発症する場所が違う

スタックは関数呼び出しや割り込みのたびに伸び、確保した領域を超えると、隣に配置されたメモリ(グローバル変数や他タスクのスタック)を上書きします。厄介なのは、上書きの瞬間には何も起きず、壊された側のデータが使われたときに初めて異常が出ることです。症状と原因の場所が離れているため、現象から原因へさかのぼる手がかりとして「検出の仕掛け」を事前に入れておくことが重要になります。

手法1:ハイウォーターマーク(計測による使用量の可視化)

タスク起動前にスタック領域全体を既知のパターン値で塗りつぶしておき、あとから「パターンが残っている量」を調べれば、これまでの最大使用時にどれだけ余裕があったかがわかります。これがハイウォーターマーク(最高水位線)方式です。FreeRTOSには標準でこの仕組みがあり、uxTaskGetStackHighWaterMark() は、タスク開始以降に観測された残りスタック量(空き)の最小値をワード単位で返します(32ビットマイコンなら1ワード=4バイトです)。利用にはINCLUDE_uxTaskGetStackHighWaterMarkの有効化が必要です。0に近いほど危険水域です。開発中は各タスクの水位を周期的にログとして出力し、最悪条件(最深の関数ネスト+割り込み)を通したうえで余裕を確認します。

手法2:FreeRTOSのスタックオーバーフローチェック機能(OS機能による検出)

FreeRTOSは、FreeRTOSConfig.hの configCHECK_FOR_STACK_OVERFLOW で2方式の実行時チェックを提供しています。値を1にするとチェック方式Aのみ、2にするとチェック方式A・Bの両方が有効になります。

  • チェック方式A(公式ドキュメントの Method 1 に相当):コンテキストスイッチ時に、スタックポインタが有効範囲内に収まっているかを確認する。
  • チェック方式B(同 Method 2 に相当):チェック方式Aに加えて、スタックの端部に置いた既知バイト列が破壊されていないかを確認する(より堅牢)。

違反を検出すると vApplicationStackOverflowHook() が呼ばれるので、ここでタスク名を保存して安全側へ停止させます。注意点は2つです。どちらのチェック方式もコンテキストスイッチがわずかに重くなること。そして、チェックはコンテキストスイッチ時などのタイミングでおこなわれるため、コンテキストスイッチの間に発生した破壊を常に捕捉できるとは限らないことです。それでも費用対効果は非常に高く(設定1行とフック実装で導入でき、再現性の低いクラッシュの手掛かりを得やすい)、開発中はチェック方式Bを常時有効にしておく価値があります。

/* FreeRTOSConfig.h */
#define configCHECK_FOR_STACK_OVERFLOW  2   /* チェック方式Bを有効化(方式Aの検査も含まれる) */
/* 検出時に呼ばれるフック */
void vApplicationStackOverflowHook(TaskHandle_t xTask,
                                   char *pcTaskName)
{
    (void)xTask;
    log_fatal("stack overflow: %s", pcTaskName);
    system_safe_stop();    /* 出力を安全状態にして停止 */
}

手法3:ベアメタルならカナリアを置く(番兵によるソフトウェア検出)

※この記事では、一般的なMCUと同様にスタックは高位アドレスから低位アドレスへ成長(下降)する前提で説明します(逆の場合は上限・下限を読み替えてください)。

RTOSなしの構成でも、スタックの下限に番兵(カナリア)値を書いておき、メインループで周期的に確認するだけで多くの破壊を捕捉できます。このとき、リンカスクリプトが定義する _stack_limit のようなシンボルはアドレスを表すだけで、書き込み可能な実体ではありません。そこへ代入するとリンクエラーや意図しないアドレスへの書き込みになるため、番兵用に1ワード以上の実体を持つセクション(例:.stack_guard)をスタック領域の直前(下限側)に配置するようリンカスクリプトで確保し、次のように使います。

#include <stdbool.h>
#define STACK_CANARY  0xDEADBEEFu
extern uint32_t stack_guard[1];   /* .stack_guard に確保した番兵の実体 */
void stack_guard_init(void) { stack_guard[0] = STACK_CANARY; }
bool stack_guard_ok(void)   { return (stack_guard[0] == STACK_CANARY); }

stack_guardはvolatile指定し、必要に応じて__attribute__((used))などで最適化による除去を防止します。また、番兵を飛び越えて先まで破壊されるケースは捕捉できないため、複数ワードに広げる、チェック周期を短くするなどで補強します。

手法4:ハードウェアで即座に検知する(MPUなどによる保護・即時例外)

ソフトウェアのチェックは「事後の発見」ですが、ハードウェアを使うと破壊の瞬間に検知できます。代表はMPU(メモリ保護ユニット)で、スタックの下限の直後にアクセス禁止のガード領域を設定しておけば、はみ出した瞬間にフォルト例外が発生し、原因のコードがその場で特定できます。また、一部の新しいアーキテクチャ(例:Arm社のプロセッサアーキテクチャであるArmv8-M)のコアにはスタックの下限を監視するスタックリミット機構が追加されており、対応マイコンならより簡潔に同じ保護が実現できます。

運用の指針

  • サイズは「実測」で決める:最初は大きめに確保し、最悪条件を通したハイウォーターマークを見て削るのが安全な順序です。
  • 割り込み分を忘れない:アーキテクチャ(ポート)によっては、タスク実行中に発生する割り込みのネスト分もタスクのスタックを消費します。Cortex-M(同社のアーキテクチャ)では、割り込みハンドラ本体の実行にはメインスタック(MSP)が使われます。例外の発生時には、基本フレームとして8ワード(=32バイト。R0〜R3・R12・LR・PC・xPSR)がハードウェアによってタスクのスタック(PSP)へ積まれます。FPUコンテキストを保存する構成では26ワード(=104バイト)となる場合があり、条件により8バイトアライン調整のパディング(+1ワード)が入ることもあります。そのため、最大割り込みネスト段数と例外フレームサイズを掛けた分はタスクのスタックにも余裕が必要です(実装や設定によって条件は変わります)。使用するマイコンの仕様を確認し、最大ネスト段数を考慮します。
  • リリース後もフックは残す:検出時にログを不揮発領域へ残して再起動する設計にしておくと、市場での「まれに落ちる」の解析が一気に楽になります。

まとめ

スタックオーバーフローは「起きてから探す」のではなく「検出の仕掛けを先に入れる」バグです。開発中はハイウォーターマーク+チェック方式B、製品ではフック+ログ保存、余裕があればMPUガード。この三段構えで、最も厄介なバグの一つが管理可能な問題に変わります。

組み込みソフトの世界 トップへ戻る