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() — 割り込み禁止+ネストカウント |
| ISR | portSET_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 版 |
|---|---|
xQueueSend | xQueueSendFromISR |
xQueueReceive | xQueueReceiveFromISR |
xSemaphoreGive | xSemaphoreGiveFromISR |
xSemaphoreTake | xSemaphoreTakeFromISR |
xTaskNotify | xTaskNotifyFromISR |
vTaskNotifyGive | vTaskNotifyGiveFromISR |
xEventGroupSetBits | xEventGroupSetBitsFromISR(タイマタスク経由) |
xTimerStart | xTimerStartFromISR |
xTaskGetTickCount | xTaskGetTickCountFromISR |
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は「この値以下の優先度(=数値が同じか大きい)の割り込みをマスクする」レジスタである。 だから——
configMAX_SYSCALL以下の割り込みは、クリティカルセクション中は入ってこない → カーネルのデータ構造を安全に触れる → API を呼んでよいconfigMAX_SYSCALLより高い割り込みは、クリティカルセクション中でも入ってくる → カーネルのデータ構造が中途半端な状態かもしれない → API を呼んではいけないつまり「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 だが、実装が使うのは上位数ビットだけである。
| チップ | 実装ビット数 | 有効な値 |
|---|---|---|
| STM32F4 | 4 bit | 上位 4 bit(0, 16, 32, ..., 240) |
| nRF52 | 3 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 側から共有データを触るとき |
単一の書き手・単一の読み手 + volatile | 32 bit 以下のフラグの受け渡しのみ |
/* タスク側で共有データを読む */
uint32_t local;
taskENTER_CRITICAL();
{
local = g_counter;
g_counter = 0;
}
taskEXIT_CRITICAL();そもそも共有変数を作らないのが最善である.
FreeRTOS が提供している通信路(キュー・通知・ストリームバッファ)は、 すべて内部で正しく排他制御されている。 それを使えば、自分で排他を書く必要がない。
「グローバル変数 +
volatile+ クリティカルセクション」は、 最後の手段であって、既定の選択肢ではない。
6. 割り込みレイテンシ
| 項 | 内容 | 典型値 |
|---|---|---|
| \(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_ISR | ISR の最後に1 回だけ。忘れると 1 ティック遅れる |
| タイマコールバック | 通常版を使う(タイマタスクの中だから) |
| 割り込み優先度 | Cortex-M は数値が小さいほど高。タスク優先度と逆 |
configMAX_SYSCALL | これより高い割り込みからは API を呼べない |
| 「呼べる」と「止められる」 | 同じことの裏表。BASEPRI で実現されている |
| 検出 | configASSERT があれば設定ミスが即座に捕まる |
| 設計原則 | ISR は 10 µs 以下。本処理は遅延タスクへ |
| 共有変数 | volatile は必要だが不十分。そもそも作らないのが最善 |
次章では、この排他制御をシステム全体でどう設計するか—— デッドロック・レース・再入の問題を扱う。