⏲️

Raspberry Pi Pico+Arduinoで周波数を測定する

2023/01/20に公開

Raspberry Pi Pico(RP2040)とArduinoフレームワークを使ってGPIOに入力される信号の周波数を測定します。この記事では精度や負荷、周波数範囲が異なる複数の方法を紹介します。

ここでのArduinoフレームワークは arduino-pico (いわゆるearlephilhower版)の 2.7.1 を対象にしています。計測時はRP2040のクロック周波数は標準の 125MHz を使用しています。

方法1: 信号の立ち上がりの回数から求める

周波数を計測するもっとも単純な方法は、1秒あたりの信号の立ち上がり回数を数えることです。attachInterrupt で立ち上がり (RISING) を外部割り込みのトリガとして使い、カウント値をインクリメントします。

実際にこのプログラムはうまく動作します。しかし出力される周波数は整数のため、1Hz未満の精度は出せません。外部割り込みハンドラ自体のオーバーヘッドにより、この方法で測定できる周波数範囲は 1~200kHz となります。

#include <Arduino.h>
#define PIN_INPUT 15

volatile uint32_t triggered_count = 0;

void count_input() {
  triggered_count++;
}

void setup() {
  Serial.begin(9600);
  pinMode(PIN_INPUT, INPUT);

  old_time = micros();
  attachInterrupt(PIN_INPUT, count_input, RISING);
}

void loop() {
  delay(1000);
  Serial.println(triggered_count);
  triggered_count = 0;
}

方法2: 信号の立ち上がりの時間間隔から求める

方法1のような立ち上がりの回数ではなく、方法2では立ち上がりと立ち上がりの時間間隔を計測することで計測を行います。プログラム中では複数回にわたって立ち上がりの時間間隔を計測し、その平均時間から周波数を計算しています。1秒間隔で周波数の表示を行いますが、その場合でも1Hz未満の周波数が表示できるように計測回数のチェックを行っています。

周波数の計算中に割り込みがされると計測結果がズレが生じるため、noInterrupts および interrupts で割り込みの無効化・有効化を行っています。なお、方法1と同様に外部割り込みハンドラ自体のオーバーヘッドは存在するため、およそ 200kHz が測定限界になります。

#include <Arduino.h>
#define PIN_INPUT 15

volatile uint64_t old_time         = 0;
volatile uint32_t observated_count = 0;
volatile uint64_t triggerred_spans = 0;

void count_input() {
  uint64_t time = micros();
  triggerred_spans += time - old_time;
  observated_count++;
  old_time = time;
}

void setup() {
  Serial.begin(9600);
  pinMode(PIN_INPUT, INPUT);
  old_time = micros();
  attachInterrupt(digitalPinToInterrupt(PIN_INPUT), count_input, RISING);
}

void loop() {
  delay(1000);

  // 計測回数のチェック (0なら未計測 -> 1Hz未満)
  if (observated_count > 0) {
    noInterrupts();
    uint64_t spans_total = triggerred_spans;
    uint32_t count       = observated_count;
    triggerred_spans     = 0;
    observated_count     = 0;
    interrupts();

    double frequency = 1.0 / ((spans_total / (double)count) * 1e-6);
    Serial.println(frequency, 3);
  }
}

外部割り込みの限界

方法1、方法2のように信号を直接的に外部割り込みとして使って周波数を精度良く計測するには 100kHz が実用的な限界と考えられます。200kHz までの誤差率を測定すると 30kHz から急激に誤差が増えることがわかります。


入力信号の周波数と方法2による出力値の周波数の誤差率の変化

誤差を補正する

前述の通り 30kHz から誤差が大きくなりますが、それまでは一定の誤差率のため補正が可能です。連立方程式を解いて以下のような補正コードを追加しました。 complement_table に補正値を記録しておき、complement_freq 関数において測定結果の周波数範囲に基づいて補正値を選択して補正を行っています。

補正処理を追加した測定プログラム(クリックで表示)
#include <Arduino.h>
#define PIN_INPUT 15

volatile uint64_t old_time         = 0;
volatile uint32_t observated_count = 0;
volatile uint64_t triggerred_spans = 0;

const double complement_table[] = {
  0.2,    1.000007317,  0.000000003280024002,
  0.3,    1.000007333,  0.0000000001000007299,
  0.4,    1.0000072,    0.00000004000028797,
  0.5,    1.000007328,  -0.00000001120008197,
  0.6,    1.000007272,  0.00000001680012185,
  0.7,    1.000007355,  -0.00000003300024221,
  0.8,    1.000007257,  0.00000003560025752,
  0.9,    1.000007288,  0.00000001080007861,
  1,      1.000007799,  -0.0000004491035017,
  2,      1.00000715,   0.0000001998014287,
  3,      1.00000725,   0,
  4,      1.000007583,  -0.0000009990075744,
  5,      1.000007485,  -0.0000006070045426,
  6,      1.000006682,  0.000003408022771,
  7,      1.000008,     -0.000004500035996,
  8,      1.0000077,    -0.000002400018481,
  9,      1.000007657,  -0.000002056015743,
  10,     1.000006476,  0.000008573055524,
  20,     1.000007417,  -0.0000008340061868,
  30,     1.000007404,  -0.0000005800042899,
  40,     1.000007446,  -0.000001840013702,
  50,     1.000006828,  0.00002288015622,
  60,     1.000007689,  -0.0000201701551,
  70,     1.000007994,  -0.00003847030749,
  80,     1.000006889,  0.00003888026777,
  90,     1.00000711,   0.00002120015075,
  100,    1.000006648,  0.00006278041737,
  200,    1.000007581,  -0.00003054023151,
  300,    1.000007252,  0.00003530025595,
  400,    1.000007332,  0.00001130008303,
  500,    1.000007548,  -0.00007510056707,
  600,    1.000006997,  0.0002004014026,
  700,    1.000007661,  -0.0001980015173,
  800,    1.000006998,  0.0002661018623,
  900,    1.000008424,  -0.0008747073687,
  1000,   1.000006475,  0.0008794056941,
  2000,   1.000007474,  -0.0001192008906,
  3000,   1.00000758,   -0.0003320025162,
  4000,   1.000007358,  0.0003340024564,
  5000,   1.00000772,   -0.0011140086,
  6000,   1.000007329,  0.0008410061646,
  7000,   1.000007399,  0.0004210031138,
  8000,   1.000007317,  0.0009950072845,
  9000,   1.000007712,  -0.002165016695,
  10000,  1.000007169,  0.00272201951,
  20000,  1.000007484,  -0.0004260031856,
  30000,  1.000013454,  -0.1198316122,
  40000,  1.000086552,  -2.312760158,
  50000,  1.000104378,  -3.025755789,
  60000,  1.0001897,    -7.291672968,
  70000,  1.000243057,  -10.49287975,
  80000,  1.005309446,  -365.1070985,
  90000,  1.029068124,  -2264.391653,
  100000, 0.9784607837, 2272.97122,
  110000, 1.032907251,  -3165.050955,
  120000, 1.057786786,  -5890.846453,
  130000, 1.021238826,  -1541.147939,
  140000, 1.04111764,   -4101.647855,
  150000, 1.028926742,  -2414.299225,
  160000, 1.035755009,  -3425.766216,
  170000, 1.074434501,  -9528.778707,
  180000, 1.012186779,  872.2811082,
  190000, 1.024215947,  -1256.53291,
  198000, 1.042773881,  -4721.940773
};
const size_t complement_table_size = sizeof(complement_table) / sizeof(double) / 3;

double complement_freq(double value) {
  double x = 0.0, y = 0.0;

  for (size_t i = 0; i < complement_table_size; i++) {
    if (value <= complement_table[i * 3]) {
      x = complement_table[i * 3 + 1];
      y = complement_table[i * 3 + 2];
      break;
    }
  }

  if (x == 0.0) {
    x = complement_table[(complement_table_size - 1) * 3 + 1];
    y = complement_table[(complement_table_size - 1) * 3 + 2];
  }

  return value * x + y;
}

void count_input() {
  uint64_t time = micros();
  triggerred_spans += time - old_time;
  observated_count++;
  old_time = time;
}

void setup() {
  Serial.begin(9600);
  pinMode(PIN_INPUT, INPUT);
  old_time = micros();
  attachInterrupt(digitalPinToInterrupt(PIN_INPUT), count_input, RISING);
}

void loop() {
  delay(1000);

  // 計測回数のチェック (0なら未計測 -> 1Hz未満)
  if (observated_count > 0) {
    noInterrupts();
    uint64_t spans_total = triggerred_spans;
    uint32_t count       = observated_count;
    triggerred_spans     = 0;
    observated_count     = 0;
    interrupts();

    double frequency = 1.0 / ((spans_total / (double)count) * 1e-6);
    Serial.println(complement_freq(freq), 3);
  }
}

方法3: PIOでパルス幅を測定する

MicroPython的午睡(29) ラズパイPico、PIOでパルス幅測定 にて紹介されている方法です。PIOの jmp 命令には x または y レジスタの値がゼロではないときにジャンプを行いつつレジスタの値をデクリメントできるようになっています。入力ピンが H であるときはレジスタの値を引き続け、L になったらループを抜けるようなステートマシンを考えることができます。

.program pulse_count

begin:
    pull block
    mov x osr
    wait 0 pin 0
    wait 1 pin 0
highloop:
    jmp pin count
    jmp exitloop
count:
    jmp x-- highloop
exitloop:
    mov isr x
    push block

count ラベルの jmp x-- highloop 部分がデクリメント部分です。入力ピンが H を維持すると jmp pin countjmp x-- highloop の2命令を往復するため、2クロック分の時間がかかります。H のパルス幅は (FIFOに入力した値 - デクリメントされてFIFOから出力された値) * 2クロック となり、信号の1周期分はさらにこの 2倍 となります。信号の1周期分のクロック数が判明するので、ここにステートマシンの動作周波数を乗算して逆数を計算すれば信号の周波数が求められます。

PIOを使うことで方法1、方法2と違ってCPUは忙殺されません。測定可能な最大周波数は 125MHz を 4分周した 31.25MHz となります。しかし算出された周波数はこの 31.25MHz を整数で割った値にしかならないので、周波数が高い範囲では特に精度が落ちます。

元記事ではMicroPythonで記述されているので、PIOの初期化も含めてC/C++で書き直してみました。なおPIOASMからC/C++のコード変換は pioasm Online を使いました。

pulse_count.h
// -------------------------------------------------- //
// This file is autogenerated by pioasm; do not edit! //
// -------------------------------------------------- //

#pragma once

#if !PICO_NO_HARDWARE
#include "hardware/pio.h"
#endif

// ----------- //
// pulse_count //
// ----------- //

#define pulse_count_wrap_target 0
#define pulse_count_wrap 8

static const uint16_t pulse_count_program_instructions[] = {
  //     .wrap_target
  0x80a0,  //  0: pull   block
  0xa027,  //  1: mov    x, osr
  0x2020,  //  2: wait   0 pin, 0
  0x20a0,  //  3: wait   1 pin, 0
  0x00c6,  //  4: jmp    pin, 6
  0x0007,  //  5: jmp    7
  0x0044,  //  6: jmp    x--, 4
  0xa0c1,  //  7: mov    isr, x
  0x8020,  //  8: push   block
           //     .wrap
};

#if !PICO_NO_HARDWARE
static const struct pio_program pulse_count_program = {
  .instructions = pulse_count_program_instructions,
  .length       = 9,
  .origin       = -1,
};

static inline pio_sm_config pulse_count_program_get_default_config(uint offset) {
  pio_sm_config c = pio_get_default_sm_config();
  sm_config_set_wrap(&c, offset + pulse_count_wrap_target, offset + pulse_count_wrap);
  return c;
}

static inline void pulse_count_program_init(PIO pio, uint sm, uint offset, uint pin) {
  pio_sm_config c = pulse_count_program_get_default_config(offset);
  pio_gpio_init(pio, pin);
  
  // pin を入力方向に、 in と jmp のベースピンに設定
  pio_sm_set_consecutive_pindirs(pio, sm, pin, 1, false);
  sm_config_set_in_pins(&c, pin);
  sm_config_set_jmp_pin(&c, pin);
  
  // クロック分周なしでステートマシン実行
  sm_config_set_clkdiv(&c, 1.0f);
  pio_sm_init(pio, sm, offset, &c);
}
#endif
main.cpp
#include <Arduino.h>
#include "hardware/pio.h"
#include "pulse_count.h"
#define PIN_INPUT 15

PIO pio = pio0;
uint32_t sm;
const uint32_t COUNT_START = 2147483648;

void setup() {
  Serial.begin(9600);
  uint32_t offset = pio_add_program(pio, &pulse_count_program);
  sm              = pio_claim_unused_sm(pio, true);
  pulse_count_program_init(pio, sm, offset, PIN_INPUT);
  
  // ステートマシン起動
  pio_sm_clear_fifos(pio, sm);
  pio_sm_set_enabled(pio, sm, true);
  pio_sm_put(pio, sm, COUNT_START);
}

void loop() {
  delay(1000);

  // FIFOの受信バッファをチェック (0なら未計測 -> 1Hz未満)
  if (pio_sm_get_rx_fifo_level(pio, sm) > 0) {
    uint32_t count = pio_sm_get(pio, sm);
  
    // ステートマシン停止
    pio_sm_set_enabled(pio, sm, false);

    Serial.println(1.0 / ((COUNT_START - count) * (1.0 / (double)clock_get_hz(clk_sys)) * /* 2 clocks */ 2.0 * 2.0), 3);

    // ステートマシン再起動
    pio_sm_clear_fifos(pio, sm);
    pio_sm_set_enabled(pio, sm, true);
    pio_sm_put(pio, sm, COUNT_START);
  }
}

比較

3つの方法を比較してみました。

方法1 方法2 方法3
測定方法 エッジカウント パルス周期測定 パルス幅測定
信号処理方法 外部割り込み 外部割り込み PIO
最高周波数 ~200kHz ~200kHz 31.25MHz
最低周波数 1Hz 0.0002Hz 0.0073Hz
周波数分解能 1Hz 0.0002Hz 不定(非線形)
CPU負荷 周波数が上がると上昇 周波数が上がると上昇 低く一定
同時測定可能なピン数 30
(Picoは26個)
30
(Picoは26個)
8
(ステートマシン数と同じ)

比較すると、方法1は他の方法とくらべてメリットが薄く、ほとんどは方法2を使うことになります。一方で方法3はCPU負荷が低く、ステートマシンに余裕があって精度を気にしないのであれば第一選択になるでしょう。

ライブラリにしてみました

方法2、方法3 をひとつのライブラリ freqcount にしました。方法2の割り込みによる方法は FreqCountIRQ、方法3のPIOによる方法は FreqCountPIO のクラスです。使い方はどちらのクラスもほとんど同じで、begin(pin) で指定したピンについて計測を行い、update で計測回数のチェックを行い、計算可能ならば周波数の計算を行います。

#include <Arduino.h>

#include "freqcount.h"
#define PIN_INPUT 0

// FreqCountPIO freq_count;  // using PIO
FreqCountIRQ freq_count;  // using IRQ

void setup() {
  Serial.begin(9600);
  freq_count.begin(PIN_INPUT);
}

void loop() {
  delay(1000);

  if (freq_count.update()) {
    Serial.println(freq_count.get_observated_frequency(), 3);
  }
}

参考文献

Discussion