FreeRTOS 15 · ソフトウェアタイマ — タイマタスクという「専用のスタッフ」

Chapter 15

ソフトウェアタイマ — タイマタスクという「専用のスタッフ」

この章がなぜ必要なのか——「タイマ」という言葉が 3 つのものを指している.

ハードウェアタイマ、ティック割り込み、そしてソフトウェアタイマ。 この 3 つは全く別物である。

そして FreeRTOS のソフトウェアタイマには、独特の制約がある。 コールバックは「割り込みの中」ではなく「タイマタスクという普通のタスクの中」で走る。 ここを理解しないと、応答時間の見積りが根本から狂う。

この章で使う既出の用語(定義は各リンク先). コンテキスト(01 章 6 節)、スタック(02 章 2 節)、タスク(02 章 2 節)、スケジューラ(03 章 2 節)、ハードウェア(03 章 2 節)、イベントリスト(04 章 4 節)、イベント待ち(04 章 4 節)、時間待ち(04 章 4 節)、FreeRTOS(06 章 4 節)、RAM(06 章 4 節)、削除(07 章 2 節)、追加(07 章 2 節)、ブロック(13 章 1 節)、キュー(14 章 7 節)

1. 3 つの「タイマ」の違い

種類実体精度コールバックの実行場所
ハードウェアタイマCPU 内蔵のペリフェラルns〜µs割り込みハンドラ
ティックハードウェアタイマ 1 本を OS が占有ティック周期カーネル内部
ソフトウェアタイマティックの上にソフトで実装ティック周期タイマタスク(普通のタスク)

ソフトウェアタイマは「ハードウェアを消費せずに、いくつでもタイマが持てる」のが利点である。 100 個作っても、使うハードウェアタイマはティック用の 1 本だけである。

代わりに、精度と即時性を捨てている.

ハードウェアタイマなら µs 精度で、割り込みで即座にコールバックが走る。 ソフトウェアタイマはティック周期の精度で、しかもタイマタスクが走るまで実行されない。

正確なタイミングが要るならハードウェアタイマを使うこと。 ソフトウェアタイマは「だいたい 1 秒後に何かする」用途のものである。

2. タイマタスク(デーモンタスク)

#define configUSE_TIMERS               1
#define configTIMER_TASK_PRIORITY      3      /* タイマタスクの優先度 */
#define configTIMER_QUEUE_LENGTH       10     /* コマンドキューの長さ */
#define configTIMER_TASK_STACK_DEPTH   configMINIMAL_STACK_SIZE

vTaskStartScheduler() の中で、タイマタスクが自動的に生成される(03 章)。

このタスクがやることは、たった 2 つである。

static portTASK_FUNCTION( prvTimerTask, pvParameters )
{
    for( ;; )
    {
        /* (1) 次に期限が来るタイマの時刻を調べる */
        xNextExpireTime = prvGetNextExpireTime( &xListWasEmpty );

        /* (2) その時刻まで寝る。起きたら期限が来たタイマを処理する */
        prvProcessTimerOrBlockTask( xNextExpireTime, xListWasEmpty );

        /* (3) コマンドキューに溜まった要求を処理する */
        prvProcessReceivedCommands();
    }
}

構造の全体像

   アプリケーションのタスク / ISR
              │
              │ xTimerStart() / xTimerStop() / xTimerChangePeriod()
              ↓
       ┌──────────────────┐
       │ タイマコマンドキュー │  ← ただのキュー([11 章](11_キュー.md#キューセット-複数のキューを同時に待つ))
       └──────────────────┘
              │
              ↓
       ┌──────────────────┐
       │   タイマタスク      │  ← 普通のタスク。優先度 configTIMER_TASK_PRIORITY
       │  (デーモンタスク)   │
       └──────────────────┘
              │
              ├─→ アクティブタイマリスト(期限の昇順ソート)
              │
              └─→ 期限が来たら pxCallbackFunction() を呼ぶ

これが最も重要な事実である.

xTimerStart() はタイマを開始しない。「開始してくれ」というコマンドをキューに送るだけである。

実際に開始されるのは、タイマタスクがそのコマンドを取り出したときである。 だから——

configTIMER_TASK_PRIORITY の設定が、タイマの応答性を決める。

3. タイマの作り方と使い方

TimerHandle_t xTimer = xTimerCreate(
    "MyTimer",                     /* 名前(デバッグ用) */
    pdMS_TO_TICKS( 1000 ),         /* 周期 */
    pdTRUE,                        /* pdTRUE = 自動リロード、pdFALSE = ワンショット */
    ( void * ) 0,                  /* タイマ ID(ユーザ任意) */
    vTimerCallback );              /* コールバック関数 */

xTimerStart( xTimer, 0 );          /* 開始(第 2 引数はコマンドキューの待ち時間) */

void vTimerCallback( TimerHandle_t xTimer )
{
    /* ここはタイマタスクのコンテキスト */
    uint32_t id = ( uint32_t ) pvTimerGetTimerID( xTimer );
    ...
}

主な API

API動作ISR 版
xTimerStart開始(既に動いていればリセット)xTimerStartFromISR
xTimerStop停止xTimerStopFromISR
xTimerReset現時刻から周期を数え直すxTimerResetFromISR
xTimerChangePeriod周期を変えて開始xTimerChangePeriodFromISR
xTimerDelete削除なし
xTimerIsTimerActive動いているか(キュー経由でない、即座に返る)—
pvTimerGetTimerIDID を取得—

ソフトウェアタイマの動作を見る

タイマの周期・モード・タイマタスクの優先度を変えて、 コールバックが実際にいつ走るかを確かめられる。

2 つのモード

モード第 3 引数動作
自動リロードpdTRUE周期的に呼ばれ続ける
ワンショットpdFALSE1 回だけ呼ばれて停止する

xTimerReset() はワンショットで真価を発揮する.

「最後の入力から 3 秒間何もなかったら実行する」—— デバウンスやアイドル検出の典型パターンである。

/* 入力があるたびに呼ぶ */
void on_input( void ) {
    xTimerReset( xIdleTimer, 0 );      /* 3 秒を数え直す */
}

/* 3 秒間 on_input が呼ばれなければ、これが実行される */
void vIdleTimeout( TimerHandle_t t ) {
    enter_sleep_mode();
}

4. コールバックの掟

コールバックはタイマタスクのコンテキストで実行される。 つまり「タイマタスクという 1 つのタスクの中で、全タイマのコールバックが順に実行される」。

このことから、2 つの絶対的な掟が導かれる。

掟 1: 絶対にブロックしてはいけない

void vTimerCallback( TimerHandle_t t )
{
    vTaskDelay( 100 );                         /* ← 絶対にダメ */
    xSemaphoreTake( xMutex, portMAX_DELAY );   /* ← 絶対にダメ */
}

ブロックするとタイマタスクが止まる。 その間、すべてのタイマのコールバックが実行されず、すべてのコマンドが処理されない。

システム全体のタイマ機能が停止する。これは非常に見つけにくい不具合になる。

掟 2: 短く済ませる

void vTimerCallback( TimerHandle_t t )
{
    heavy_computation();       /* ← 他のタイマが全部遅れる */
}

タイマタスクは 1 本しかない。長い処理は他のタイマの期限を遅らせる。

正しくは、通知を送って別のタスクに処理させる。

void vTimerCallback( TimerHandle_t t )
{
    xTaskNotifyGive( xWorkerTask );    /* 通知するだけ。すぐ戻る */
}

タイマタスクのスタックサイズにも注意.

#define configTIMER_TASK_STACK_DEPTH  configMINIMAL_STACK_SIZE

既定値は最小サイズである。コールバックの中で深い関数を呼ぶと溢れる。 実際、printf を 1 行入れただけで落ちることがある。

コールバックに何か書くなら、このサイズを見直すこと(17 章)。

5. xTimerPendFunctionCall() — 遅延実行

タイマ機能のもう 1 つの顔である。「この関数を、タイマタスクに実行してもらう」。

BaseType_t xTimerPendFunctionCallFromISR( PendedFunction_t xFunctionToPend,
                                          void *pvParameter1,
                                          uint32_t ulParameter2,
                                          BaseType_t *pxHigherPriorityTaskWoken );

これは ISR の中で長い処理をしたくないときに使う。

void vDeferredHandler( void *pvParam1, uint32_t ulParam2 )
{
    /* ここはタイマタスクのコンテキスト。ブロックしなければ何でもできる */
    process_packet( (uint8_t*)pvParam1, ulParam2 );
}

void DMA_IRQHandler( void )
{
    BaseType_t woken = pdFALSE;
    xTimerPendFunctionCallFromISR( vDeferredHandler, dma_buffer, dma_len, &woken );
    portYIELD_FROM_ISR( woken );
}

これが「遅延割り込み処理 (deferred interrupt processing)」の FreeRTOS 版である.

Linux では下半分 (bottom half) / ワークキューと呼ばれる仕組みに相当する。 「割り込みの中では最小限のことだけして、残りは後で普通のコンテキストでやる」—— これは OS 設計の普遍的なパターンである。

14 章で見た xEventGroupSetBitsFromISR() も、内部でこれを使っている。

専用タスクとの比較

方法利点欠点
xTimerPendFunctionCallタスクを追加しなくてよいタイマタスクを共有するので互いに遅らせ合う
専用のワーカータスク優先度を独立に設定できるタスク 1 つ分の RAM を使う

頻度が低く軽い処理なら前者、頻度が高いか重い処理なら後者である。

6. 内部実装 — アクティブタイマリスト

タイマも、期限の昇順にソートされたリストで管理される(10 章の遅延リストと同じ発想)。

static List_t xActiveTimerList1;
static List_t xActiveTimerList2;
static List_t *pxCurrentTimerList;
static List_t *pxOverflowTimerList;

ティックの折り返し対策も、遅延リストと同じ「リスト 2 本の交換」である。

static void prvProcessTimerOrBlockTask( const TickType_t xNextExpireTime,
                                        BaseType_t xListWasEmpty )
{
    vTaskSuspendAll();
    {
        xTimeNow = prvSampleTimeNow( &xTimerListsWereSwitched );

        if( xTimerListsWereSwitched == pdFALSE )
        {
            if( ( xListWasEmpty == pdFALSE ) && ( xNextExpireTime <= xTimeNow ) ) {
                ( void ) xTaskResumeAll();
                prvProcessExpiredTimer( xNextExpireTime, xTimeNow );   /* 期限到来 */
            }
            else {
                /* 次の期限まで、コマンドキューで待つ */
                vQueueWaitForMessageRestricted( xTimerQueue,
                                                ( xNextExpireTime - xTimeNow ),
                                                xListWasEmpty );
                if( xTaskResumeAll() == pdFALSE ) { portYIELD_WITHIN_API(); }
            }
        }
        else { ( void ) xTaskResumeAll(); }
    }
}

vQueueWaitForMessageRestricted() という工夫.

タイマタスクは、2 つのことを同時に待つ必要がある。

  1. 次のタイマの期限が来ること(時間待ち)
  2. コマンドキューに要求が届くこと(イベント待ち)

この関数は「キューに対して、指定時間だけ待つ。ただし受信はしない」という 特殊な待ち方をする。10 章で見た「2 つのリストへの同時登録」がそのまま効いている。

どちらでも起きて、起きてから何が起きたか判断する。 きれいな設計である。

自動リロードの再登録

static void prvProcessExpiredTimer( const TickType_t xNextExpireTime,
                                    const TickType_t xTimeNow )
{
    TIMER_t * const pxTimer = listGET_OWNER_OF_HEAD_ENTRY( pxCurrentTimerList );

    ( void ) uxListRemove( &( pxTimer->xTimerListItem ) );

    if( ( pxTimer->ucStatus & tmrSTATUS_IS_AUTORELOAD ) != 0 )
    {
        /* ★ 期限「基準」で再登録する。実行時刻基準ではない */
        if( prvInsertTimerInActiveList( pxTimer,
                                        xNextExpireTime + pxTimer->xTimerPeriodInTicks,
                                        xTimeNow, xNextExpireTime ) != pdFALSE )
        { ... }
    }
    else {
        pxTimer->ucStatus &= ~tmrSTATUS_IS_ACTIVE;
    }

    pxTimer->pxCallbackFunction( ( TimerHandle_t ) pxTimer );    /* コールバック */
}

再登録が「実行時刻 + 周期」ではなく「前回の期限 + 周期」である点に注目.

これは 08 章の vTaskDelayUntil() と同じ考え方である。 コールバックの実行が遅れても、期限の格子はずれない。

期限:     0 ─── 100 ─── 200 ─── 300     ← 常に 100 刻み
実行:     5     108     205     310      ← 遅れるが、期限は動かない

だから長期的な累積誤差が生じない。 よくできている。

7. よくある落とし穴

落とし穴症状対策
configTIMER_TASK_PRIORITY が低すぎるタイマが遅れる・止まる高優先度タスクより上に置く
configTIMER_QUEUE_LENGTH が小さいxTimerStart が失敗する長くする。戻り値を確認する
コールバックでブロック全タイマが停止通知して別タスクで処理する
タイマタスクのスタック不足原因不明のクラッシュconfigTIMER_TASK_STACK_DEPTH を増やす
xTimerStart の戻り値を見ない静かに失敗している必ず確認する
スケジューラ開始前に xTimerStartコマンドは溜まるが処理されない開始後に呼ぶ(09 章のデーモン起動フックが便利)
if( xTimerStart( xTimer, pdMS_TO_TICKS( 10 ) ) != pdPASS ) {
    /* コマンドキューが満杯だった */
    log_error( "timer start failed" );
}

configTIMER_TASK_PRIORITY をどこに置くか.

悩ましい判断である。

原則としては、高めに置いて、コールバックを極端に軽くするのがよい。 コールバックが軽ければ、高優先度でも実害がない。

典型的には「アプリケーションの最高優先度タスクと同じか、1 段上」に置く。

8. この章のまとめ

ポイント内容
実体タイマタスク(デーモンタスク) という普通のタスク
API の正体xTimerStart() はコマンドキューに要求を送るだけ
応答性configTIMER_TASK_PRIORITY が決める
コールバックの掟ブロック禁止・短く済ませる。全タイマが道連れになる
重い処理通知を送って別タスクで処理する
xTimerPendFunctionCallISR からの遅延実行。Linux の下半分に相当
内部構造期限の昇順リスト + 2 本交換によるオーバーフロー対策
同時待ちvQueueWaitForMessageRestricted で「時間 or コマンド」を待つ
再登録前回の期限 + 周期。累積誤差が出ない
精度が要るならハードウェアタイマを使うこと

次章では、これまで何度も出てきた「ヒープ」—— FreeRTOS のメモリ管理を見る。