FreeRTOS 17 · スタックとオーバーフロー — 最も多い「原因不明のクラッシュ」の正体

Chapter 17

スタックとオーバーフロー — 最も多い「原因不明のクラッシュ」の正体

この章がなぜ必要なのか——RTOS のバグで最も多いのがこれだから.

「たまに再起動する」「デバッグビルドでは動くのにリリースで落ちる」 「printf を足したら動かなくなった」—— これらの症状の相当部分がスタックオーバーフローである。

しかも症状が出るまでに時間がかかり、出る場所が原因の場所と無関係なので、 原因究明が極端に難しい。だから先に知っておく必要がある。

この章で使う既出の用語(定義は各リンク先). コンテキスト(01 章 6 節)、TCB(02 章 2 節)、スタック(02 章 2 節)、タスク(02 章 2 節)、ハードウェア(03 章 2 節)、移植層(03 章 2 節)、FreeRTOS(06 章 4 節)、RAM(06 章 4 節)、キュー(14 章 7 節)、割り込みハンドラ(15 章 1 節)

1. なぜ RTOS でスタックが問題になるのか

OS を使わないプログラムでは、スタックは1 本だけである。 リンカが「RAM の末尾から下向きに、残り全部」を割り当てるので、 たいていは十分な余裕がある。

RTOS では、タスクごとにスタックが要る。

RAM 全体(例: 64 KB)
┌────────────────────────────────┐
│ .data / .bss(グローバル変数)    │
├────────────────────────────────┤
│ FreeRTOS ヒープ                  │
│   ├ TaskA のスタック (1 KB)      │
│   ├ TaskB のスタック (1 KB)      │  ← ここが全部固定サイズ
│   ├ TaskC のスタック (512 B)     │
│   └ キュー・セマフォなど          │
├────────────────────────────────┤
│ 割り込み用スタック(MSP)         │
└────────────────────────────────┘

各タスクのスタックは固定サイズで、しかも小さい。 そして——溢れても、CPU は何も教えてくれない。 隣のメモリを黙って書き換えるだけである。

これが最悪の性質である.

TaskA のスタックが溢れると、TaskB の TCB やスタックを壊す。 すると、しばらく経ってから TaskB が異常な動作をする。

原因と症状が別のタスクに現れる。 デバッガで追っても、 「TaskB のここでポインタが壊れている」までしか分からない。

2. スタックには何が積まれるか

項目サイズの目安
コンテキスト退避Cortex-M4: 約 64 バイト、M4F(FPU 使用時): 約 200 バイト
関数の戻りアドレス4 バイト × 呼び出しの深さ
退避レジスタ関数 1 段あたり 8〜32 バイト
ローカル変数関数ごとに異なる。配列があると一気に増える
関数の引数(レジスタに載らない分)5 個目以降
割り込みのネストCortex-M はタスクスタックを使うので加算が必要

割り込みがタスクスタックを使う

これは Cortex-M で特に重要である。

通常時(タスク実行中):  PSP がタスクスタックを指す
割り込み発生:            ハードウェアが PSP のスタックに 8 レジスタを積む
                          → ★ タスクのスタックが消費される
割り込みハンドラ実行:     MSP に切り替わる(ハンドラのローカル変数は MSP 側)

つまり、割り込みハンドラが軽くても、割り込みが入るだけでタスクスタックが 32〜100 バイト減る。 しかも割り込みがネストすれば、その分だけ増える。

だからスタック計算に「割り込み分」を必ず足す.

\[ S_{\text{必要}} = S_{\text{最深呼出}} + S_{\text{コンテキスト}} + N_{\text{ネスト}} \times S_{\text{割込}} + \text{マージン} \]

割り込みの最大ネスト段数を把握していないなら、保守的に見積もるしかない。 割り込み優先度の段数分だけネストしうる、と考えるのが安全側である。

3. スタック使用量を測る — High Water Mark

FreeRTOS は、タスク生成時にスタック全体を特定の値で埋める。

#if ( ( configCHECK_FOR_STACK_OVERFLOW > 1 ) || ( configUSE_TRACE_FACILITY == 1 ) \
      || ( INCLUDE_uxTaskGetStackHighWaterMark == 1 ) )
{
    ( void ) memset( pxStack, ( int ) tskSTACK_FILL_BYTE,
                     ( size_t ) ulStackDepth * sizeof( StackType_t ) );
}
#endif
#define tskSTACK_FILL_BYTE  ( 0xa5U )

まだ 0xA5 のままの領域=一度も使われていない領域である。

pxStack ──→ ┌────────────────┐
            │ A5 A5 A5 A5    │  ← 未使用(ここが High Water Mark = 余裕)
            │ A5 A5 A5 A5    │
            ├────────────────┤ ← ここまで使ったことがある
            │ (使用済み)      │
            │      ⋮         │
            └────────────────┘
UBaseType_t uxTaskGetStackHighWaterMark( TaskHandle_t xTask );

戻り値は「一度も使われていない残りワード数」である。 0 に近いほど危険。バイト数ではなくワード数なので、32 bit CPU なら 4 倍する。

void vMonitorTask( void *pv )
{
    for (;;) {
        UBaseType_t hwm = uxTaskGetStackHighWaterMark( xWorkerTask );
        printf( "worker stack margin: %u words (%u bytes)\n",
                (unsigned)hwm, (unsigned)(hwm * sizeof(StackType_t)) );
        vTaskDelay( pdMS_TO_TICKS( 5000 ) );
    }
}

スタック使用量を見る

関数の深さやローカル変数を変えて、 スタック使用量と High Water Mark がどう変わるかを確かめられる。

High Water Mark には限界がある.

「まだ深い経路を通っていないだけ」かもしれない。

if( rare_condition ) {
    deep_error_handling();      /* ← 普段は通らない。通ると 500 バイト使う */
}

通常運転で測って「余裕 300 ワード」でも、 エラー処理経路に入った瞬間に溢れる、ということが起きる。

対策:

-fstack-usage を使う

gcc -fstack-usage -c task.c
cat task.su
task.c:42:6:vSensorTask    96      static
task.c:58:5:process_data   248     static
task.c:71:5:format_output  1064    dynamic

各関数のスタック使用量が分かる。 dynamic は可変長配列や alloca を使っている印であり、組み込みでは避けるべきである。

呼び出しグラフと組み合わせると、最悪深さが計算できる. cflow や商用ツールで呼び出しグラフを作り、 各経路の .su の値を足し合わせて最大値を取る。 再帰があると計算できないので、組み込みでは再帰も避ける。

4. オーバーフロー検出

#define configCHECK_FOR_STACK_OVERFLOW  2
値方法検出できるものコスト
0検出しない—なし
1切り替え時にスタックポインタを確認SP が範囲外に出た場合小
2方法 1 + 末尾 20 バイトのパターン確認深く踏み込んだ形跡中

方法 1 の実装

#define taskCHECK_FOR_STACK_OVERFLOW()                                       \
{                                                                             \
    if( pxCurrentTCB->pxTopOfStack <= pxCurrentTCB->pxStack ) {               \
        vApplicationStackOverflowHook( ( TaskHandle_t ) pxCurrentTCB,         \
                                       pxCurrentTCB->pcTaskName );            \
    }                                                                         \
}

コンテキストスイッチのときにしか確認しないので、 「切り替えの合間に深く踏み込んで、戻ってきた」場合は見逃す。

方法 2 の実装

#define taskCHECK_FOR_STACK_OVERFLOW()                                        \
{                                                                              \
    const uint32_t * const pulStack = ( uint32_t * ) pxCurrentTCB->pxStack;    \
    const uint32_t ulCheckValue = ( uint32_t ) 0xa5a5a5a5;                     \
                                                                               \
    if( ( pulStack[ 0 ] != ulCheckValue ) ||                                   \
        ( pulStack[ 1 ] != ulCheckValue ) ||                                   \
        ( pulStack[ 2 ] != ulCheckValue ) ||                                   \
        ( pulStack[ 3 ] != ulCheckValue ) )                                    \
    {                                                                          \
        vApplicationStackOverflowHook( ( TaskHandle_t ) pxCurrentTCB,          \
                                       pxCurrentTCB->pcTaskName );             \
    }                                                                          \
}

スタックの末端 16 バイトが 0xA5 のままかを確認する。 一時的に深く踏み込んだ形跡も残るので、方法 1 より検出力が高い。

どちらも「完全」ではない.

それでも、あるとないとでは大違いである。開発中は必ず 2 にすること。 リリースでも、コストが許すなら 1 は残しておく価値がある。

フックの書き方

void vApplicationStackOverflowHook( TaskHandle_t xTask, char *pcTaskName )
{
    /* ★ ここでは、そのタスクのスタックが既に壊れている前提で書く */
    taskDISABLE_INTERRUPTS();

    /* 不揮発メモリにタスク名を記録してからリセットするのが実用的 */
    save_crash_info( pcTaskName );
    system_reset();
}

フックの中で複雑なことをしてはいけない.

スタックが壊れているので、関数呼び出し自体が危険である。 printf を呼ぶと、その中でさらにスタックを使って、二次被害を起こす。

最小限の記録をして、リセットするのが現実的である。 どのタスクで起きたかさえ分かれば、原因の絞り込みには十分である。

5. MPU によるハードウェア検出

Cortex-M3 以上には MPU (Memory Protection Unit) があり、 「このアドレス範囲への書き込みを禁止する」設定ができる。

スタックの末端に MPU 領域を設定すると、 オーバーフローした瞬間に MemManage フォールトが発生する。

方法検出タイミング精度
configCHECK_FOR_STACK_OVERFLOW切り替えのとき事後(既に壊れている)
MPU溢れた瞬間正確(PC が原因の場所を指す)

FreeRTOS-MPU(portable/GCC/ARM_CM4_MPU など)を使うと、 タスクごとの MPU 設定が自動化される。

MPU 版は設定が複雑になる代償がある.

開発中に一時的に使う、あるいは 安全性が要求される製品で本格導入する、のどちらかである。 Armv8-M(Cortex-M23 / M33 など)にはスタックリミットレジスタ (PSPLIM) があり、 MPU より簡単に同じ検出ができる。移植層が対応していれば有効にする価値が高い。

6. スタックサイズの決め方 — 実務的な手順

手順内容
1多めに割り当てる(例: 2048 バイト)。まず動かす
2十分に長時間・全経路を実行する(エラー処理も含む)
3uxTaskGetStackHighWaterMark() で余裕を測る
4使用量 × 1.5 + 割り込み分まで削る
5もう一度長時間動かして確認する
/* 手順 3 の測定用タスク */
void vStackMonitor( void *pv )
{
    TaskStatus_t *pxStatus;
    UBaseType_t n;

    for (;;) {
        vTaskDelay( pdMS_TO_TICKS( 10000 ) );

        n = uxTaskGetNumberOfTasks();
        pxStatus = pvPortMalloc( n * sizeof( TaskStatus_t ) );
        if( pxStatus != NULL ) {
            n = uxTaskGetSystemState( pxStatus, n, NULL );
            for( UBaseType_t i = 0; i < n; i++ ) {
                printf( "%-16s margin=%4u words\n",
                        pxStatus[i].pcTaskName,
                        (unsigned)pxStatus[i].usStackHighWaterMark );
            }
            vPortFree( pxStatus );
        }
    }
}

目安の値

タスクの内容スタックサイズの目安(バイト)
LED 点滅など極小256〜512
センサ読み取り・単純な制御512〜1024
通信プロトコル処理1024〜2048
printf を使う+1024〜2048
sprintf("%f") を使う+2048 以上
ファイルシステム・TCP/IP2048〜4096

printf の問題は本当に深刻である.

newlib の完全版 printf は、内部で malloc を呼び、 数キロバイトのスタックを使うことがある。

対策:

3 つ目が最も実用的である。他のタスクは 「ログ文字列をキューに送るだけ」にすれば、スタックを小さく保てる。

7. 事故を減らす習慣

習慣効果
大きな配列をローカルに置かないstatic かヒープへ
再帰を使わない深さが計算できなくなる
可変長配列 (VLA) と alloca を使わない同上
深い関数呼び出しを避ける特にライブラリ関数のネスト
printf をタスクに直接書かないログタスクに集約
割り込みハンドラを浅く保つタスクスタックを消費する
configASSERT を有効にする関連するミスが捕まる
/* 悪い例 */
void vTask( void *pv ) {
    uint8_t buf[ 2048 ];        /* ← スタックに 2 KB */
    ...
}

/* 良い例 */
static uint8_t buf[ 2048 ];      /* ← .bss に置く */
void vTask( void *pv ) {
    ...
}

static にすると再入不可になる点に注意. 同じ関数から複数のタスクを作る場合(02 章)、static バッファは共有される。 その場合はミューテックスで守るか、タスクごとにバッファを渡す設計にすること。

8. この章のまとめ

ポイント内容
なぜ問題かタスクごとに固定サイズで、しかも溢れても検出されない
最悪の性質原因と症状が別のタスクに現れる
割り込みCortex-M ではタスクスタックを消費する。ネスト分を加算
High Water Mark0xA5 の埋め草で測る。ワード単位
HWM の限界「まだ深い経路を通っていない」可能性がある
-fstack-usage静的に各関数の使用量が分かる。dynamic は要注意
検出設定開発中は configCHECK_FOR_STACK_OVERFLOW = 2
フック最小限の記録+リセット。複雑なことをしない
MPU / PSPLIM溢れた瞬間に捕まる。最も正確
printf単独で 1〜2 KB。ログ専用タスクに集約する

ここまでで第 IV 部は終わりである。 次章から第 V 部——「正しく動かす」ための知識に入る。 まずは、最も間違いが起きやすい境界——割り込みとの関係である。