SPRESENSE で ソフトウェアPLLを実現する
通信技術を調べていく中で、信号処理における位相同期(Phase Lock Loop:PLL)の重要性を、改めて実感することになりました。特に、受信側で入力信号の正確な復調を行うには、送信されたキャリア信号の位相や周波数に同期する仕組みが欠かせません。

今回は、SPRESENSE を用いて、入力された正弦波信号と同期するソフトウェアPLLの実装について検討を始めることにしました。SPRESENSEは豊富な演算資源とオーディオ処理向けライブラリを備えており、信号処理の学習・実験プラットフォームとして非常に適しています。
PLLのイメージ

PLL(Phase Locked Loop:位相同期ループ)をソフトウェアで実現する方法はいくつかありますが、基本的な考え方は共通しています。それは、入力信号と内部で生成した出力信号の位相差を検出し、その差を徐々に埋めていくというもの。この“差分を埋める”制御には、PID(比例・積分・微分)制御のようなフィードバック制御手法がよく使われます。簡単に表現すると、次のような流れになります。

PLLをソフトウェアで構築するにあたり、まずは基本的なPID制御による位相制御を試してみました。しかし、PID制御のパラメータの設定が非常に難しく、少しのズレで発振したり、追従できなかったりと、安定して位相を一致させることができませんでした。やはり、現実的なソフトウェアPLLの実装には、もう少し洗練された手法や工夫が必要だと実感しました。そうした中で、参考になりそうな情報を探していたところ、かなり参考になる次の記事にたどり着きました。
Writing a Phase-locked Loop in Straight C
https://liquidsdr.org/blog/pll-howto/

この記事の手法を、Spresenseへ移植してみました。外部信号は、マイク入力から取得した正弦波を使用しています。この信号に対して、内部で動作するソフトウェアオシレータを同期させるようにしました。この実装は、入力されたアナログ波形の位相と周波数に対し、内部オシレータが追従するように制御しています。正弦波信号を直接マイク端子から受け取ることで、PLLの実動作をリアルタイムに観測・検証できます。
また、外部から正弦波信号を入力しない場合でも、b_test フラグを true に設定することで、ソフトウェアPLLの動作を擬似的にシミュレーションすることができます。これは、パラメータチューニングを行う際に非常に便利です。
移植にあたり、SPRESENSEの環境に対応するために、いくつかの工夫を加えています。文献では、ターゲットとなる入力信号の位相が既知であることを前提に設計されていますが、実際のマイク入力から得られるアナログ信号は、絶対位相が不明であるという問題があります。
そこで、まず入力信号のゼロクロス点(振幅がゼロを通過するタイミング)を検出し、そこを位相0の基準点として扱うようにしました。これにより、入力信号の実際の位相に合わせて、PLL側のオシレータが正しく追従できるようになります。

また、入力信号とソフトウェアオシレータの生成信号との位相差を計算する処理にも工夫を加えました。ここでは、複素数による表現を用いて、三角関数の内積と外積で位相差を求める手法を採用しています。

ここでは、500Hz の正弦波をマイク入力から与えた場合の動作例を以下に示します。青色の波形が外部入力の正弦波、橙色の波形がソフトウェアPLLによって生成された信号です。グラフからも分かるように、位相がしっかりと同期している様子が確認できます。

同期がうまく取れない場合は、PLLの帯域幅を表すパラメータ wn を調整してみてください。元の参考文献では明示されていませんが、文脈から判断するに、wn は正規化周波数(バンド幅 ÷ サンプリング周波数)で定義されているようです。
今回のターゲット周波数は 500Hz なので、サンプリング周波数を 48kHz と仮定すると、正規化周波数はおおよそ 0.01 になります。しかしこの値では、長時間動作させると徐々に同期が崩れるケースが見られました。
そこで、wn = 0.1(≒ 5000Hz に相当)としたところ、安定した位相同期が得られるようになりました。wn の値は、PLLの応答速度と安定性のバランスに影響するため、実際の信号条件に応じて適切に調整する必要があります。
・作ってみた感想
今回、PLL制御をゼロから実装してみたことで、想像以上に手間のかかる処理であることを実感しました。特に位相差の検出や安定化制御の調整といった部分では、試行錯誤を重ねる必要があり、パラメータ設定の難しさも再認識することとなりました。とはいえ、この取り組みを通じてPLLの動作原理に対する理解が格段に深まりました。通信や制御など、さまざまな場面で応用していきたいと思います。

SONY SPRESENSE メインボード CXD5602PWBMAIN1

SONY SPRESENSE 拡張ボード CXD5602PWBEXT1

トランジスタ技術 2025年7月号 - トランジスタ技術編集部
今回は、SPRESENSE を用いて、入力された正弦波信号と同期するソフトウェアPLLの実装について検討を始めることにしました。SPRESENSEは豊富な演算資源とオーディオ処理向けライブラリを備えており、信号処理の学習・実験プラットフォームとして非常に適しています。
PLLのイメージ
PLL(Phase Locked Loop:位相同期ループ)をソフトウェアで実現する方法はいくつかありますが、基本的な考え方は共通しています。それは、入力信号と内部で生成した出力信号の位相差を検出し、その差を徐々に埋めていくというもの。この“差分を埋める”制御には、PID(比例・積分・微分)制御のようなフィードバック制御手法がよく使われます。簡単に表現すると、次のような流れになります。
PLLをソフトウェアで構築するにあたり、まずは基本的なPID制御による位相制御を試してみました。しかし、PID制御のパラメータの設定が非常に難しく、少しのズレで発振したり、追従できなかったりと、安定して位相を一致させることができませんでした。やはり、現実的なソフトウェアPLLの実装には、もう少し洗練された手法や工夫が必要だと実感しました。そうした中で、参考になりそうな情報を探していたところ、かなり参考になる次の記事にたどり着きました。
Writing a Phase-locked Loop in Straight C
https://liquidsdr.org/blog/pll-howto/
この記事の手法を、Spresenseへ移植してみました。外部信号は、マイク入力から取得した正弦波を使用しています。この信号に対して、内部で動作するソフトウェアオシレータを同期させるようにしました。この実装は、入力されたアナログ波形の位相と周波数に対し、内部オシレータが追従するように制御しています。正弦波信号を直接マイク端子から受け取ることで、PLLの実動作をリアルタイムに観測・検証できます。
また、外部から正弦波信号を入力しない場合でも、b_test フラグを true に設定することで、ソフトウェアPLLの動作を擬似的にシミュレーションすることができます。これは、パラメータチューニングを行う際に非常に便利です。
#include <Audio.h>
#define ARM_MATH_CM4
#define __FPU_PRESENT 1U
#include <arm_math.h>
AudioClass *theAudio = AudioClass::getInstance();
static const int sampling_rate = AS_SAMPLINGRATE_48000;
static const int signal_length = 1024;
static const uint32_t buffer_size = sizeof(int16_t)*signal_length;
static char s_buffer[buffer_size];
static float pTmp[signal_length];
static float pSrc[signal_length*2];
static float pDst[signal_length];
// "wait_time" is not effective under 20msec that is NuttX tick time
const uint32_t wait_time = 1000000UL*signal_length/sampling_rate/2;
static float carrier_frequency = 500; // Hz
static const float dt = 1. / float(sampling_rate);
// parameters
static const float wn = 0.1f; // pll bandwidth (original is 0.01f)
static const float zeta = 0.707f; // pll damping factor
static const float K = 1000; // pll loop gain
// generate loop filter parameters (active PI design)
static const float t1 = K/(wn*wn); // tau_1
static const float t2 = 2*zeta/wn; // tau_2
// feed-forward coefficients (numerator)
static const float b0 = (4*K/t1)*(1. + t2/2.0f);
static const float b1 = (8*K/t1);
static const float b2 = (4*K/t1)*(1. - t2/2.0f);
// feed-back coefficients (denominator)
// a0 = 1.0 is implied
static const float a1 = -2.0f; // -2.0f;
static const float a2 = 1.0f;
// test by internal generated signal
static bool b_test = false;
void setup() {
Serial.begin(115200);
theAudio->begin();
theAudio->setRenderingClockMode(AS_CLKMODE_NORMAL);
theAudio->setRecorderMode(AS_SETRECDR_STS_INPUTDEVICE_MIC);
theAudio->initRecorder(
AS_CODECTYPE_PCM, "/mnt/sd0/BIN", sampling_rate, AS_CHANNEL_MONO);
if (!b_test) {
Serial.println("Recorder starts!");
theAudio->startRecorder();
}
}
void loop() {
// filter buffer
static float v0 = 0.0f, v1 = 0.0f, v2 = 0.0f;
// initialize states
static float phi = 0.00f;
static float phi_hat = 0.0f;
// frequency offset
static float frequency_offset = carrier_frequency * dt;
if (!b_test) {
uint32_t read_size;
err_t err = theAudio->readFrames(s_buffer, buffer_size, &read_size);
if (err != AUDIOLIB_ECODE_OK &&
err != AUDIOLIB_ECODE_INSUFFICIENT_BUFFER_AREA) {
Serial.println("Recording Error");
theAudio->stopRecorder();
return;
}
if (read_size < buffer_size) { usleep(wait_time); return; }
arm_q15_to_float((int16_t*)s_buffer, pTmp, signal_length);
} else {
static float phase = 0;
for (int n = 0; n < signal_length; ++n) {
pTmp[n] = arm_sin_f32(phase);
phase += TWO_PI*frequency_offset;
if (phase >= TWO_PI) phase -= TWO_PI;
}
}
float average;
arm_mean_f32(pTmp, signal_length, &average);
if (average < 0.1) v0 = v1 = v2 = 0.0;
memcpy(&pSrc[signal_length], &pSrc[0], sizeof(float)*signal_length);
memcpy(&pSrc[0], &pTmp[0], sizeof(float)*signal_length);
static const int offset = signal_length;
for (int n = 0; n < signal_length; ++n) {
float x_Q = 0, x_I = 0;
static float p = 0;
int m = n + offset;
if (pSrc[m-1] < 0 && pSrc[m] >= 0.0) { // zero cross point
float b = -pSrc[m-1]/(pSrc[m]-pSrc[m-1]);
p = float(n-1) + b;
}
if (p <= n) {
phi = TWO_PI*(float(n)-p)*frequency_offset;
} else if (p > n) {
phi = TWO_PI*(float(n)+float(signal_length)-p)*frequency_offset;
}
if (phi >= TWO_PI) phi -= TWO_PI;
x_Q = arm_cos_f32(phi); //x_Q = pSrc[n];
x_I = arm_sin_f32(phi);
float y_Q = arm_cos_f32(phi_hat);
float y_I = arm_sin_f32(phi_hat);
float mix_Q = x_Q*y_Q + x_I*y_I;
float mix_I = x_I*y_Q - x_Q*y_I;
float delta_phi = atan2f(mix_I, mix_Q); // calc diff angle
v2 = v1;
v1 = v0;
v0 = delta_phi - v1*a1 - v2*a2;
phi_hat = v0*b0 + v1*b1 + v2*b2;
pDst[n] = y_I;
}
print_wave(&pSrc[signal_length], &pDst[0]);
}
void print_wave(float *pSrc, float *pDst) {
static const int plt_width = 50;
static const uint32_t interval_ms = 500;
static uint32_t last_ms = millis();
static bool onoff = true;
uint32_t current_ms = millis();
uint32_t duration_ms = current_ms - last_ms;
static const int skip = 3*sampling_rate/(carrier_frequency*plt_width);
if (duration_ms > interval_ms) {
for (int n = 0; n < signal_length; n += skip) {
Serial.printf("%f,%f\n",pSrc[n], pDst[n]);
}
digitalWrite(LED0, onoff = onoff ? false : true); // hert-beat
last_ms = current_ms;
}
}
移植にあたり、SPRESENSEの環境に対応するために、いくつかの工夫を加えています。文献では、ターゲットとなる入力信号の位相が既知であることを前提に設計されていますが、実際のマイク入力から得られるアナログ信号は、絶対位相が不明であるという問題があります。
そこで、まず入力信号のゼロクロス点(振幅がゼロを通過するタイミング)を検出し、そこを位相0の基準点として扱うようにしました。これにより、入力信号の実際の位相に合わせて、PLL側のオシレータが正しく追従できるようになります。
また、入力信号とソフトウェアオシレータの生成信号との位相差を計算する処理にも工夫を加えました。ここでは、複素数による表現を用いて、三角関数の内積と外積で位相差を求める手法を採用しています。
ここでは、500Hz の正弦波をマイク入力から与えた場合の動作例を以下に示します。青色の波形が外部入力の正弦波、橙色の波形がソフトウェアPLLによって生成された信号です。グラフからも分かるように、位相がしっかりと同期している様子が確認できます。
同期がうまく取れない場合は、PLLの帯域幅を表すパラメータ wn を調整してみてください。元の参考文献では明示されていませんが、文脈から判断するに、wn は正規化周波数(バンド幅 ÷ サンプリング周波数)で定義されているようです。
今回のターゲット周波数は 500Hz なので、サンプリング周波数を 48kHz と仮定すると、正規化周波数はおおよそ 0.01 になります。しかしこの値では、長時間動作させると徐々に同期が崩れるケースが見られました。
そこで、wn = 0.1(≒ 5000Hz に相当)としたところ、安定した位相同期が得られるようになりました。wn の値は、PLLの応答速度と安定性のバランスに影響するため、実際の信号条件に応じて適切に調整する必要があります。
・作ってみた感想
今回、PLL制御をゼロから実装してみたことで、想像以上に手間のかかる処理であることを実感しました。特に位相差の検出や安定化制御の調整といった部分では、試行錯誤を重ねる必要があり、パラメータ設定の難しさも再認識することとなりました。とはいえ、この取り組みを通じてPLLの動作原理に対する理解が格段に深まりました。通信や制御など、さまざまな場面で応用していきたいと思います。

SONY SPRESENSE メインボード CXD5602PWBMAIN1

SONY SPRESENSE 拡張ボード CXD5602PWBEXT1

トランジスタ技術 2025年7月号 - トランジスタ技術編集部
この記事へのコメント