FreeRTOS 09 · アイドルタスクとフック — 「何もしない」タスクが必要な理由

Chapter 09

アイドルタスクとフック — 「何もしない」タスクが必要な理由

この章がなぜ必要なのか——「走るものがない」状態を作らないため.

06 章の規則は「実行可能な最高優先度タスクが必ず走っている」だった。 では、全部のタスクがブロックしていたらどうなるのか。

走らせるタスクがなければ、pxCurrentTCB は何を指せばいいのか。 スケジューラは何をすればいいのか。

この「例外」を消すために、FreeRTOS は絶対にブロックしないタスクを 1 つ用意する。 それがアイドルタスクである。そして、それは「何もしない」わけではない。

この章で使う既出の用語(定義は各リンク先). TCB(02 章 2 節)、スタック(02 章 2 節)、タスク(02 章 2 節)、ビジーウェイト(02 章 3 節)、スケジューラ(03 章 2 節)、Ready(04 章 1 節)、FreeRTOS(06 章 4 節)、削除(07 章 2 節)

1. なぜアイドルタスクが必要か

素朴に考えれば、「走るタスクがなければ、割り込みが来るまで CPU を止めればいい」と思える。 実際、そういう設計の OS もある。

だが FreeRTOS はそうしない。理由は 3 つある。

理由説明
スケジューラを単純にする「Ready が空」という特別扱いが不要になる
後片付けの実行主体が要る自分を削除したタスクのスタックを、誰かが解放しないといけない
アプリケーションに介入点を与える省電力・統計・ウォッチドッグの処理を差し込める

とくに 1 つ目が本質的である。

アイドルタスクは「規則の例外をなくすための装置」である.

優先度 0 に絶対にブロックしないタスクが 1 つ常駐していれば、 pxReadyTasksLists[0] は常に空でない。

だから 07 章の

while( listLIST_IS_EMPTY( &( pxReadyTasksLists[ uxTopPriority ] ) ) ) { --uxTopPriority; }

は、必ず有限回で止まることが保証される。

スケジューラのコードから条件分岐が 1 つ消える。 例外をなくすために 1 つ余計なものを常駐させる——これは OS 設計の常套手段である。 Linux の swapper プロセス(PID 0)もまったく同じ役割である。

2. アイドルタスクの中身

static portTASK_FUNCTION( prvIdleTask, pvParameters )
{
    for( ;; )
    {
        /* (1) 削除されたタスクの後片付け */
        prvCheckTasksWaitingTermination();

        /* (2) タイムスライスが無効なら、同優先度に譲る */
        #if ( configUSE_PREEMPTION == 0 )
        {
            taskYIELD();
        }
        #endif

        #if ( ( configUSE_PREEMPTION == 1 ) && ( configIDLE_SHOULD_YIELD == 1 ) )
        {
            /* 優先度 0 に他のタスクがいるなら、すぐ譲る */
            if( listCURRENT_LIST_LENGTH( &( pxReadyTasksLists[ 0 ] ) ) > 1 ) {
                taskYIELD();
            }
        }
        #endif

        /* (3) アプリケーションのフック */
        #if ( configUSE_IDLE_HOOK == 1 )
        {
            extern void vApplicationIdleHook( void );
            vApplicationIdleHook();
        }
        #endif

        /* (4) ティックレス(低消費電力) */
        #if ( configUSE_TICKLESS_IDLE != 0 )
        {
            TickType_t xExpectedIdleTime = prvGetExpectedIdleTime();
            if( xExpectedIdleTime >= configEXPECTED_IDLE_TIME_BEFORE_SLEEP ) {
                vTaskSuspendAll();
                {
                    xExpectedIdleTime = prvGetExpectedIdleTime();
                    if( xExpectedIdleTime >= configEXPECTED_IDLE_TIME_BEFORE_SLEEP ) {
                        portSUPPRESS_TICKS_AND_SLEEP( xExpectedIdleTime );
                    }
                }
                ( void ) xTaskResumeAll();
            }
        }
        #endif
    }
}

4 つの仕事がある。順に見ていく。

3. 仕事 1: 削除されたタスクの後片付け

04 章で触れたとおり、タスクが自分自身を削除するとき、 その場でスタックを解放することはできない。まだそのスタックの上で走っているからである。

static void prvCheckTasksWaitingTermination( void )
{
    while( uxDeletedTasksWaitingCleanUp > 0U )
    {
        taskENTER_CRITICAL();
        {
            pxTCB = listGET_OWNER_OF_HEAD_ENTRY( &xTasksWaitingTermination );
            ( void ) uxListRemove( &( pxTCB->xStateListItem ) );
            --uxCurrentNumberOfTasks;
            --uxDeletedTasksWaitingCleanUp;
        }
        taskEXIT_CRITICAL();

        prvDeleteTCB( pxTCB );      /* スタックと TCB を解放 */
    }
}

アイドルタスクが走らないと、メモリが返ってこない.

これが「タスクを削除したのにヒープが減ったまま」の正体である。

/* 高優先度タスクがビジーウェイトしている */
void vBadTask(void *pv) {
    for (;;) { if (flag) handle(); }    /* ブロックしない */
}

このタスクがいると、アイドルタスクに CPU が回らない。 vTaskDelete() を何回呼んでも、TCB とスタックは xTasksWaitingTermination に 溜まり続ける。やがてヒープが尽きる。

「メモリリークだと思ったら、実はアイドルタスクの飢餓だった」—— 実務でよく見る症状である。

アイドルタスクのスタックサイズ

#define configMINIMAL_STACK_SIZE  128       /* ワード単位 */

アイドルタスク自身はほとんどスタックを使わない。 問題は vApplicationIdleHook() である。

フックの中で printf を呼んだり深い関数を呼んだりすると、 アイドルタスクのスタックが溢れる。しかも症状が出にくい(たまにしか走らないから)。

フックの中では、必要最小限のことしかしないこと。

4. 仕事 2: configIDLE_SHOULD_YIELD

#define configIDLE_SHOULD_YIELD  1        /* 既定値は 1 */

これは「優先度 0 にアプリケーションのタスクを置いた場合」の挙動を決める。

設定挙動
1アイドルタスクは、優先度 0 に他のタスクがいたら即座に譲る
0アイドルタスクもタイムスライスで 1 ティック分走る

1 なら、優先度 0 のアプリタスクは CPU をほぼ全部使える。

そもそも優先度 0 を使わないこと.

configIDLE_SHOULD_YIELD = 1 にしても、 アイドルタスクは「1 回ループを回ってから」譲る。完全にゼロにはならない。

しかも、優先度 0 に複数タスクがいるとタイムスライスが正しく効かない (アイドルタスクが毎回途中で譲るため、時間配分が不均等になる)。

アプリケーションのタスクは優先度 1 以上に置く。 これで全部解決する。

5. 仕事 3: アイドルフック

#define configUSE_IDLE_HOOK  1

void vApplicationIdleHook( void )
{
    /* ここに書く */
}

アイドルフックには 2 つの絶対的な制約がある。

制約理由
絶対にブロックしてはいけないアイドルタスクがブロックすると、走るタスクがゼロになる
絶対に削除・終了してはいけない同上
void vApplicationIdleHook( void )
{
    vTaskDelay( 10 );        /* ← 絶対にダメ */
    xQueueReceive( q, &x, portMAX_DELAY );    /* ← 絶対にダメ */
}

これをやると、pxReadyTasksLists[0] が空になり、 07 章の探索ループが優先度 0 を下回って暴走する。 configASSERT を定義していれば、そこで捕まる。

アイドルフックの正しい使い方

用途内容
省電力__WFI() で CPU を止める。次の割り込みで起きる
CPU 使用率の測定アイドルフックが呼ばれた回数を数える
ウォッチドッグ「全タスクが正常に寝ている」証拠としてキックする
低優先度の後始末ログのフラッシュなど(ブロックしない範囲で)
/* 省電力の最小形 */
void vApplicationIdleHook( void )
{
    __WFI();      /* Wait For Interrupt。次の割り込みまで CPU クロックを止める */
}

__WFI() を入れるだけで消費電流が劇的に下がる.

何もしないとアイドルタスクは全力でループを回り続ける。 Cortex-M4 なら数十 mA を無駄に消費する。

__WFI() を入れると、次の割り込み(多くはティック割り込み)まで CPU が止まる。 1 ms 周期のティックでも、消費電流は 1/5〜1/10 になることがある。

さらに下げたいなら configUSE_TICKLESS_IDLE(次節)である。

CPU 使用率の測定

アイドルフックの呼び出し回数を数えると、CPU 使用率が分かる。

volatile uint32_t ulIdleCount = 0;

void vApplicationIdleHook( void )
{
    ulIdleCount++;
}

/* 別のタスクで、1 秒ごとに */
float cpu_usage = 100.0f * (1.0f - (float)ulIdleCount / ulIdleCountMax);
ulIdleCount = 0;

ulIdleCountMax は「全タスクを止めて 1 秒間アイドルさせたときのカウント」を あらかじめ測っておく(キャリブレーション)。

CPU 使用率を測る

タスクの負荷を変えて、アイドル時間と CPU 使用率がどう変わるかを確かめられる。

この方法の欠点.

__WFI() を入れると、アイドルフックの実行時間が「割り込みを待つ時間」に化けるので、 カウント方式の測定が成立しなくなる。

より正確に測るには、21 章の実行時間統計(run-time stats)を使う。 高分解能タイマでタスクごとの実行時間を積算する仕組みである。

6. 仕事 4: ティックレスアイドル

08 章で触れた低消費電力機能である。アイドルタスクの中で実行される。

TickType_t xExpectedIdleTime = prvGetExpectedIdleTime();
if( xExpectedIdleTime >= configEXPECTED_IDLE_TIME_BEFORE_SLEEP ) {
    portSUPPRESS_TICKS_AND_SLEEP( xExpectedIdleTime );
}

prvGetExpectedIdleTime() は「次にタスクを起こすべき時刻までの残りティック数」を返す。 それが configEXPECTED_IDLE_TIME_BEFORE_SLEEP(既定 2)以上なら、寝る価値があると判断する。

vTaskSuspendAll() で囲んで、もう一度確認しているのはなぜか.

vTaskSuspendAll();
{
    xExpectedIdleTime = prvGetExpectedIdleTime();      /* もう一度 */
    if( xExpectedIdleTime >= configEXPECTED_IDLE_TIME_BEFORE_SLEEP ) {
        portSUPPRESS_TICKS_AND_SLEEP( xExpectedIdleTime );
    }
}
( void ) xTaskResumeAll();

1 回目の判定と 2 回目の判定の間に、割り込みでタスクが Ready になったかもしれないからである。 古い値を信じて長時間寝てしまうと、そのタスクの起床が遅れる。

これはチェック・アンド・アクトの競合 (TOCTOU) の典型的な対策である。 「判定した状態のまま行動する」ために、判定と行動を排他区間で囲み直している。 19 章で改めて扱う。

7. 他のフック

FreeRTOS が用意しているフックは 5 つある。

フック呼ばれる場所用途制約
vApplicationIdleHookアイドルタスク省電力・統計ブロック禁止
vApplicationTickHookティック割り込みの中高頻度の軽い処理ISR の制約が全部かかる
vApplicationStackOverflowHookスタック溢れ検出時ログ・リセット状況が壊れている前提で書く
vApplicationMallocFailedHookpvPortMalloc が NULL を返したときログ・リセットヒープがない前提で書く
vApplicationDaemonTaskStartupHookタイマタスクの起動直後スケジューラ開始後の初期化一度だけ呼ばれる

vApplicationTickHook の注意

これは割り込みコンテキストで呼ばれる。

void vApplicationTickHook( void )
{
    /* ここは ISR の中。FromISR 版の API しか使えない */
    xQueueSendFromISR( q, &data, &xHigherPriorityTaskWoken );    /* ○ */
    xQueueSend( q, &data, 0 );                                    /* × */
    vTaskDelay( 1 );                                              /* × 論外 */
}

しかも毎ティック呼ばれるので、ここが重いとシステム全体が遅くなる。 数マイクロ秒で終わる処理だけを書くこと。

vApplicationDaemonTaskStartupHook は意外に便利.

「スケジューラが動き出してから初期化したい」ものがある。 たとえば、キューやセマフォを使う初期化処理、 あるいはタスクとして走らせる必要のあるドライバの初期化である。

main() の中ではまだスケジューラが動いていないのでブロックできない。 このフックはタイマタスク(デーモンタスク)の起動直後に一度だけ呼ばれるので、 ブロックする API を安全に使える最初の場所になる。

8. この章のまとめ

ポイント内容
存在理由「Ready が空」という規則の例外をなくすため
優先度常に 0。アプリのタスクは 1 以上に置くこと
仕事 1削除済みタスクの後片付け。走らないとメモリが返らない
仕事 2configIDLE_SHOULD_YIELD による譲り
仕事 3アイドルフック。ブロック禁止・終了禁止
仕事 4ティックレスアイドル。TOCTOU 対策で二重判定
省電力の第一歩フックに __WFI() を 1 行入れるだけで消費電流が大幅に下がる
vApplicationTickHookISR コンテキスト。FromISR 版しか使えない

ここまでで第 II 部(スケジューラの中核)は終わりである。 次章から第 III 部——「待つ・起こす」の仕組みに入る。