FreeRTOS 18 · 割り込みとの境界 — FromISR がなぜ別 API なのか

Chapter 18

割り込みとの境界 — FromISR がなぜ別 API なのか

この章がなぜ必要なのか——ここが最も事故が多い場所だから.

「ISR の中で xQueueSend() を呼んだら、たまにハングする」 「portYIELD_FROM_ISR() を忘れたら、応答が 1 ティック遅れる」 「割り込み優先度を設定したら、configASSERT で止まった」

これらは全部、カーネルと割り込みの境界の規則を知らないことから来る。 規則自体は少ないので、一度整理しておけば済む。

この章で使う既出の用語(定義は各リンク先). コンテキスト(01 章 6 節)、TCB(02 章 2 節)、タスク(02 章 2 節)、スケジューラ(03 章 2 節)、データ構造(03 章 2 節)、ハードウェア(03 章 2 節)、Blocked(04 章 1 節)、Ready(04 章 1 節)、FreeRTOS(06 章 4 節)、ブロック禁止(09 章 7 節)、タイムアウト(10 章 3 節)、キュー(14 章 7 節)、ストリームバッファ(14 章 7 節)、ソフトウェアタイマ(15 章 1 節)、ティック(15 章 1 節)、割り込みハンドラ(15 章 1 節)、不可(16 章 4 節)

1. なぜ ISR 版 API が必要なのか

理由は 3 つある。

理由 1: ISR はブロックできない

void ISR( void )
{
    xQueueSend( q, &data, portMAX_DELAY );    /* ← 満杯なら「待つ」…どこで? */
}

ブロックとは「タスクを Blocked リストに移して、他のタスクに切り替える」ことである(10 章)。 だがISR はタスクではない。TCB がない。移すべきリストも、保存すべきコンテキストもない。

だから ISR 版 API にはタイムアウト引数がない。

BaseType_t xQueueSendFromISR( QueueHandle_t xQueue,
                              const void *pvItemToQueue,
                              BaseType_t *pxHigherPriorityTaskWoken );
                              /* ↑ タイムアウトの代わりにこれ */

理由 2: 排他の方法が違う

コンテキスト排他の手段
タスクtaskENTER_CRITICAL() — 割り込み禁止+ネストカウント
ISRportSET_INTERRUPT_MASK_FROM_ISR() — 元のマスク値を返す
/* ISR の中 */
uxSavedInterruptStatus = portSET_INTERRUPT_MASK_FROM_ISR();
{
    /* 危険区間 */
}
portCLEAR_INTERRUPT_MASK_FROM_ISR( uxSavedInterruptStatus );

ISR は既に「ある割り込み優先度で走っている」ので、 元の状態を保存して、正確に戻す必要がある。単純な有効/無効ではない。

理由 3: 切り替えのタイミングが違う

これが最も本質的である。

/* タスク版: 内部で切り替えてしまう */
xQueueSend( q, &data, 0 );
    → 高優先度タスクが起きた → その場で portYIELD()

/* ISR 版: 切り替えず、「必要だ」と教えるだけ */
xQueueSendFromISR( q, &data, &xHigherPriorityTaskWoken );
    → 高優先度タスクが起きた → xHigherPriorityTaskWoken = pdTRUE にするだけ

なぜ ISR の中で切り替えないのか。

割り込みが 3 段ネストしているとき

  Task ──┬─→ IRQ_A ──┬─→ IRQ_B ──┬─→ IRQ_C
         │           │           │
         │           │           └─ ここで切り替えたら?
         │           │              IRQ_A と IRQ_B の途中の状態が
         │           │              宙に浮いてしまう

割り込みの途中で切り替えると、外側の割り込みの復帰処理が失われる。 だから「全部の割り込みを抜けてから切り替える」必要がある。

void ISR( void )
{
    BaseType_t xHigherPriorityTaskWoken = pdFALSE;

    xQueueSendFromISR( q, &data, &xHigherPriorityTaskWoken );
    xSemaphoreGiveFromISR( sem, &xHigherPriorityTaskWoken );   /* 同じ変数を使い回せる */

    portYIELD_FROM_ISR( xHigherPriorityTaskWoken );   /* ← ISR の最後に 1 回だけ */
}

portYIELD_FROM_ISR() は、多くの移植で「PendSV を保留する」だけである。 実際の切り替えは、すべての割り込みを抜けた後の PendSV ハンドラで起きる。

portYIELD_FROM_ISR() を忘れるとどうなるか.

エラーにはならない。動く。だが遅くなる。

起こされたタスクは Ready リストに入っているので、 次のティック割り込み(最大 1 ティック後)には走り出す。

つまり「たまに応答が 1 ms 遅れる」という症状になる。 これは非常に見つけにくい。 忘れないこと。

2. どちらの API を使うか — 判定

呼び出し元使う API
タスク関数の中通常版
ISR の中FromISR 版
vApplicationTickHook の中FromISR 版(ティック割り込みの中だから)
タイマのコールバックの中通常版(タイマタスクの中だから。15 章)
vApplicationIdleHook の中通常版(アイドルタスクの中。ただしブロック禁止)
main() の中(スケジューラ開始前)通常版(ただしブロックは不可)

タイマコールバックが「通常版」なのが引っかけである. 「タイマ」という名前から割り込みを連想するが、 FreeRTOS のソフトウェアタイマのコールバックはタイマタスクという普通のタスクの中で走る。 だから通常版の API を使う。ただしブロックは禁止(15 章)。

ISR 版が存在する主な API

通常版ISR 版
xQueueSendxQueueSendFromISR
xQueueReceivexQueueReceiveFromISR
xSemaphoreGivexSemaphoreGiveFromISR
xSemaphoreTakexSemaphoreTakeFromISR
xTaskNotifyxTaskNotifyFromISR
vTaskNotifyGivevTaskNotifyGiveFromISR
xEventGroupSetBitsxEventGroupSetBitsFromISR(タイマタスク経由)
xTimerStartxTimerStartFromISR
xTaskGetTickCountxTaskGetTickCountFromISR

ISR 版がない API は、ISR から呼んではいけない。

ISR から呼べない API理由
vTaskDelay / vTaskDelayUntilブロックする
xSemaphoreTake(ミューテックス)ISR は所有者になれない(12 章)
xSemaphoreGive(ミューテックス)同上
vTaskSuspendAll / xTaskResumeAllスケジューラの制御はタスクの仕事
pvPortMalloc / vPortFree実行時間が不定。内部で vTaskSuspendAll を使う実装もある
xQueueCreate などの生成 API上記の理由

3. 割り込み優先度 — 最も事故が多い設定

Cortex-M では、割り込み優先度は数値が小さいほど高い(FreeRTOS のタスク優先度と逆)。

#define configKERNEL_INTERRUPT_PRIORITY        255   /* 最低優先度 */
#define configMAX_SYSCALL_INTERRUPT_PRIORITY   191   /* この値より数値が小さい割り込みは
                                                        FreeRTOS API を呼べない */
数値小 = 優先度高
  0  ┐
  … │ ← FreeRTOS API を呼べない領域
     │   クリティカルセクションにも止められない
191 ─┼── configMAX_SYSCALL_INTERRUPT_PRIORITY
     │
  … │ ← FreeRTOS API を呼べる領域(FromISR 版のみ)
     │   クリティカルセクションで止まる
255 ┘ ← configKERNEL_INTERRUPT_PRIORITY(PendSV と SysTick がここ)
数値大 = 優先度低

2 つの領域の意味

領域FreeRTOS APIクリティカルセクション用途
configMAX_SYSCALL より高い呼べない止められない超低レイテンシが要る処理(モータ転流など)
configMAX_SYSCALL 以下FromISR 版のみ呼べる止まる通常の割り込み

なぜこう分けるのか.

taskENTER_CRITICAL() は、Cortex-M では BASEPRI レジスタに configMAX_SYSCALL_INTERRUPT_PRIORITY を書くことで実現されている。

BASEPRI は「この値以下の優先度(=数値が同じか大きい)の割り込みをマスクする」レジスタである。 だから——

つまり「API を呼べる」と「止められる」は同じことの裏表である。 割り込みを止めずに API を呼べるようにする方法はない。

よくある事故

/* STM32 の HAL を使った例 */
HAL_NVIC_SetPriority( USART1_IRQn, 0, 0 );      /* ← 優先度 0 = 最高 */

void USART1_IRQHandler( void )
{
    xQueueSendFromISR( q, &c, &woken );          /* ← configASSERT で止まる */
}

優先度 0 は configMAX_SYSCALL_INTERRUPT_PRIORITY より高いので、 この ISR から FreeRTOS API を呼んではいけない。

FreeRTOS はこれを検出する。

#define portASSERT_IF_INTERRUPT_PRIORITY_INVALID()  vPortValidateInterruptPriority()

void vPortValidateInterruptPriority( void )
{
    ulCurrentInterrupt = vPortGetIPSR();

    if( ulCurrentInterrupt >= portFIRST_USER_INTERRUPT_NUMBER )
    {
        ucCurrentPriority = pcInterruptPriorityRegisters[ ulCurrentInterrupt ];
        configASSERT( ucCurrentPriority >= ucMaxSysCallPriority );      /* ★ */
    }

    configASSERT( ( portAIRCR_REG & portPRIORITY_GROUP_MASK ) <= ulMaxPRIGROUPValue );
}

configASSERT を定義していないと、このチェックが働かない.

そして何も言わずに、たまにカーネルのデータ構造が壊れる。 症状は「数時間動いた後にハングする」——最悪の形で現れる。

configASSERT を必ず定義すること。 これがその理由の 1 つである。

優先度ビット数の罠

Cortex-M の優先度レジスタは 8 bit だが、実装が使うのは上位数ビットだけである。

チップ実装ビット数有効な値
STM32F44 bit上位 4 bit(0, 16, 32, ..., 240)
nRF523 bit上位 3 bit(0, 32, 64, ..., 224)
Cortex-M0+2 bit上位 2 bit(0, 64, 128, 192)
#define configPRIO_BITS  4      /* ★ チップに合わせる */
#define configMAX_SYSCALL_INTERRUPT_PRIORITY  ( 5 << (8 - configPRIO_BITS) )

HAL の API は「論理優先度」(0〜15)を取り、 FreeRTOS の設定は「レジスタ値」(0〜255)を取る——ここで混乱が起きる。

/* HAL: 論理優先度 5 */
HAL_NVIC_SetPriority( USART1_IRQn, 5, 0 );

/* FreeRTOS: レジスタ値 5 << 4 = 80 */
#define configMAX_SYSCALL_INTERRUPT_PRIORITY  ( 5 << 4 )    /* = 80 */

論理 5 は レジスタ値 80 なので、ちょうど境界にある=API を呼べる(>= なので)。

優先度グルーピングも罠である.

Cortex-M には「プリエンプション優先度」と「サブ優先度」の分割がある。 FreeRTOS はサブ優先度を使わない前提なので、 全ビットをプリエンプション優先度に割り当てる必要がある。

HAL_NVIC_SetPriorityGrouping( NVIC_PRIORITYGROUP_4 );   /* STM32F4: 4 bit 全部 */

上の vPortValidateInterruptPriority() の 2 つ目の configASSERT が、これを検出する。

4. 割り込みハンドラの設計

遅延割り込み処理 (Deferred Interrupt Processing)

ISR は最小限にして、本処理はタスクに任せる。

/* ISR: 最小限 */
void UART_IRQHandler( void )
{
    BaseType_t woken = pdFALSE;

    while( uart_rx_ready() ) {
        uint8_t c = UART->DR;
        xStreamBufferSendFromISR( xStream, &c, 1, &woken );
    }

    portYIELD_FROM_ISR( woken );
}

/* タスク: 本処理 */
void vUartTask( void *pv )
{
    uint8_t buf[64];
    for (;;) {
        size_t n = xStreamBufferReceive( xStream, buf, sizeof(buf), portMAX_DELAY );
        parse_protocol( buf, n );      /* 重い処理はここで */
    }
}

利点:

利点内容
割り込み禁止時間が短い他の割り込みを妨げない
優先度で制御できるISR に優先度を付けるより柔軟
ブロックできるタスクなのでミューテックスも使える
デバッグしやすい普通のタスクとして追える

「割り込みハンドラは短く」は普遍的な原則である.

Linux の「上半分/下半分」、Windows の「ISR / DPC」、 FreeRTOS の「ISR / 遅延タスク」——名前が違うだけで、思想は同じである。

目安: ISR の実行時間は 10 µs 以下、できれば 5 µs 以下。

xTimerPendFunctionCallFromISR を使う方法

専用タスクを作らずに済む方法である(15 章)。

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

タイマタスクを共有するので、頻度が低く軽い処理に向く。

5. ISR とタスクでデータを共有する

volatile uint32_t g_counter;      /* ISR とタスクの両方が触る */

volatile は必要だが、十分ではない

volatile が保証するのは「コンパイラが最適化で読み書きを省略しない」ことだけである。

原子性は保証しない。

/* タスク側 */
g_counter++;       /* 読む・足す・書く の 3 段階。途中で ISR が入りうる */

32 bit CPU で 32 bit 変数の読み書き単体は原子的だが、 読み書きの組み合わせ(read-modify-write)は原子的でない。

正しい共有の方法

方法使いどころ
キュー/通知/ストリームバッファで渡す最も推奨。共有変数を作らない
taskENTER_CRITICAL()(タスク側)短い区間。ISR も止まる
portSET_INTERRUPT_MASK_FROM_ISR()(ISR 側)ISR 側から共有データを触るとき
単一の書き手・単一の読み手 + volatile32 bit 以下のフラグの受け渡しのみ
/* タスク側で共有データを読む */
uint32_t local;
taskENTER_CRITICAL();
{
    local = g_counter;
    g_counter = 0;
}
taskEXIT_CRITICAL();

そもそも共有変数を作らないのが最善である.

FreeRTOS が提供している通信路(キュー・通知・ストリームバッファ)は、 すべて内部で正しく排他制御されている。 それを使えば、自分で排他を書く必要がない。

「グローバル変数 + volatile + クリティカルセクション」は、 最後の手段であって、既定の選択肢ではない。

6. 割り込みレイテンシ

\[ L_{\text{割込}} = L_{\text{ハードウェア}} + T_{\text{最長割込禁止}} + T_{\text{上位割込}} \]
項内容典型値
\(L_{\text{ハードウェア}}\)CPU が割り込みを受け付けてハンドラに入るまでCortex-M: 12 サイクル
\(T_{\text{最長割込禁止}}\)クリティカルセクションの最長時間設計次第
\(T_{\text{上位割込}}\)自分より高優先度の割り込みの実行時間設計次第

FreeRTOS のクリティカルセクションは短く設計されている(数十サイクル程度)が、 アプリケーション側で長いクリティカルセクションを書くと、そこが支配的になる。

割り込みレイテンシを見る

クリティカルセクションの長さと割り込みの優先度を変えて、 レイテンシがどう変わるかを確かめられる。

configMAX_SYSCALL_INTERRUPT_PRIORITY より高い割り込みは、 T_{最長割込禁止} の影響を受けない.

これが「超低レイテンシが要る処理を、その領域に置く」理由である。 ただしそこから FreeRTOS API は一切呼べないので、 結果をタスクに渡すには、別の手段(DMA、ハードウェア FIFO、 あるいは低優先度の割り込みを自分で保留する)が必要になる。

7. この章のまとめ

ポイント内容
ISR 版が要る理由ブロックできない・排他の方法が違う・切り替えのタイミングが違う
切り替えISR では行わず、xHigherPriorityTaskWoken で教える
portYIELD_FROM_ISRISR の最後に1 回だけ。忘れると 1 ティック遅れる
タイマコールバック通常版を使う(タイマタスクの中だから)
割り込み優先度Cortex-M は数値が小さいほど高。タスク優先度と逆
configMAX_SYSCALLこれより高い割り込みからは API を呼べない
「呼べる」と「止められる」同じことの裏表。BASEPRI で実現されている
検出configASSERT があれば設定ミスが即座に捕まる
設計原則ISR は 10 µs 以下。本処理は遅延タスクへ
共有変数volatile は必要だが不十分。そもそも作らないのが最善

次章では、この排他制御をシステム全体でどう設計するか—— デッドロック・レース・再入の問題を扱う。