💨

Wio TerminalのFreeRTOSの構造

2020/12/18に公開

Wio TerminalのFreeRTOSの構造

この記事は Wio Terminal Advent Calendar の18日目の記事です。

Wio TerminalのFreeRTOSは、現時点で割り込み周りの扱いに問題があるので、原因と対策について説明します。

Wio TerminalのFreeRTOS

Wio TerminalのFreeRTOSは、 Seeed_Arduino_FreeRTOS ライブラリとして分離されています。

元々は Seeed_Arduino_FreeRTOSWio Terminal向けのArduino core には含まれておらず、個別にzipファイルをダウンロードしてインストールする必要がありました。
ただし、現在では Arduino core に 含まれている ため、個別にインストールする必要はありません。

FreeRTOSの使い方

元々 Seeed_Arduino_FreeRTOS は、Wio TerminalのWi-Fi/BLE通信用のライブラリ Seeed_Arduino_atUnifiedSeeed_Arduino_rpcUnified で使うことを目的として作られています。
アプリケーション自体の処理と平行して効率よく無線通信処理を行うためには、FreeRTOSなどのRTOSが合った方が便利だからです。

上記の Seeed_Arduino_atUnifiedSeeed_Arduino_rpcUnified を使う場合は、これらのライブラリ側でFreeRTOSの初期化処理を行うので、アプリケーションの開発者は特に何もする必要はなく、そのままFreeRTOSのAPIを使えます。

逆に、Seeed_Arduino_atUnifiedSeeed_Arduino_rpcUnified を使わない場合は、アプリケーション開発者自身でFreeRTOSの初期化処理を記述する必要があります。

このためのサンプルが Seeed_Arduino_FreeRTOSBasic_RTOS_Example サンプル・スケッチです。

FreeRTOSの初期化処理

FreeRTOSのスケジューラーを実行するだけであれば、 vTaskStartScheduler() を呼び出すだけで終わりですが、Arduino環境で使うためには、その前に loop() 関数を実行するためのタスクを起動しないといけません。

元々はFreeRTOSのIdleタスク実行時に loop() が呼ばれる実装だったようですが、ソースコード上のコメントにある通り、この方法だと setup() が呼ばれる前に loop() が呼ばれてしまうバグがあったようで、現在ではコメントアウトされています。

よって、vTaskStartScheduler を呼び出す前に、 xTaskCreate を呼び出して loop() を実行するタスクを作る必要があります。
xTaskCreate に渡すタスクの処理の関数は、void* 型の引き数を一つとる、 void (*)(void*) 型の関数ポインタであり、タスクが生存している限り関数から抜けないようになっている必要があります。
このため、 loop() を呼び出し続けるラッパーとして loopTask 関数を定義して xTaskCreate に渡しています。

xTaskCreate の第3引き数はタスクのスタック・サイズを指定します。 loop() で実行する処理で必要十分な量を割り当てないと、スタック破壊による割と分かりづらいバグとなりますので注意が必要です。
今回はとりあえず 2048 バイトにしています。

static TaskHandle_t task = nullptr;

void loop();
static void loopTask(void*)
{
    for(;;) {
        loop();
        vTaskDelay(0);
    }
}

void setup() {
    xTaskCreate(loopTask, "loop", 2048, nullptr, 0, &task);
    vTaskStartScheduler();
}

void loop() {
  // ...
}

FreeRTOSで割り込みを使う

RTOSを使っている場合、割り込み処理中で必要最低限の処理を行い、残りの処理はキューに入れて高優先度のタスクで実行することが多いと思います。
この場合、FreeRTOSのAPIを割り込みハンドラから呼び出すことになりますが、FreeRTOSには割り込みハンドラから呼び出す専用のAPIが用意されています。
これらのAPIは関数名の末尾に FromISR が付いています。

例えばキューに値を入れるAPIは xQueueSend ですが、ISRから呼び出す場合は xQueueSendFromISR となります。

ただし、FromISR が付いているAPIであっても、一定以上の優先度の割り込みハンドラからは呼び出せないようになっています。

このときの優先度の最大値は configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY として、 FreeRTOSConfig.h に定義されています。執筆時点の Seeed_Arduino_FreeRTOS での実装では 5 です。

よって、割り込みを使う場合は、configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY 以上の値を指定して
NVIC_SetPriority を呼び出して、割り込みの優先度を下げておく必要があります。 (※Cortex-Mの割り込み優先度は大きい値ほど優先度が低い)

以下の例では、TC3の割り込み優先度をFreeRTOS APIを呼び出せる優先度の最大値に設定します。

NVIC_SetPriority(TC3_IRQn, configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY);

現時点での問題

前述の通りFreeRTOSを使い、割り込みハンドラからFreeRTOS APIを呼び出すアプリケーションを作成すると、アプリケーションがクラッシュします。

実際にどこでクラッシュしているのかデバッガを使って追いかけると、FreeRTOS内部のアサーションに引っかかっていることが分かります。

アサーション失敗

このアサーションは、APIを呼び出した割り込みハンドラの優先度が、前述の優先度より低いかどうか (優先度の数値が configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY 以上かどうか) をチェックしています。
よって、アサーションに引っかかったということは、割り込みの優先度が configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY より高くなっているということです。

しかし、前述の通り、NVIC_SetPriority(..., configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY) を呼び出して、割り込みの優先度を configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY に設定しているため、このアサーションに引っかかるのは、何かおかしいということになります。

configASSERT( ucCurrentPriority >= ucMaxSysCallPriority );

このアサーションの式のうち、 ucCurrentPriority には、APIを呼び出した割り込みの優先度の値が入っています。また、 ucMaxSysCallPriority はAPIを呼び出せる割り込み優先度の最大値が入っています。

デバッガでそれぞれの値を見ると、 ucCurrentPriority = 64ucMaxSysCallPriority = 126 となっています。

ここで、WioTerminalで使われているSoC ATSAMD51P19A のCPUコア Cortex-M4F の割り込み優先度レジスタ IPR の仕様を確認すると、8bitのうちCPUの構成に応じた上位数ビットのみが実際に優先度として使用できることが分かります。(詳しくは Cortex-M4 Devices Generic User Guide の p.4-7 を参照)

IPR の使用可能なビット数は、Arduino coreに付属しているツールセットに含まれる CMSIS-Atmel パッケージ内のヘッダ atsamd51p19a.h__NVIC_BITS として定義されています。現在のバージョンでは #define __NVIC_BITS 2 として定義されています。

NVIC_SetPriority は内部的には __NVIC_BITS を用いて IPR レジスタに

priority << (8u - __NVIC_PRIO_BITS)

の値を計算して設定します。これにより、 上位 __NVIC_PRIO_BITSpriority の値が設定されます。

デバッガで確認した ucCurrentPriorityucMaxSysCallPriority の値は、同様に 8 - __NVIC_PRIO_BITS つまり、6ビット左シフトした値になっています。

configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY << 6 == 320 なので、320となるはずですが、優先度レジスタは8bit幅のため、実際には 320 & 255 == 64 となっています。10進数の 5 を表現するためには、最低3ビット必要なのに、 __NVIC_PRIO_BITS が2となっているため、上位1ビットが無くなってしまっています。

一方、FreeRTOS側で定義している定数 ucMaxSysCallPriority の値は 126 == 5 << (8 - 3) となっています。
これは、Seeed_Arduino_FreeRTOSFreeRTOSConfig.h の内部で __NVIC_PRIO_BITS#undef して、本来 __NVIC_PRIO_BITS と同じ値に定義されるはずの configPRIO_BITS の値を 3 として定義しているためです。

/* Cortex-M specific definitions. */
#undef __NVIC_PRIO_BITS
#ifdef __NVIC_PRIO_BITS
	/* __BVIC_PRIO_BITS will be specified when CMSIS is being used. */
	#define configPRIO_BITS       		__NVIC_PRIO_BITS
#else
	#define configPRIO_BITS       		3        /* 8 priority levels */
#endif

では、なぜこのような定義をしているのかというと、このように定義しなければ、別のアサーションに引っかかるためです。

この問題の根本原因は、CMSIS-Atmel__NVIC_PRIO_BITS の定義が間違っていることです。
ATSAMD51P19Aのデータシート (DS60001507F) p.63 によると、ATSAMD51P19Aの割り込みは8つの優先度レベルがあるとの記載があるので、正しくは __NVIC_PRIO_BITS3 のはずです。
実際、新しいバージョンの CMSIS-Atmel では __NVIC_PRIO_BITS3 として定義されています。

現状、アプリケーション開発者として取れる対策は、優先度が上位2ビットのみ使用可能である前提で NVIC_Priority を呼び出すことです。たとえば、 NVIC_Priority(..., 3) とすれば、現時点で設定可能な優先度を指定できますが、実質的にこの優先度しか使えません。

より柔軟な優先度設定を使えるようにするためには、Seeed公式によるArduino core内の __NVIC_PRIO_BITS の定義の修正を待つ必要があります。

割り込み中のFreeRTOS API呼び出しの動作確認

上記の対策にてFreeRTOS APIが割り込みハンドラから呼び出せることを確認するサンプルは次の通りです。
(GitHubのリポジトリにも置いてあります)

#include <FreeRTOS.h>
#include <queue.h>
#include <cstdint>
#include <sam.h>
#include "SAMD51_TC.h"

static TaskHandle_t task = nullptr;

static QueueHandle_t  queue = nullptr;

// ISR handler called when the TC3 period has elapsed.
static void isrCallback()
{
    // Post counter value to the loop task.
    static std::uint8_t value = 0;
    xQueueSendFromISR(queue, &value, nullptr);
    value += 1;
}

void loop();
static void loopTask(void*)
{
    for(;;) {
        loop();
        vTaskDelay(0);
    }
}

void setup() {
    queue = xQueueCreate( 32, 1 );
    configASSERT(queue);

    TimerTC3.initialize();
    TimerTC3.setPriority(3);
    // Uncomment if you want to reproduce the Seeed_Arduino_FreeRTOS priority issue.
    //TimerTC3.setPriority(configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY);
    TimerTC3.attachInterrupt(isrCallback);

    Serial.begin(115200);
    Serial.println("start");

    xTaskCreate(loopTask, "loop", 2048, nullptr, 0, &task);
    vTaskStartScheduler();
}
void loop() {
    std::uint8_t value;
    // Receive the value sent from the TC3 ISR.
    xQueueReceive(queue, &value, portMAX_DELAY);
    Serial.println(value);
}

タイマー TC3 を使って1秒間隔で割り込みを発生させ、割り込みハンドラから isrCallback を呼び出しています。
タイマー周りの初期化処理は SAMD51_TC.h を使っています。 TimerTC3.attachInterrupt 内部で TimerTC3.setPriority() に指定した値をそのまま NVIC_SetPriority に渡して呼び出しています。

元のコードのまま実行すると、Wio TerminalのUSBシリアル経由で0~255までの値が1秒ごとに出力されます。

コード中の

// TimerTC3.setPriority(configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY);

をコメントアウトすると、優先度として configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY を指定することになるので、前述の通りアサーションに引っかかって動作しなくなります。

実際にアサーションに引っかかったかどうかはデバッガを接続しないと確認しにくいですが、デバッガをお持ちの方は試してみてください。

おわりに

以上で、プライオリティに関するワークアラウンドを適用すれば、とりあえずWio TerminalでFreeRTOSを使った割り込み処理ができることがわかると思います。

修正方法自体もわかっていますが、修正できるのはSeeedの中の人だけなので、一応報告をしておこうかと思います。

2020/12/22 追記: 次のリリース時に修正が適用されるようです。

https://github.com/Seeed-Studio/ArduinoCore-samd/issues/40

https://github.com/Seeed-Studio/Seeed_Arduino_CMSISAtmel/commit/87a1dce4e0603e6d522deb5302df8ac740041dfb

Discussion