STM32F4を使用したダイレクトサンプリング方式ソフトウェアラジオ


はじめに

以前長波のJJYを受信しましたが,その時はワンセグチューナーやサウンドカードでAD変換を行いPC上で動くソフトウェアラジオ(SDR)を使用しました. SDRを丸ごと自作したいと前々から思っていたので,STM32F4マイコンを使用してスタンドアローンで動くSDRを作りました. できるだけ少ない部品でハードウェアはシンプルにすることを目指しました.

仕様は次のようになります:

設計

全体の構成

STM32F407VGT6マイコンを使ってSDRを作ることにしました.このマイコンは最大7.2MspsのADCや浮動小数点演算器(FPU)を内蔵しています. このマイコンを搭載したDIYMOREというブランドのマイコンボードを使用しました.このボードにはマイコンの他に電源レギュレータや水晶振動子など必要最低限の部品が載っています. ソフトウェアの開発にはArduino IDE (Arduino Core STM32)を使いましたが,このボードにはデフォルトで対応しています. SDRを実装する場合,AD変換と周波数変換をマイコンの外部で行う方式もありますが,今回はそれらをすべてマイコン内部で行うダイレクトサンプリング方式のSDRとしました. そのため外付け回路はかなりシンプルになります.

以下に,今回作成するSDRの構成図を示します:

まずアンテナから入ってきた信号は,ローパスフィルタとローノイズアンプからなる外付けのフロントエンド回路を通過してマイコンに入力されます. マイコンでは最初にADコンバータでデジタル信号に変換してDC成分の除去を行います.この時点でサンプリング周波数は6MHzです. 次に,NCO (Numerically Controlled Oscillator)とI/Qミキサー(直交変調器)により目的の周波数の信号を取り出します. この時点で音声帯域以上の周波数成分は不要となるので,CICフィルターとFIRフィルターにより低いサンプリング周波数に変換します(デシメーション処理). 最終的にサンプリング周波数は128分の1にされて46.875kHzとなります. そしてバンドパスフィルタにより不要な周波数帯域の信号を除去した後で,AM/CW/SSBの各電波形式に応じた復調処理を行います. 復調された信号はAGCでレベル調整をした後でDAコンバータに送られ,ローパスフィルタを通してヘッドフォンから再生されます.

これらの一連の流れは,できるだけ多数のデータをまとめて処理した方が割り込み処理等のオーバーヘッドを削減できて効率的ですが,一方で必要なメモリが増えるとともに信号の遅延も大きくなります. 今回はマイコンのRAMの都合で,ADCで一度に入力するデータは16,384サンプルとして,DACで一度に出力されるデータは128サンプルとなりました.

似たようにマイコン内蔵のADCを利用したダイレクトサンプリング方式のSDRとして,すでにARM Radioというものがあります. ARM Radioの場合はADCは約1.8Mspsで,CICフィルタとFIRフィルタによるデシメーション比はそれぞれ1/16と1/4でDAC周波数は28kHzとなっていますが,他にもフィルターの実現方法などが本機とは異なっています. 例えば,本機はCICデシメーションまでは浮動小数点演算ではなく固定小数点演算を使い,また一度も周波数領域へ変換せずにすべて時間領域で処理を行っています. SDRのコードを開発するにあたっては,SDR専用のアルゴリズムがあるのではなく基本的にデジタル信号処理の応用であり,特にUnderstanding Digital Signal Processing (Richard G. Lyons)という本が実践的な内容もあり詳しくて参考になりました.

フロントエンド部

アンテナからの入力が来るフロントエンド部は,ADCのナイキスト周波数(3MHz)より上の周波数成分を除去するためのローパスフィルタ(LPF)と,微弱信号を増幅するローノイズアンプ(LNA)から成っています. LNAは入手が容易で外付け部品も少ないBGA420を使用しました.このアンプはノイズもゲイン(17dB)もやや控えめで3.3Vで動作します.

回路図・配線図と完成した基板は下のようになりました(この回路にはフロントエンド部だけではなく出力フィルター部も含まれています).

回路図
配線図
入力アンプ・出力フィルター基板

AD変換部・DC除去部

STM32F407のADCは1チャネル最大2.4Mspsで変換を行うことができますが,ここではそれを2Mspsで動作させ,3チャネルを交互に利用するトリプルインターリーブを使うことで6Mspsの速度で変換を行います. ADCの内部クロックを30MHzで動作させて6Mspsでサンプリングする場合,3つのADCを使うと3*30/6=15クロックごとに変換が必要となります. ADC分解能を12ビットとする場合変換に要するクロックは15サイクル必要となっているので間に合います. また1つのADCがポートを占有する時間は5サイクルとなっているので,15サイクルあれば3つのADCで1つの入力ポートを共有することができます. 変換された値はDMAでword単位でメモリに転送し,16,384サンプルが貯まると割り込みを発生させて以降のDACへの転送までの一連の処理がトリガーされます. なお以降の処理を行う間も新たなデータがADCで変換されるため,バッファを2つ用意して切り替えることで,現在処理しているバッファとは別のバッファにADCからのデータを貯えるようにしています.

ADCから取り入れたデータは,オフセット値を差し引くことでDC除去をするとともに,信号の平均値を計算してオフセット値を更新します.

NCO・I/Qミキサー部

ここではいわゆる複素ミキサーにより周波数変換を行って複素信号を得ます. これは,LO周波数のsin波とcos波を入力信号に掛け算することで計算できますが,これらの信号を生成する部分がNCO部です. sin・cos信号はあらかじめ計算してテーブルに格納しておき,チューニング操作により受信周波数が変更されると再計算します. 効率的に計算を行うため,NCOテーブルのサイズはADCバッファのサイズと同じにしました.そのため受信周波数の振幅の整数倍がNCOテーブルに収まるように,周波数の端数を調整しています.

CIC・FIRデシメーションフィルタ部

ここでは,信号のサンプリング周波数を扱いやすい周波数に落とす,いわゆるデシメーション処理を行います. 周波数変換を行った後なので音声周波数より上の周波数は不要になっているからです. 全体で128分の1まで周波数を落としますが,最初にCICフィルターという高速だが特性に癖のあるフィルターで32分の1まで落し,次に汎用的なFIRフィルターで4分の1に落とします. CICフィルターは3段で,FIRフィルターは32タップとしました.

このようなデシメーション処理により信号の有効ビットを増やすことができます. ここでは合計で128分の1にサンプリング周波数を変換しているため,得られるゲインはNをADCの有効ビット(N=12),Rをデシメーション比(R=128)としてSNR=(6.02 * N + 1.76)+10 log (R) = 95 dBとなります.

今回作成したSDRでは,このCICフィルターまでは固定小数点演算を使用し,それ以降は浮動小数点演算を使用しています. STM32F4はFPUを搭載しているため,基本的な演算は浮動小数点も固定小数点と同じクロック(1 CPUサイクル)で計算できます. その上浮動小数点演算は,固定小数点演算で必要な位取りを考慮する必要がなく,またより多くのレジスタを使うことができるので,できるだけ浮動小数点演算を使いたいところです. 今回中途半端に固定小数点演算を併用した理由は,このCICフィルターは固定小数点演算の方が効率的に処理できるからです. 詳細は省略しますが,CICフィルターは単純な整数の加算と減算で実装することができます(そのためFPGA等での実装にも適しています). しかしながら,それをそのまま浮動小数点演算に置き換えると計算結果がおかしくなります. これは整数用CICフィルターのアルゴリズムはmodulo演算を仮定していて,積分器でオーバーフローが起きても整数の場合は問題がないためです. 浮動小数点でCICフィルターが実装できないわけではありません.実際にARM Radioでは浮動小数点を使っています. CICフィルターはFIRフィルターとして表現することができて,例えば3次のCICフィルターは次のような4タップのFIRフィルターで実現できます: (1+z^-1)^3=1+3z^-1+3z^-2+z^-3. しかしながら整数演算によるCICフィルターに比べてどうしても計算効率が落ちるため,サンプル数が多く実時間処理のボトルネックになる可能性があるこの段階ではできるだけ高速な整数用CICフィルターを使うことにしました.

CICフィルターは櫛形の特徴的な特性があるため,FIRフィルターではそれを補正するように設計しましたが,FIRフィルターの特性を見るかぎりあまり影響はなかったようなので不要だったかもしれません. FIRフィルターの設計方法については後述のバンドパスフィルター部で述べます. 以下にCICフィルターとFIRフィルターの特性を示します.

CICフィルター
FIRフィルター

バンドパスフィルター部

必要な音声帯域の信号を抜き出して聴きやすくするためのバンドパスフィルターを3段のbiquad (双2次)フィルターで実装しています. 200, 500, 1k, 2k, 5k, 10k, 20kHzのローパスフィルターを選べるようにしています.

最初は6次のIIRフィルターで設計しようとしましたが不安定だったので,biquadフィルターを多段で使うことにしたら安定しました. FIRフィルターだと特性が緩やかすぎたので使いませんでした. 処理する信号のサンプリング周波数が46.875kHzと必要以上に高いので,低い周波数のフィルターを作るのは難しくなります.

なおARM Radioではバンドパスフィルターを時間空間で処理するのではなく,FFTで変換して周波数空間で処理しています. このアプローチにも興味があったのですが,最終的にoverlap-save法などで時間空間に戻す必要があり少し面倒になりそうなのでここでは採用しませんでした.

このバンドパスフィルター,および前述のFIRデシメーションと後述のヒルベルト変換のフィルターはPythonのscipyパッケージを使って設計しました. 設計に使ったスクリプトはここにあります. バンドパスフィルターの特性は下のようになりました.

バンドパスフィルター

AM・CW・SSB復調部

ここでは,電波形式に応じた復調処理を行います.

AM信号の場合はエンベロープ復調を使いました. これは単純に複素信号の絶対値を計算するだけです. AM信号は理論的にはSSBと同じ方法で復調することもできますが,キャリア周波数のずれの影響を大きく受けてしまします. 復調後に直流成分の除去を行っています.

CW信号の場合は,トーン周波数(ここでは800Hz)だけ周波数変換を行うことで復調しました.

SSB信号(USBまたはLSB)の場合は,ヒルベルト変換により片側の周波数成分を消すことで復調しました. ヒルベルト変換には31タップのFIRフィルターを使いました. USBとLSB復調の切り替えは,FIRフィルターの係数にマイナスを掛けたものをあらかじめ計算しておきそれを使いました. I/Q信号を入れ替えて計算する方法もありますが,今回はメモリ配置の都合でその手が使えませんでした.

AGC部・DA変換部

復調後の信号は,ゲインを調整してからDACのバッファに転送します. DACはタイマー割り込みで駆動して,ADC同様に2つのバッファを切り替えて使用します. 自動ゲイン調整(AGC)は簡易的なもので,アタック時間もホールド時間もゼロとしています.

出力フィルター部

オペアンプ(LMV358)を使ったアクティブフィルターでDACの出力の余計な周波数成分を除去します. カットオフ周波数が24kHzとナイキスト周波数よりやや高めなのは,最初はDAC周波数を48kHzで設計していたためです. 回路図等は前述のフロントエンド部を参照して下さい.

LMV358は出力はフルスイング可能ですが,入力範囲は3.3V動作時に0.0〜2.5V程度となっており,ゲイン1で動作させる場合は入力側の電圧範囲の制約を受けます. 3.3Vの電源で12bit DAC出力なので,出力値を2048±1024の範囲に振ることで0.83〜2.47VをDACから出しています.

プログラムの最適化

作成したプログラムはページ末尾にリンクのあるGitHub上に置いてあります. SDRのメインとなる信号処理はDSP.hとDSP2.Sというファイルにあります. プログラムを開発するにあたり,まずは直接的でシンプルなコードをC言語で書き,次にアルゴリズムを工夫して最適化したコードをC言語で書きました. この時点で実用的な速度が得られましたが,どれだけ速くできるか興味があったのでさらにアセンブラでも書いてみました. DSP.hファイル中のOPTIMIZE_LEVELの値を0, 1, 2に変えることで,これらのC言語非最適化版,C言語最適化版,アセンブラ最適化版のコードを切り替えることができます. 各処理ルーチンの実行速度を測定した結果次のようになりました(単位はマイクロ秒).

処理 非最適化版(C言語) アルゴリズム最適化版(C言語) アルゴリズム最適化(アセンブラ)
DC除去 555 198 160
I/QミキサーとCICデシメーション 2575 839 833
FIRデシメーション 3734 283 122
バンドパスフィルター 80 61 63
AM復調 15 12 12
CW復調 11 6 7
SSB復調 597 138 55
全体 7565 1538 1257

I/Qミキサー部はシンプルな処理なので,オーバーヘッドを減らすためCICデシメーションと同じ関数で処理しています. 上記の時間は6Mspsの信号の16,384サンプルを処理するのに要する時間なので,その他の処理時間を無視すれば1/(6M/16384)=2730μsに収まれば実時間で処理できることになります. さすがに何も工夫せずに直接的に実装した場合は遅いですが,アルゴリズムを改良するだけで十分に高速になりました. 各関数の入出力に使うバッファのサイズは固定なので,配列数が特定の数で割りきれることを仮定した単純なループ展開も一部で効果がありました. STM32の能力では,アセンブラを使って徹底的にチューニングしなければ厳しいかと思っていましたが,アルゴリズムの工夫だけで大きな性能改善を得ることができてやや拍子抜けでした. 単純な関数だと,下手に手で書く方がやや遅いケースもあります. 一番高速なアセンブラ版の結果を見ると,I/QミキサーとCICデシメーションの処理が一番遅く,やはりサンプル数を減らす前の処理が一番重たいことが分かります. FIRデシメーションはアルゴリズムの最適化により13倍高速になっています. 以下に,最適化前(OPTIMIZE_LEVEL==0)と最適化後(OPTIMIZE_LEVEL==1)のコードを示します. 最適化したFIRフィルターの処理では,入力データの個数が32の倍数であることを仮定して,無駄なコピー操作が生じないようにするとともにFIRフィルターの対称性も利用しています. なおアセンブラ版では,浮動小数点レジスタがタップ数と同じ32個あるので,できるだけメモリアクセスを行わないようにしています.

static inline void decimator_fir(const float *coeff, float *state, int size, const float *inp, float *out) {
#if OPTIMIZE_LEVEL == 0
  for (int i = 0; i < size; i++) {
    for (int j = 31; j >= 1; j--) state[j] = state[j - 1];
    state[0] = inp[i];
    if (i % 4 == 3) {
      float acc = 0.0f;
      for (int j = 0; j < 32; j++) acc += coeff[j] * state[j];
      out[i / 4] = acc / (1 << 31);
    }
  }
#elif OPTIMIZE_LEVEL == 1
  float stt[32];
  for (int i = 0; i < 32; i++) stt[i] = state[i];
  for (int i = size / 32; i != 0; i--) {
    for (int j = 0; j < 8; j++) {
      for (int k = 0; k < 4; k++) stt[j * 4 + k] = *inp++;
      float acc = 0.0f;
      for (int k = 0; k < 16; k++) acc += coeff[k] * (stt[(j * 4 + 3 - k + 32) % 32] + stt[(j * 4 + 4 + k) % 32]);
      *out++ = acc / (1 << 31);
    }
  }
  for (int i = 0; i < 32; i++) state[i] = stt[i];
#elif OPTIMIZE_LEVEL == 2
  decimator_fir2(coeff, state, size, inp, out);
#endif
}

ところで,Cコンパイラが出力したアセンブラリストを見るとLDR/STRやVLDR/VSTR命令が多用されていました. LDMIA/STMIAやVLDMIA/VSTMIA命令をうまく使えば必要なクロック数がほぼ1/2になり高速になるかと思いましたがほとんど変わりませんでした. データシートをよく見ると,LDR/STR命令などは単体では2クロック必要でも,例えば3連続で行えば4クロックで実行できるため差は出ないことが分かりました. なお,Arduino Core STM32で使われるgccの代わりにSTM32用のLLVM/Clangも使ってみましたが,速度はほとんど変わりませんでした.

マイコンのクロック周波数

使用したマイコンのSTM32F407VGT6は水晶発振子8MHzの倍数クロックをPLLで生成することができて,最大クロック周波数は168MHzとなっていますが,オーバークロックして240MHzで動かしています. これは処理速度のためというよりもADCによる制約のためです. 12bit分解能でADCを行う場合15 ADCクロックサイクルが必要になり,6Mspsで3つのADCを交互に使うトリプルインターリーブを用いると6M*15/3=30MHzのADCクロックが必要になります. ADCクロックはAPB2クロックから分周器(2, 4, 6, 8分周のいずれか)で作りますがPCLK2は最大85MHzとなっているため60MHzとします. PCLK2はSYSCLKからAHBプリスケーラ(1, 2, 4, ..., 512分周)とAPB2プリスケーラ(1, 2, 4, 8, 16分周)で作りますが,そうなるとSYSCLKの選択肢は60MHz, 120MHz, 240MHzしかありません. 一方でSTM32F407は240MHzでも問題なく動作したという報告がいくつかある(1, 2)ので,今回はもともと実験的な用途ということもあり240MHzで動作させることにしました. 今のところオーバークロックが原因と思われる問題は起きていません.

クロックやADCなどのマイコンの設定はPeripheral.cppというファイルで行っていますが,STM32CubeMXのGUIを使ってソースファイルを生成して利用しました.

ソフトウエアに関するその他

割り込みの優先度は,DACを最優先とし,次にSysTick,最後にADCとしています. ADCは多少遅れてもタイミングに余裕がありますが,SysTickの優先度が低いとmicros()などが正常に動かなくなり,またDACはスムーズな音声出力のためにタイミング管理が重要となります.

STM32F4はCCMというRAM領域が(64kB)あります.この領域はDMAには使えないので,NCOデータの置き場所に使っています.

現状のコードでは,フラッシュメモリのうち3%を,RAMのうち57%を使用しています.

ユーザーインターフェース

SDRを操作するために,1.14インチの240x135ドットの液晶とスイッチ付きロータリーエンコーダを付けています.

InformationモードとWaterfallモードがあり,スイッチを長押しするとモードを交互に切り替えます. どちらのモードでも,スイッチを短押しすると選択中の項目を切り替え,右回転と左回転でそれぞれ項目の値を増加したり減少したりします.周波数設定時は早く回すと値を高速変化します.

キー入力は,1kHzのタイマー割り込みでロータリーエンコーダからの入力を調べます.スイッチが押された場合長押しか短押しかの判定もします. LCDのフォントは扱いやすさを考えて16x32としました.高さ16ピクセルの一つの画像としてROMに格納しました.フォントデータ変換に使ったコードはここにあります.

ウォーターフォール表示は,FIRデシメーション直後の信号をFFT変換して使っています. 本当は垂直にスクロールしたいところですが,使用したLCDのバッファの都合で水平にスクロールしています. 表示には5カラーのヒートマップを使いました. 信号強度は対数に変換して表示する必要がありますが,log関数を実装するのが面倒だったのと,どうせ細かい色は目で区別できないので64色のカラーパレットがあれば充分と考えたため,ループ展開した二分探索でカラーパレットのインデックスを見つけるコードを書きました. SDRの処理が思ったよりも軽かったため,ウォーターフォール表示の処理も苦労せずに行えました.

動作テスト

信号処理のコードは,色々な入力信号に対する出力をPC上でシミュレーションしてデバッグしていました. そのおかげかSTM32に書き込んで動作させたところ,一発でAM放送の受信に成功しました. 数メートルのコードをアンテナとして使い,近隣のAM放送局をいくつか試したところ受信できました. しかし長波JJYは室内で試した限り受信できませんでした. CWとSSBモードに関しては,シミュレーションで復調のテストを行っただけで実際の電波を受信してのテストはまだしていません.

今回初めてSDRを自作しましたが,実用性に関しては色々と問題があります. まずベースバンド周波数(46.785kHz)が比較的高いために,狭いフィルターを実現するのが困難になってしまいました. また出力段のオペアンプの出力が弱く,小さい音しか出せません. ADCのサンプリング周波数はキリがよい6MHzとしましたが,7.2MHzまで可能なのでその方が3.5MHzバンドを一部カバーできてよかったかもしれません.

(2021年10月追記) これらの問題を踏まえて,JJY専用のソフトウェアラジオを作成しました.

配線後の基板
使用中の様子
ウォーターファール表示

ダウンロード


[Back]
2020-11 製作
2021-04-10 ページ作成
2021-10-17 バグ修正とコードの変更
T. Nakagawa