MicroPythonにTimerクラスを追加する(RPiベアメタル)

timerclass-sample.png

先日の実験でRaspberry Piのタイマ割込処理がだいたい分かったので、MicroPythonにこのタイマ割り込みを実装してみました。

MicroPythonにはTimerクラスがあり、タイマーを使ってコールバック関数を呼ぶことができます。
タイマー起動後は、コールバック関数は自動で呼ばれますので、マルチタスクのようなことができます。
例えば、一定周期でLEDを点滅させるという処理をしながら、シリアルポートでデータを読み書きできます。
また、一定時間後に一度だけ実行するONE_SHOTモードもあります。

以下は今回作成したTimerクラスを使った簡単なサンプルです。


System Timerには4つのタイマーがありますが、今回はとりあえずタイマー3だけを実装しています。
inc_ctr()がコールバックさせる関数で、引数tにはタイマーオブジェクトが入ります。
この関数はグローバル変数counterを1ずつ増やします。

タイマーをinitメソッドで初期化するとタイマーが動作を開始します。
今回の実装ではクロックを1MHzとしているので、periodの単位は1マイクロ秒です。
period=1000000だと、1,000,000マイクロ秒つまり1秒周期でinc_ctr()が繰り返し呼ばれます。

一方、直後のwhileループはcounterが3以上になるまでループします。
ループしている間も、inc_ctr()は1秒おきに呼ばれていますので、3秒後にこのループをから抜けます。
すると今度は、ワンショットモードでタイマーを起動していますので、1秒後に”done!”とプリントアウトします。

Timerクラスはハードウェアタイマに強く依存しているため、同時使用できるタイマーの個数などの詳細仕様はCPU毎に異なっています。
Raspberry Piでは、以前解説したように、使用できるタイマはARM Timerが一つと、System Timerが2つです。
今回は、System Timerを1つだけ使い、インスタンスも1つしか持てないTimerクラスを実装してみました。

MicroPythonへのクラスの追加については以前も書きましたので省略します。

今回のポイントは割り込みの処理です。
まず、MicroPythonのメイン関数に割り込みベクタの設定とIRQ有効化の処理を追加します。
そして、IRQの割り込みハンドラの中でTimerクラスに関する処理を行います。
割り込みハンドラのコードは以下のようにしました。


割り込みの原因がシステムタイマ3だった場合、timer_root.callbackに保存されているコールバック関数を実行します。
ただし、割り込みハンドラの中で実行するのではなく、この関数を実行するというタスクを実行待ちキューへ追加します。
この処理はmp_sched_schedule()で行います。

コールバック関数が実際に実行されるのは、MicroPythonのメインの実行ループの中で関数mp_handle_pending()が呼ばれたときです。
つまり、適切なタイミングでmp_handle_pending()を呼んでやる必要があります。

MicroPythonのREPLが動いているときは、シリアルポートの入力待ちをしている間、ずっとIdleループになっています。
入力待ちの間もコールバック関数が実行されるように、ループの中からもmp_handle_pending()を呼ぶように処理を追加しました。
ちなみに他のプラットフォームでのMicroPythonの実装を見ると、ループの中で行うべき諸々の処理をまとめてmpconfigport.hの中でMICROPY_EVENT_POLL_HOOK というマクロを定義しています。
そして、上記のシリアルポート入力や、delay_ms()など、様々なループからこのマクロを呼び出すようになっています。

mp_sched_schedule's scheduled callbacks don't run on empty REPL lines · Issue #3273 · micropython/micropython

このような実装なので、タイマ割り込みのタイミングはあまり正確ではありません。
ESP8266の実装に関して、D.P.Georgeは「100 usec以下の精度を求めるならC、アセンブラ、あるいはViperを使うべき」と書いています。
ちなみにViperとは、組み込み向けリアルタイムPythonだそうで、現在はZerynthiに改名されているようです。

esp8266: can't time pulses with good accuracy · Issue #2052 · micropython/micropython

なお、今回実装にあたってSTM32、ESP8266、ESP32のTimerクラスの実装も調べました。
Timerクラスはどうしてもハードウェアタイマに依存しますので、プラットフォームごとの仕様の違いがかなりありました。

STM32シリーズは制御用マイコンなので、ハードウェアタイマーの個数も機能も多く、定期実行やワンショットなどの他に、PWMにも使えます。(AVRと似ています。)
MicroPythonの実装も、ハードウェアタイマーのパラメータをいろいろ設定できますし、コールバック関数を単に待ち行列に追加するのでなく、割り込みハンドラの内部で実行できるようになっています。
ただ、割り込みハンドラ内でMicroPythonを実行することになりますから、コンテキストの保存には十分配慮する必要があると思われます。
実装を見ると、nlr(non-local return)関連の関数が使われています。
これはUNIX等におけるsetjmp/longjmpのプラットフォームに特化した実装のようです。

class Timer – control internal timers — MicroPython 1.9.3 documentation

対照的に、ESP8266はハードウェアタイマのスペックが比較的貧弱で、EspressifのSDKにも依存するためか、機能はかなり削られています。
また、バーチャルタイマーという概念が導入されています。
これはESP8266でリアルタイムOSを動作させ、リアルタイムOS上のタスクとしてタイマのカウントをソフトウェア的に行うもののようです。
周期は1msec単位で指定できますが、精度は粗く、周期があまり一定していないという評価がフォーラムでもいくつか出ていました。
仮に精度が1msecあったとしても、60Hzの信号でも周期が2~4%ずれることになりますので、用途によってはちょっと微妙な場合もありそうです。

class Timer – control hardware timers — MicroPython 1.9.3 documentation

ESP32はハードウェアタイマが4つありますが、周期の指定は同様に1msec単位のようです。

ESP32 MicroPython: Timer interrupts | techtutorialsx

また、delay_ms()を実行中にmp_handle_pending()が呼ばれるのは10msecに一度であり、これはESP32で使用しているFreeRTOSのタスクスイッチの単位が10msecであるためだそうです。
delay_ms()でなくforループの場合は、ループ一周ごとにpendingされているタスクを実行できるようです。

esp32: time.sleep_us() doesn't check for pending events · Issue #3493 · micropython/micropython

コメント