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 が異常な動作をする。
- 原因: TaskA の深い関数呼び出し
- 症状: 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 ワード」でも、 エラー処理経路に入った瞬間に溢れる、ということが起きる。
対策:
- エラー経路も含めてテストする(フォールトインジェクション)
- 静的解析ツールで最悪呼び出し深さを求める(GCC の
-fstack-usage、 あるいは商用のスタック解析ツール)- マージンを厚めに取る(30〜50 %)
-fstack-usage を使う
gcc -fstack-usage -c task.c
cat task.sutask.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 より検出力が高い。
どちらも「完全」ではない.
- 検出は切り替えのときだけ
- 末端 16 バイトを飛び越えてさらに深く書いた場合、パターンは残っているかもしれない
- ポインタバグによる暴走書き込みは検出できない
それでも、あるとないとでは大違いである。開発中は必ず
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 | 十分に長時間・全経路を実行する(エラー処理も含む) |
| 3 | uxTaskGetStackHighWaterMark() で余裕を測る |
| 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/IP | 2048〜4096 |
printfの問題は本当に深刻である.newlib の完全版
printfは、内部でmallocを呼び、 数キロバイトのスタックを使うことがある。対策:
newlib-nanoを使う(-specs=nano.specs)- 軽量
printf実装(mpaland/printfなど)に差し替える- ログ専用タスクを 1 つ作り、そこだけ大きなスタックを与える
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 Mark | 0xA5 の埋め草で測る。ワード単位 |
| HWM の限界 | 「まだ深い経路を通っていない」可能性がある |
-fstack-usage | 静的に各関数の使用量が分かる。dynamic は要注意 |
| 検出設定 | 開発中は configCHECK_FOR_STACK_OVERFLOW = 2 |
| フック | 最小限の記録+リセット。複雑なことをしない |
| MPU / PSPLIM | 溢れた瞬間に捕まる。最も正確 |
printf | 単独で 1〜2 KB。ログ専用タスクに集約する |
ここまでで第 IV 部は終わりである。 次章から第 V 部——「正しく動かす」ための知識に入る。 まずは、最も間違いが起きやすい境界——割り込みとの関係である。