Claude Media
Claude CodeでFreeRTOSのスタックオーバーフローとWDTリセットの原因を絞る

Claude CodeでFreeRTOSのスタックオーバーフローとWDTリセットの原因を絞る

ESP32のログに出るGuru Meditationやウォッチドッグ警告から、スタック溢れかCPU占有かを切り分ける手順です。addr2lineで関数名に戻し、uxTaskGetStackHighWaterMarkで裏取りします。

ESP32のファームウェアが勝手に再起動するとき、ログに出る症状は大きく3つに分かれます。タスクのスタック溢れ、CPUを長く握り続けるタスク、割り込みを長く止めるコードです。Claude Codeにログをそのまま貼ると「スタックを増やしてみましょう」と一律に返ってきがちなので、先にログの種類で原因を絞り、アドレスを関数名に戻してから読ませます。

この記事では、ESP-IDF(ESP32の公式開発フレームワーク)のパニックログを例に、スタック溢れとウォッチドッグ(WDT)リセットを切り分ける流れを扱います。バックトレースの変換にはaddr2line、スタックの余裕の裏取りにはuxTaskGetStackHighWaterMarkを使います。

ログの見た目で、原因の候補が先に決まる

最初に見るのは、パニックハンドラが括弧内に出す原因名です。ESP-IDFではCPU例外もウォッチドッグもスタック溢れも、すべて同じパニックハンドラを通ります。ログの文言から、疑う方向は次のように分かれます。

ログに出る文言疑う原因最初に調べる場所
Debug exception reason: Stack canary watchpoint triggered (タスク名)疑う原因そのタスクのスタック溢れ最初に調べる場所名指しされたタスクのスタックサイズと呼び出しの深さ
Stack smashing protect failure!疑う原因ローカル配列の範囲外書き込み最初に調べる場所バックトレース先頭の関数
Interrupt wdt timeout on CPU0疑う原因割り込み禁止区間・ISRが長い最初に調べる場所ISRとクリティカルセクション
Task watchdog got triggered疑う原因タスクがCPUを手放さない最初に調べる場所警告に出るタスク名と実行中のタスク
rst:0x10 (RTCWDT_RTC_RESET)疑う原因起動からapp_mainまでの停止最初に調べる場所ブートローダ設定と起動処理
IllegalInstruction疑う原因タスク関数からのreturnなど最初に調べる場所該当タスクの終了処理

表の最後の行は少し毛色が違いますが、FreeRTOSのタスク関数が値を返して終わるとIllegalInstructionになる、という落とし穴が公式に載っています。タスクを終えたいときはvTaskDelete()で自分自身を削除します。

Claude Codeに渡す前に、この表で「どの行か」だけでも決めておくと、調査の範囲が狭まります。

バックトレースを関数名に戻してから読ませる

パニック時のバックトレースは0x400e14ed:0x3ffb5030のようにPC:SPの対が並ぶだけの行です。PCはプログラムカウンタ、SPはスタックポインタを指します。モデルにこの16進数の列を読ませても、関数名は推測にしかなりません。先にELFファイルのシンボルで関数名と行番号に戻します。

idf.py monitor(IDF Monitor)で見ている場合、この変換は自動です。実行可能コードを指すアドレスが出るたびに、ファイル名と行番号が次の行に付きます。内部では次のコマンドが走っています。

xtensa-esp32-elf-addr2line -pfiaC -e build/PROJECT.elf ADDRESS

問題になるのは、ログが生のまま保存されている場合です。別のシリアルツールで取ったログや、CIで回収したログにはこの注釈行が付きません。そのときはClaude Codeにaddr2lineを実行させます。バックトレース行からPCだけを抜き出して渡す形です。

grep -o '0x4[0-9a-f]\{7\}:0x3[0-9a-f]\{7\}' crash.log \
  | cut -d: -f1 \
  | xargs xtensa-esp32-elf-addr2line -pfiaC -e build/app.elf

PROJECT.elfはbuild/ディレクトリにできる、そのファームウェアのELFです。クラッシュしたバイナリと違うビルドのELFを渡すと、関数名はそれらしく出ても間違った場所を指します。ログを採ったビルドと同じELFかどうかは、必ず確認します。

ツール名の接頭辞はチップによって変わります。ここで示したxtensa-esp32-elf-は公式のESP32向けページに載っているもので、他のチップでは別のツールチェーン名になります。

関数名に戻したあとは、次のように頼みます。

crash.logのバックトレースをaddr2lineで関数名に戻して。
先頭の行(クラッシュ地点)と、それを呼んだタスク関数までの経路を一覧にして、
スタックを大きく使いそうなローカル変数(大きな配列・printfの書式バッファ)があれば行番号つきで挙げて。
修正はまだしないで。

最後の「修正はまだしない」が肝心です。原因の候補が出る前にスタックサイズを書き換えられると、症状が消えたのか隠れたのか分からなくなります。

addr2lineの許可ルールを先に書いておく

非対話でこの手順を回すなら、addr2lineの実行をあらかじめ許可しておくと確認ダイアログで止まりません。Claude Codeの許可ルールは、コマンドの前方一致に*を付ける書き方です。パイプでつないだ複合コマンドは部品ごとに照合されるため、前節のgrep・cut・xargs・addr2lineをそれぞれ許可に入れます。

{
  "permissions": {
    "allow": [
      "Bash(grep *)",
      "Bash(cut *)",
      "Bash(xargs xtensa-esp32-elf-addr2line *)",
      "Bash(xtensa-esp32-elf-addr2line *)"
    ]
  }
}

.claude/settings.jsonに置く想定の例です。書き込み(idf.py flash)まで許可する必要はありません。ログを読む調査の段階では、ビルドと書き込みは人が実行する形で足ります。

スタック溢れは、ウォッチポイントのログを手がかりにする

ESP-IDFは、FreeRTOSのタスク切り替えのたびに、スタック末尾の32バイトにウォッチポイントを設定します。ここに書き込みが入るとパニックが起き、次のようなログが出ます。

Debug exception reason: Stack canary watchpoint triggered (task_name)

括弧の中がタスク名です。このログが出たなら、Claude Codeに渡す問いは「このタスクのスタックサイズはいくつで、どこで大きく使っているか」に絞れます。この機能はCONFIG_FREERTOS_WATCHPOINT_END_OF_STACKで有効にします。

注意したいのは、検知の限界です。公式には、すべてのスタック溢れがウォッチポイントを発火させるとは限らないと書かれています。タスクがカナリア(スタック末尾の目印のバイト列)を飛び越えて、さらに先のメモリへ書き込んだ場合は検知できません。ログにウォッチポイントの文言が出ていないことは、スタック溢れではない証拠にはなりません。次の節の数値で裏を取ります。

FreeRTOS本体のスタック検査はCONFIG_FREERTOS_CHECK_STACKOVERFLOWで選びます。選択肢は3つです。

設定検査の中身
検査なし検査の中身スタック溢れを確認しない
スタックポインタ値(方式1)検査の中身タスク切り替えごとに、ポインタが有効範囲にあるかを見る。速いが、切り替えの合間に起きた溢れは検知できない
カナリアバイト(方式2)検査の中身スタック末尾の目印が壊れていないかを見る。方式1より確実だが少し遅い

独自のvApplicationStackOverflowHook()を用意しない場合は、ESP-IDFの既定の関数が使われます。

もう一つ、Stack smashing protect failure!は別物です。こちらはGCCのスタック保護機能が出すメッセージで、CONFIG_COMPILER_STACK_CHECK_MODEで有効にします。公式の説明では、バックトレースは範囲外書き込みが起きた関数を指すので、その関数のローカル配列に上限のない書き込みがないかを調べます。タスク全体のスタックが足りないのではなく、1つの関数の中で配列があふれた状態です。この二つは直し方が違うので、ログの文言で振り分けてからClaude Codeに渡します。

uxTaskGetStackHighWaterMarkで余裕を数値にする

ログだけでは「溢れたかもしれない」までしか言えません。決め手は、タスクがこれまで最も深く使ったスタックの残りを測ることです。uxTaskGetStackHighWaterMark()は、タスク開始以来の最小の空きスタックをバイトで返します。値が小さいほど、溢れに近づいたことを意味します。

ESP-IDFではスタックサイズがバイト指定です。素のFreeRTOSの資料はワード単位で書かれているため、サンプルをそのまま写すと桁を取り違えます。この返り値もバイトです。

Claude Codeには、疑わしいタスクのループ内に計測を入れさせます。

#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "esp_log.h"
 
static void sensor_task(void *arg)
{
    for (;;) {
        // ... 通常の処理 ...
 
        // NULLを渡すと、呼び出したタスク自身の値が返る
        UBaseType_t free_bytes = uxTaskGetStackHighWaterMark(NULL);
        ESP_LOGI("stack", "sensor_task free min: %u bytes",
                 (unsigned)free_bytes);
 
        vTaskDelay(pdMS_TO_TICKS(1000));
    }
}

関数名に戻した結果と合わせて、次の順で頼みます。

手順

スタック溢れを数値で確かめる流れ

  1. 1

    疑わしいタスクを特定する

    ウォッチポイントのログに出たタスク名、またはバックトレースの経路にあるタスク関数を選びます。

  2. 2

    計測行を入れる

    そのタスクのループ末尾に、上のログ出力を足してもらいます。最も重い処理(通信の再試行や大きな文字列整形)が走る経路を通すことが条件です。

  3. 3

    再現させて値を読む

    ビルドと書き込みは人が行い、ログをファイルに保存してClaude Codeへ渡します。空きが0に近いなら、スタック不足の裏が取れます。

  4. 4

    サイズを上げるか、使用量を減らす

    xTaskCreate()のスタック指定を増やすか、大きなローカル配列を静的領域やヒープへ移します。変更後に同じ計測で空きが戻ったことを確認します。

注意点が一つあります。公式の説明には、この関数を使うにはFreeRTOSConfig.hでINCLUDE_uxTaskGetStackHighWaterMarkを1にする必要があると書かれています。ビルドで未定義になった場合は、この設定を確認します。

また、uxTaskGetStackHighWaterMark()が返すのは「これまでの最小の空き」なので、再現していない経路の分は入っていません。通常運転で十分な値が出ていても、まれにしか通らない経路でスタックを使うタスクは、その経路を通した後に測り直します。

ウォッチドッグの3種類は、発生源が違う

ウォッチドッグによるリセットは、ひとまとめに「WDT」と呼ばれがちですが、ESP-IDFでは役割の違うタイマーが3つあります。どれかによって見る場所が変わります。

種類見張るものログの手がかり
タスクウォッチドッグ(TWDT)見張るものタスクがCPUを手放さず走り続けることログの手がかりTask watchdog got triggeredに続くタスク名
割り込みウォッチドッグ(IWDT)見張るもの割り込みが長く止まることログの手がかりInterrupt wdt timeout on CPU0
RTCウォッチドッグ見張るものブートローダからapp_mainまでの起動時間ログの手がかりrst:0x10 (RTCWDT_RTC_RESET)

TWDTは「止まったタスク」を名指しする

TWDTは既定で各CPUのアイドルタスクを監視します。あるタスクがCPUを手放さず回り続けると、アイドルタスクが動けず、TWDTが発火します。無限ループや、周辺機器の完了を待つ空ループが典型です。

公式の説明にある警告の形は次のとおりです。

Task watchdog got triggered. The following tasks/users did not reset the watchdog in time:
 - IDLE0 (CPU 0)
Tasks currently running:
CPU 0: main
CPU 1: IDLE1

「現在実行中のタスク」の欄が、CPUを占有している候補です。この例ではCPU0でmainが走っています。ここで注意したいのは、TWDTの既定動作は警告とバックトレースを出して実行を続けるだけで、リセットはしないことです。パニックにしてリセットさせるのはCONFIG_ESP_TASK_WDT_PANICを設定したときです。「リセットが起きていないのにWDTの警告が出る」のは仕様どおりです。

どのコードが長いかを絞るには、esp_task_wdt_add()でそのタスクを監視対象にして、処理の区切りごとにesp_task_wdt_reset()を呼ぶ手があります。公式も、どのタスクのどの経路が最も時間を取っているかを調べる方法として挙げています。なお、フラッシュの広い範囲を消去するような処理も、実行が長くなってTWDTの発火原因になります。この場合はCONFIG_ESP_TASK_WDT_TIMEOUT_Sを伸ばすか、実行前にesp_task_wdt_init()で期限を延ばします。

IWDTは割り込みの長さを疑う

IWDTは、FreeRTOSのティック割り込みが期限内に実行されないとき、つまり割り込みが止められているときに発火します。止める要因は、割り込み禁止、クリティカルセクション、同じか高い優先度のISR(割り込みサービスルーチン)の3つです。

ログにInterrupt wdt timeout on CPU0が出たら、バックトレースの先頭が止めていたコードのはずです。Claude Codeには、関数名に戻した結果から「割り込み禁止やクリティカルセクションの中にある処理」と「ISRの中で待ちが発生している箇所」を挙げさせます。公式の対処方針は、ISRではキューで処理をタスクへ渡して計算を最小にすること、クリティカルセクションは短くすることです。それが難しい場合の最終手段がCONFIG_ESP_INT_WDT_TIMEOUT_MSの延長です。

RTCウォッチドッグは起動の問題

rst:0x10 (RTCWDT_RTC_RESET)は、アプリ起動前の段階で出る理由です。RTCウォッチドッグは最初のROMブートローダから監視を始め、app_main()の直前に無効化されます。ブートローダでの既定の期限は9000msで、CONFIG_BOOTLOADER_WDT_TIME_MSで変えられます。タスクのスタック溢れとは別系統なので、app_mainにたどり着いていないログならこちらを疑います。パニック処理が10秒を超えたときにも、このウォッチドッグがシステムをリセットします。

OpenOCDでJTAGデバッグをしているときは、ブレークポイントに止まるたびに割り込み・タスクの両ウォッチドッグが無効にされ、戻っても再有効になりません。デバッガを繋いだ状態で再現しなかったからといって、ウォッチドッグ起因ではないとは言えません。

調査手順をCLAUDE.mdに残す

毎回同じ説明をするのは手間なので、組み込みの調査手順はCLAUDE.mdに置いておきます。

## クラッシュログの読み方(ESP32)
- ログを読む前に、必ず build/*.elf を使って addr2line でバックトレースを関数名に戻す
- 原因名(括弧内)を最初に分類する: スタック溢れ / TWDT / IWDT / RTCWDT / その他
- スタック溢れを疑うときは、修正の前に uxTaskGetStackHighWaterMark の計測行を提案する
- ビルドと書き込み(idf.py build / flash)は実行しない。ログは人が採って渡す

「修正の前に計測」の一行が、勘でスタックを倍にする対応を防ぎます。

よくあるつまずき

  • 関数名が実際の場所とずれる: ログを採ったビルドと別のELFでaddr2lineを実行しています。ビルドを更新した直後のELFに古いログを当てないよう、ログ採取時のELFを保管します。
  • アドレスがin ROMと表示される: ROM内の関数はアプリのELFにないため、関数名だけが出ます。IDF MonitorはROMのELFを別に読み込みます。
  • スタックを増やしたのに再起動が続く: 原因がスタックではなくTWDTかIWDTだった可能性があります。上の表で、ログの文言を分類し直します。
  • サイズの桁がずれる: ESP-IDFのスタック指定はバイトです。ワード単位の資料の値を写すと、想定より小さい、または大きいスタックになります。
  • コアダンプを取りたい: 保存設定の手順はClaude CodeでESP-IDFのファームウェアを開発する手順にあります。シリアルデバッグの基本はClaude CodeでESP32の開発とシリアルデバッグを進める手順が扱っています。

まとめ

ログの括弧内の文言で原因を分類し、アドレスは必ず関数名に戻してから読ませる。この順番を固定すると、Claude Codeの回答がスタック増量の一点張りになりません。スタック溢れの疑いはuxTaskGetStackHighWaterMarkの数値で確定させ、ウォッチドッグは発火したタイマーの種類で調べる場所を変えます。

バックトレースから原因の関数を辿る考え方は、言語が違っても共通です。Rustの例はClaude CodeでRustのpanicを読み、backtraceから原因の関数を辿るで、並行処理の停止をログから追う例はClaude Codeでデッドロックの原因を調べる手順で扱っています。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. docs.espressif.com/projects/esp-idf/en/latest/esp32/api-guides/fatal-errors.h…
  2. docs.espressif.com/projects/esp-idf/en/latest/esp32/api-reference/system/wdts…
  3. docs.espressif.com/projects/esp-idf/en/latest/esp32/api-reference/system/free…
  4. docs.espressif.com/projects/esp-idf/en/latest/esp32/api-guides/tools/idf-moni…
  5. code.claude.com/docs/en/permissions
この記事を共有:XはてブLinkedIn