Arduino 周波数/周期カウンタ (2)

周波数を計測するのに、基準クロックから作成した正確な 1 ms とか 1 s とかの一定時間幅のゲート信号を使って入力信号をカウントする「普通」の方式では、周波数の分解能はゲート・タイムの逆数に比例する形になります。
たとえば、ゲート・タイム 1 s では周波数分解能は 1 Hz です。 オーディオ帯域の信号の下限の 20 Hz に対しては、分解能 1 Hz では誤差 5 % にもなってしまいます。
音程で数セント、比率で言えば 0.1 % 程度の誤差を問題にする場合には、ゲート・タイムを 50 s 程度にする必要があり、計測時間が長くなってしまいます。
この欠点は、「周波数」を直接計測するのではなく、入力信号の「周期」を測定しておいて、周波数は周期の逆数を「計算」して求めることにより解消できます。 この方式を「レシプロカル方式」と呼びます。 レシプロカル (reciprocal) とは「逆数」のことです。
周期の測定方法は、具体的には、カウンタのクロック入力を入力信号ではなく基準クロックとし、また、ゲート信号は基準クロックから作成するのではなく入力信号の1周期(あるいは複数周期)とします。
例として、基準クロック 10 MHz を 20 Hz の入力信号の1周期分の 50 ms だけゲートを開けてカウントするとカウントは 500,000 となり、周期の分解能は 0.1 μs になります。
Web 上には、PIC や AVR、あるいは PSoC などのマイコンを使った周波数カウンタの作例が多くあります。
マイコン内蔵のタイマ/カウンタだけで作ると制約が多いので、高性能、高機能をターゲットとする場合には、中心となるカウンタ部は CPLDFPGA などで外部ハードウェアとして構成し、マイコンはコントロールに使う方法が取られるようです。
ここでは、測定対象を可聴周波数域 (20 Hz 〜 20 kHz) のオーディオ信号とし、高性能も狙っていないので、

  • 外部ハードウェアは設けない
  • マイコン内部リソースの使用も最小にする

ことを目標に周期測定ライブラリを作成することにしました。
入力信号の複数サイクルに渡る周期を測定しようと思うと、一般には、基準クロックをカウントするメイン・カウンタのほかに、入力信号のパルス数をカウントするカウンタも必要になります。
しかし、ここでは、16 ビット・カウンタである Timer1 を唯一のハードウェア・リソースとしてメイン・カウンタに使用し、入力信号のパルス数のカウントはソフトウェアで実現して、ハードウェア・リソースは割り当てないことにします。
また、「周期」から「周波数」を求めるには、一般には実数での「除算」が必要になりますが、このライブラリでは「周期」あるいは「周波数」を求めるための整数データのみを返すこととし、実数演算に必要なソフトウェア・リソースも節約することにしました。
呼び出し側が、受け取った整数データから「計算」により、必要とする「周期」や「周波数」の値を求めます。
これは、マイコン自体をスタンドアロンな周波数カウンタとして使うのではなく、一連の測定の一部として周波数測定を行う場合、ホストの PC 側で必要なデータ処理を行うことが前提となりますから、その処理の中に (実数計算である) 周波数の計算を含めてしまえば、マイコン側に無理に実数計算をやらせる必要もなくなります。
たとえば、マイコン側からはシリアルを介して CSV 形式の整数データを PC 側に送り、PC 側では、出来上がった CSV ファイルを ExcelOpenOffice.org Calc などで読み込んでデータ処理するついでに周波数の計算をすれば良いことになります。
周期測定ライブラリの (ソフトウェア) インターフェース仕様としては、Arduino の公式 Web ページの Playground / Code Library のベージでも紹介されている「FreqCounter」ライブラリの仕様に習うことにしました。
「FreqCounter」は、C++ のクラスとしては書かれておらず、単に namespace 機能を利用して名前の衝突を防いでいるので、それに習い、このライブラリも「PeriodCounter」という namespace に置いた C プログラムとして実現しました。
ヘッダファイル「FreqCounter.h」を下に示します。

/***********************************************/
/* Period measurement library for Arduino 0017 */
/* 2009/12/6  Created by pcm1723               */
/***********************************************/
#ifndef PeriodCounter_h
#define PeriodCounter_h
#include <inttypes.h>
#include <avr/interrupt.h>

namespace PeriodCounter {
extern void start(int16_t ms);
extern void stop( void );

extern volatile int16_t  p_den;
extern volatile uint32_t p_num;
extern volatile uint8_t  p_ready; 
extern volatile int8_t   p_pin;
extern volatile uint8_t  p_mode;
} // namespace PeriodCounter
#endif // PeriodCounter_h

実際のプログラムでは PeriodCounter::start() のように、namespace 名を前に付けて完全な名前で指定するか、using 宣言/ディレクティブを使って namespace 名を省略するかのいずれかを使う必要がありますが、ここでの説明では、単に namespace 名は省略します。
使用方法を簡単にまとめると、

  1. p_pin 変数で使用する入力ピン番号を指定
  2. start() 関数でゲートタイムを指定して周期測定を開始
  3. (p_ready == 1) となるまで待つ
  4. p_num, p_den 変数に結果が入っているので読み出す

という流れになります。
周期測定を繰り返す場合には、入力ピンを変更しないのなら 2. の start() 関数の呼び出しに戻ればよく、再度 p_pin 変数を設定しなおす必要はありません。
start() 関数でセットアップされた後の処理は、すべて割り込みルーチン内で行われるので、周期測定ライブラリで使用するハードウェア・リソースと干渉しない限り、メインルーチン側では自由に処理が行えます。
(p_ready == 1) を検出して、p_num, p_den の値を読み取った後は、周期測定ライブラリで使用しているハードウェア・リソース (Timer1、アナログコンパレータ、ADC および入力マルチプレクサ) を使用しても構いません。
Timer1 の外部クロック入力はひとつしかありませんが、インプット・キャプチャ入力は ICP1 端子だけでなく、アナログコンパレータ経由で複数のピンから選択できますから、 p_pin 変数によって信号を入力するピンを選べるようにしました。
p_pin の値により、

  • 0 〜 6: アナログ 0 番ピン 〜 6 番ピン (ADC マルチプレクサ入力)
  • 7: ディジタル 7 番ピン (アナログコンパレータ入力、AIN1)
  • 8: ディジタル 8 番ピン (ディジタル入力、ICP1)

が選べます。
p_pin で 0 〜 7 を指定した場合には、アナログ・コンパレータや、ADC の入力マルチプレクサのハードウェア・リソースを使用します。 簡単のため、コンパレータの比較電圧は内蔵バンドギャップの 1.1 V に固定し、トリガするエッジの極性も固定にしてあります。
start() 関数を呼ぶ前に p_pin 変数を指定しておきます。 p_pin 変数のデフォルト値は 8、つまり、何も指定しない場合にはディジタル 8 番ピン、ICP1 が選択されることになります。
start() 関数では、周期測定に先立って必要なハードウェア・セットアップが行われます。
タイマ/カウンタとしては Timer1 だけが使われ、Timer0/Timer2 は使用しません。
Timer1 はプリスケーラなしの 16 ビット PWM モードに設定され、PWM 周波数は 16 MHz クロックの場合、約 244 Hz と低くなりますが、周期測定と並行して analogWrite() 関数で 16 ビット PWM DAC として機能させることができます。
16 ビット PWM モードで TOP 値を保持するために OCR1A レジスタが使われますので、Timer1 の PWM 出力として使えるのは、OC1B 出力であるディジタル 10 番ピンだけです。
FreqCounter ライブラリと仕様を合わせて、start() 関数の引数で ms 単位でのゲートタイムを指定します。
実際のゲートタイムの管理は、16 ビットカウンタのオーバーフローが何回発生したかを数えているだけなので、標準の 16 MHz クロックでは、2**16 / 16 [MHz] = 4.096 [ms] 単位でしか増減できません。
簡単のため、ms 単位で渡される引数の値を 4 で割って、オーバーフロー回数の値としています。 したがって、start() 関数の引数に 1000 (ms) を指定しても、実際のゲートタイムは 1024 (ms) になります。
また、ゲートタイムは、その時間内に発生した入力信号のエッジを検出する「ウィンドウ」なので、実際に捉えられる入力信号の (複数) 周期はゲートタイムよりも短くなります。
ゲートタイムを指定する引数は符号付き 16 ビット整数型ですから、最大値は 32767 となり、ゲートタイムとしては約 32 秒までを指定できることになります。
start() 関数で開始した周期測定を途中で中止したい場合には stop() 関数を呼び出します。 stop() 関数の実際の処理としては、Timer1 のオーバフロー割り込みとインプット・キャプチャ割り込みを禁止しているだけです。
測定を「再開」する機能はありません。 start() 関数を呼び出して、新たな測定として開始します。
(p_ready == 1) の条件が検出できたら、p_num, p_den に測定結果が得られています。 それ以前の状態では p_den, p_num の値は不定です。
p_num および p_den は整数値で、実際の周期と周波数は、F_CPU をクロック周波数として、実数計算で

  • 周期 = p_num / (p_den * F_CPU)
  • 周波数 = (F_CPU * p_den) / p_num

という式で求められます。
p_num の「num」は numerator (分子)、p_den の「den」は denominator (分母) の意味です。 周期の計算では名前の通りですが、周波数の計算では分子と分母が入れ替わります。
具体的には p_den はゲートタイム中に捕捉された入力信号の周期の回数で、p_num は、その p_den 周期を F_CPU クロックで計測して何クロックあったかという数字です。
p_den がマイナスの場合には、ゲートタイム中に入力信号のクロック・エッジが1回も観測されなかったことを示し、 p_den == 0 の場合はエッジが1回しか観測されず、「周期」を決定できなかったことを示しています。
p_den >= 1 の場合にだけ、1回以上の周期が観測されて、周期が求まったことを示しています。
現状のプログラムでは、16 ビット・ハードウェア・カウンタをソフト的に 32 ビットカウンタへ拡張しており、その関係で 12/65536 つまり 1/5000 程度の確率で誤差が生じる可能性があります。
原因を追究して、対策できそうだということが分かってきましたので、次回以降の記事で詳しいことを述べたいと思います。