Lチカしながら音楽再生・Arduino UNO R4のタイマと割り込みとクロック事情
このブログ初の記事 で、ビュートローバーARM が T-SQUARE の TRUTH を BGM 再生しながら疾走する映像を貼りましたが、この度この再生ソフトを Arduino UNO R4 に移植しました。表題の通りタイマとか割り込みとかクロックとか、調べることが多々あり苦戦しましたが、だいぶ理解が進んだのでここにお披露目したいと思います!
-
CallbackTimerR4
指定サイクルで割り込みを生成し、登録した関数を実行することができるライブラリです。 -
BackgroundMusicR4
Arduinoに何かをさせながらtone()でBGMを流すことができるライブラリです。 -
Arduino-UNO-R4/background_music
Lチカしながら大好きな TRUTH を奏でるデモスケッチです。
映像は「Arduinoでライントレース用ラップタイマーを作る」で紹介したラップタイマに移植した時の動作の様子です。
Lチカしながら音楽再生・Arduino UNO R4のタイマと割り込みとクロック事情https://t.co/Rt2UcFZT3u pic.twitter.com/cY1WBBwdS9
— Kingsman (@EmbeddedKiddie) April 4, 2024
以降は、表題に関する調査と学習の結果報告になります
tone()関数の問題点と対策
チュートリアル にあるノンブロッキングな関数 tone()
の典型的な使い方は、直後に delay()
を呼び出しブロックする、次のようなパターン。
tone(pin, frequency, duration);
delay(duration);
これを楽譜に合わせて次々に音符を再生するワケですが、これだと音を鳴らす以外に何も出来ません。
作成したライブラリの仕組み自体はすごく簡単で、delay()
の代わりに 音符の長さだけ時間が経過したら割り込みをかけます。割り込みハンドラ では、noTone()
で音を止めた後、音符の配列データから次の音符(周波数と音符長)を読み込み、次の割り込みを設定する - この繰り返しです。
この処理は、Lチカのプログラムとは何のインタラクションも無く完全に独立しているため、loop()
には以下のLチカのコードがあるだけです。
void loop() {
digitalWrite(LED_BUILTIN, HIGH);
delay(500);
digitalWrite(LED_BUILTIN, LOW);
delay(500);
}
まずはこの動作を実現できる Arduino ライブラリの調査から始めました。
Arduino UNO R4 のタイマ系ライブラリの状況
Arduino のタイマ系ライブラリを大きく分けると、MsTimer2
などハードウェア・タイマを利用するタイプと、AsyncTimer
の様に loop()
中に millis()
や micros()
でタスク起動のタイミングを管理するコードを埋め込むタイプとがあります。
両者とも IDE からインストールできるものをいくつかトライしましたが、前者は ARV 系でないと動作しないものしか見つからず、後者は CPU やボードに依存しないものの、タイマの学習にはならないので今回は見送りです。
また WASHIYAMA GIKENさん が Github 上で AGTimer_R4_Library
を公開されていて、「これだ!」と思い実装を進めていったのですが、要件に合わなかったり(後述)多少動作が不安定なところがあったため、最終的に自作するハメとなりました。
Arduino UNO R4 ハードウェア・タイマの仕組み
ということで、あらためてルネサス R7FA4M1AB(RA4M1 グループ)が持つタイマの仕組みから紐解きたいと思います。あくまで初心者目線ですので、よくご存知の方はスッ飛ばしてください。まずは一般論から。
マイコン内蔵タイマの仕組み
マイコン内蔵のタイマには、CPU コアと密に関係するシステムタイマ(SysTickタイマ)や USB や UART、SPI などの I/F で同期やタイミングを制御するためのタイマ、RTC(リアルタイムクロック)、WDT(ウォッチドッグタイマ)など、周辺回路に専用のタイマがありますが、ここでは PWM 制御などに使えるなど汎用的なタイマについて見ていきたいと思います。
汎用タイマの仕組みを理解するには、ストップウォッチを想定すると良いです。ザックリですが、この種のタイマは次の図のような幾つかの基本要素で構成されてます。
-
秒針1周分に相当する 総カウント数
8ビットから32ビット まで、マイコンの種類と用途に応じて色々あります。 -
1目盛分の 刻み幅 を設定するレジスタ
秒針を刻むための信号源として、外部信号と内部クロックが切り替えられます。内部の場合には メインクロック 以外にも消費電力を抑えるため、より低い周波数のクロックを発生させる オシレータ を使うことが出来たりします。
さらにクロックの周波数を 1/4 や 1/256 などにする 分周比 の設定が可能です。 -
秒針の進みを数える カウンタ・レジスタ
1目盛1カウントとして刻むカウンタです。例えば16ビットタイマは 0 〜 65535 まで、32ビットタイマは 0 〜 4294967295 まで数えられます。
秒針が1周すると 0 に戻る、即ちオーバーフローするので、アプリケーション側で適切に扱えるよう、オーバーフロー時に割り込みを発生させる機能があったりします。 -
タイミング を決めるレジスタと 動作 を制御するレジスタ
秒針の進みに応じて、イベントの発生タイミングを設定するレジスタ(カウンタ・レジスタとの比較で決めるため、「コンペア・レジスタ」とか「マッチ・レジスタ」と呼ばれる)と、割り込みの発生や、汎用出力ポートへの HIGH または LOW 信号の出力など、イベント発生時の動作を制御するレジスタがあります。
具体例を挙げてみます。例えば、カウンタが
- レジスタ A
の設定値に到達したら、汎用出力ポートを HIGH にする
- レジスタ B
の設定値に到達したら、汎用出力ポートを LOW にする
- レジスタ C
の設定値に到達したら、タイマをリセットする
という設定では、1目盛分の刻み幅と A・B・C の設定値から決まる、ある 周波数 と デューティ比 の PWM 出力を作り出せることになります。
また、このような HIGH か LOW の出力だけでなく、所定の LSB ずつ上げ下げすれば、三角波やノコギリ波の出力が可能ですし、制御する出力ポートを複数にすれば、多相モータの制御が可能となるなど、およそ汎用という名に相応しい、複雑な動作を実現できます。
RA4M1 のタイマ
一般論の次は、ルネサスのマイコン R7FA4M1AB のタイマです。Arm Cortex-M4 コアの直近で一定の時を刻む 24 ビットの SysTick
タイマ(システムタイマ)のほか、アプリケーション用の高機能なタイマとして以下が備わっています。
- GPT32 × 2
- GPT16 × 6
- AGT × 2
- RTC
- WDT / IWDT
特に2つの汎用タイマ 〜 GPT(32ビットが2個、16ビットが6個)と AGT(16ビットが2個)〜 が今回の調査のお題です。
GPT とは
General Pulse width modulation Timer、即ち汎用の PWM 用タイマです。前章 でタイマの基本要素をいくつか紹介しましたが、実際のレジスタは11種類、235個もあり、矩形波やノコギリ波、三角波の生成や、単純な DC モータのデューティ制御から三相モーター用の出力まで、多様なニーズに応える動作モードの設定ができるようになっています。
AGT とは
Asynchronous General purpose Timer の略で、ユーザーズマニュアルによると「低消費電力非同期汎用タイマ」とのことです。クロック発信源の1つとして 32.768KHz の「低速オンチップ オシレータ」が選択でき、汎用タイマ機能のほか、外部信号により低消費電力モードから復帰する際のフィルタ機能として、「外部パルスの幅または周期の測定や、外部イベントのカウントが可能」とのことです。
GPTと比較すると関連するレジスタも少なく、5種類、10個です。
Arduino UNO R4 のタイマ事情
GPT や AGT を汎用タイマとして機能させるには、次のようなステップが必要です。
- クロック発信源(ソース)の選択と分周比の設定
- 動作モードの設定
- コンペアマッチレジスタの設定
- 割り込みの許可と優先度の設定
- カウンタレジスタのクリア
- タイマのスタート
Arduino UNO R4 ではこのようなウザい詳細を隠蔽し、使われていないタイマを引き当て、周波数とデューティー比(あるいはクロック周期と分周比)を指定すれば、後は良しなにやってくれる関数が FspTimer.cpp
に定義されています。
FSP(Flexible Software Package)はルネサス製 ARM マイコンのソフトウェア群で、Qiita の記事「Arduino UNO R4のFspTimerライブラリの使い方」を参考に、実際に動作させてみて分かったことを紹介します。
汎用タイマの空き状況
全部で10個ある汎用タイマのうち、GPT は32ビットが2個と16ビットが1個、AGT は16ビット1個が既に Arduino 側で使用済みです。残りの5個は全て16ビットで、GPT が4個、AGT が1個という状況です。
使用済みタイマのうち millis()
と micros()
で AGT が 使われています。
割り込みハンドラの怪?
attachInterrupt()
の解説によると、割り込みハンドラ関数内では多重割り込みが禁止されていて、故に millis()
がインクリメントされず delay()
が機能しない旨の記述があります。が、UNO R4 の場合、タイマや外部入力ピンへの割り込みハンドラ内で millis()
も delay()
も使えます。
おそらく ARM の NVIC(Nested Vector Interrupt Controller、多重型ベクタ割り込みコントローラ)の効果だと思いますが、ユーザ側で複数の割り込みを使う場合、優先順位の調査が必要かと思います(未調査です、スミマセン…)。
クロック周波数
FspTimer.cpp
で設定可能なクロックです。
-
GPT
サブクロックのPCLKD
が発信源で、48MHz がデフォルト。分周比は 1、4、16、64、256、1024 が選択可能。 -
AGT
サブクロックのPCLKB
が発信源で、24MHz がデフォルト。分周比は 1、2、8 が選択可能。
サンプルプログラム
FspTimer
クラスの基本的な2つの使い方を示します。
- PWM 出力用(FspTimer.cpp#L182-L184)
/* -------------------------------------------------------------------------- */
bool FspTimer::begin(timer_mode_t mode, uint8_t tp, uint8_t channel, float freq_hz, float duty_perc, GPTimerCbk_f cbk /*= nullptr*/ , void *ctx /*= nullptr*/ ) {
/* -------------------------------------------------------------------------- */
※ 周波数 490Hz、Duty 50% を出力する専用の関数 FspTimer::begin_pwm()
もあります。
- RAW モード(FspTimer.cpp#L62-L65)
/* begin function RAW mode */
/* -------------------------------------------------------------------------- */
bool FspTimer::begin(timer_mode_t mode, uint8_t tp, uint8_t channel, uint32_t period_counts, uint32_t pulse_counts, timer_source_div_t sd, GPTimerCbk_f cbk /*= nullptr*/ , void *ctx /*= nullptr*/ ) {
/* -------------------------------------------------------------------------- */
また実際には2つの 32 ビットタイマー GPT32 は既に使われているので、16 ビットのタイマーしか得られません。そのため、それぞれ以下より長い周期のタイマーは設定できません。
- GPT は、
1/(48MHz ÷ 1024) × 65535 = 1398.08 [msec]
まで - AGT は、
1/(24MHz ÷ 8) × 65535 ≒ 21.85 [msec]
まで
以下の例では ワンショットタイマーの TIMER_MODE_ONE_SHOT
を指定していますが、TIMER_MODE_PERIODIC
を指定すれば周期的な割り込みを発生させることが出来ます。
#include <Arduino.h>
#include <FspTimer.h>
#include <math.h> // for round()
FspTimer fsp_timer;
volatile uint32_t lap_time;
volatile bool irq_fired = false;
static void IrqCallback(timer_callback_args_t* p_args) {
lap_time = millis() - lap_time;
irq_fired = true;
}
void setup() {
Serial.begin(115200);
while (!Serial);
pinMode(LED_BUILTIN, OUTPUT);
// タイマーのプールから使えるタイマーを取得します
// 強制的に AGT_TIMER を使うには、`type = AGT_TIMER; channel = 1;` とします
uint8_t type;
int channel = FspTimer::get_available_timer(type); // GPT_TIMER が優先して取得されます
if (channel == -1) {
Serial.println("Cannot get timer.");
return;
}
#if 0
// 10 msec(100 Hz)のワンショットタイマーをセットします
// PWM 出力ではないので、Duty 比の 50% は 100% を指定しても OK です
fsp_timer.begin(TIMER_MODE_ONE_SHOT, type, channel, 100.0 /* [Hz] */, 50.0 /* [%] */, IrqCallback, nullptr);
#else
/* PCLK の分周比は以下の様に定義されています
https://github.com/arduino/ArduinoCore-renesas/blob/main/variants/MINIMA/includes/ra/fsp/inc/api/r_timer_api.h#L130-L144
typedef enum e_timer_source_div
{
TIMER_SOURCE_DIV_1 = 0, ///< Timer clock source divided by 1
TIMER_SOURCE_DIV_2 = 1, ///< Timer clock source divided by 2
TIMER_SOURCE_DIV_4 = 2, ///< Timer clock source divided by 4
TIMER_SOURCE_DIV_8 = 3, ///< Timer clock source divided by 8
TIMER_SOURCE_DIV_16 = 4, ///< Timer clock source divided by 16
TIMER_SOURCE_DIV_32 = 5, ///< Timer clock source divided by 32
TIMER_SOURCE_DIV_64 = 6, ///< Timer clock source divided by 64
TIMER_SOURCE_DIV_128 = 7, ///< Timer clock source divided by 128
TIMER_SOURCE_DIV_256 = 8, ///< Timer clock source divided by 256
TIMER_SOURCE_DIV_512 = 9, ///< Timer clock source divided by 512
TIMER_SOURCE_DIV_1024 = 10, ///< Timer clock source divided by 1024
} timer_source_div_t;
*/
// 取得したタイマーの発信源となる周辺回路用クロック(PCLK)の周波数を取得し分周比を設定します
uint32_t clock_hz;
timer_source_div_t src_div;
if (type == GPT_TIMER) {
clock_hz = R_FSP_SystemClockHzGet(FSP_PRIV_CLOCK_PCLKD);
src_div = TIMER_SOURCE_DIV_1024; // 1, 4, 16, 64, 256, 1024
Serial.print("GPT_TIMER ");
} else {
clock_hz = R_FSP_SystemClockHzGet(FSP_PRIV_CLOCK_PCLKB);
src_div = TIMER_SOURCE_DIV_8; // 1, 2, 8
Serial.print("AGT_TIMER ");
}
// 10 msec 分のクロック数を算出します
uint32_t counts = round((10.0f /* msec */ / 1000.0f) * clock_hz / (1 << src_div));
// 分周比を合わせ、10 msec(100 Hz)のワンショットタイマーをセットします(カウントは0からスタート)
fsp_timer.begin(TIMER_MODE_ONE_SHOT, type, channel, counts - 1, 1, src_div, IrqCallback, nullptr);
Serial.print("channel:" + String(channel));
Serial.print(", clock:" + String(clock_hz));
Serial.print(", div:" + String(1 << src_div));
Serial.print(", counts:" + String(counts) + "\n");
#endif
// タイマーをスタートさせます
fsp_timer.setup_overflow_irq();
fsp_timer.open();
fsp_timer.start();
lap_time = millis();
Serial.println("Start.");
}
void loop() {
if (irq_fired) {
irq_fired = false;
digitalWrite(LED_BUILTIN, HIGH);
Serial.println("Fired after " + String(lap_time) + " msec");
}
}
自作のタイマ系ライブラリと BGM 再生ライブラリ
さて、楽譜には同じ高さの音を繋げて1つの音として演奏する タイ という記号が出てきます。例えば曲の速さが で、4分音符に16分音符が続くタイ
では、484ミリ秒の間、鳴らし続けなければなりません。また数秒間続く全音符のタイも度々登場します。
これを1ミリ秒刻みの割り込みで484回を数えたり、数千回を数えたりするのは CPU リソースの無駄使いで、数えるのはタイマに任せるのが得策です。
ここで問題なのは長い周期の場合で、16ビットを目一杯使い分周比を最大にしても GPT では計算式 より約 1398ミリ秒(小数点以下切り捨て)、AGT では
≒ 21ミリ秒(同)までしか計れません。AGT の場合、32.768KHz の低速オンチップ オシレータと分周比 1/64 が選択できれば
≒ 127秒まで計れますが、
FspTimer
クラスではこれが出来ません。
またオーバーフロー割り込みを設定するメソッド setup_overflow_irq()
を試してみましたが、うまく動かすことができませんでした。
WASHIYAMA GIKENさん の AGTimer_R4_Library
はどうだったかと言うと、プログラム上はクロック 32.768KHz、分周比 1/64 を設定している箇所があるのですが、およそ2秒を超えると想定通りに動作しません。ご自身でも書かれてますが、長周期の動作にデリケートなところがありそうで、使用を断念しました。
で、結局どうしたか?
オーバーフローフラグによる割り込みを模擬しました。
つまり一旦オーバーフローする直前に割り込みがかかるようタイマをセットし、ハンドラ内で残り時間を再セット、これを残り時間がゼロになるまで繰り返します。例えば2秒を計測する場合、GPT であれば(2000回の割り込みではなく)1.398秒後に1回だけ割り込みを追加します。AGT の場合は追加の割り込みが90回となりますが、それでも(2000回に比べたら)激減できます。
これで、どんなに長い音符も再生できるようになりました。メデタシ メデタシ。
次回は…
ハナシが長くなったので、ライブラリの解説は次回以降にさせていただきます