miniDDSといって、ソフトウェア実装のDDSによる、シンプルなファンクションジェネレータがweb上でよく見られます。そしてどいういうわけか、複数の異なるプロジェクトがminiDDSを名乗っています。どうやら、miniDDSは特定のプロジェクトを指しているわけではなく、その手の簡易ファンクションジェネレータの一般名詞になっているようです。名前は違いますが、当サイトでもいくつかソフトウェアDDS応用プロジェクトを公開しています。たとえば、これとかこれ。
最近、秋月で見つけた安価なSTM32マイコン(STM32F303K8T6)が3チャネルの12-bit DACを内蔵しているのに気付きました。更新レートは最高で1Msa/secと、そこそこ高機能なminiDDSを作れそうです。ということで、STM32にソフトウェアDDSを実装してみました。その際、DDS出力の精度を向上させるテーブル補間に関して考察してみたので、これも一緒に公開しておきます。
DDS機能は、図1に示すようにSTM32F303K8の内蔵モジュールを構成して実現しています。インターバルタイマはDDSのサンプリングレートを生成し、これがDDSの動作の基準タイミングとなります。DACはタイマの出力信号によってD-A変換を実行するため、正確な間隔でデータを出力できるようになっています。DACは次の出力データを準備するため、D-A変換を行ったタイミングで割り込みやDMAの要求を生成できます。このプロジェクトではサンプリングレートがかなり高速(72CPUクロック毎)なため、DMAによって次の出力データをDACにロードするようにしました。DMAコントローラはDACからのDMA要求のたびにRAM上のバッファから1サンプル分のデータを読み出し、DACのレジスタに書き込みます。また、バッファの最後のデータを転送したら停止せず先頭から再スタート(サーキュラモードという)、バッファの半分および最終のデータが転送される毎にCPUに割り込みをかけるように設定しておきます。CPUは割り込みのたびにまとまった長さ(バッファ半分)の波形データを合成してDMAバッファを埋めることになります。このようにしてDMAバッファを波形出力FIFOとして使っています。
このライブラリのDDS処理(=DMA割り込み処理)のCPU負荷率は、CPUクロック72MHz、サンプルレート1Msa/秒のとき、1チャネル構成で45%程度、2チャネル構成では85%程度でした。3チャネル構成ではサンプルレートを720ksa/秒以下にする必要があります。処理的にギリギリではありますが、そもそもDACのスペックが最大1Msa/秒なので、ハードウェアの機能をほぼフルに使っているといえます。なお、miniDDSの多くは8-bitマイコンに実装されているためテーブルを参照する際の補間は無し(位相の端数切り捨て)でしたが、このプロジェクトでは32-bitマイコンを使って処理能力に余裕があるため、LUT長は短め(512エントリ,16bit値)とし直線補間を行ってみました。補間処理と波形の精度については続く節で考察します。
DDSではサンプル毎に波形データを取得・出力することにより波形を合成します。たとえば、正弦波の合成ならsin関数をリアルタイムに演算することになりますが、これは処理速度の点で非現実的です。そこで実際のDDSでは、1周期分の正弦波(テーブルにフィットする波形ならこれに限らず何でも良い)を波形テーブル(LUT)に保持しておき、テーブル参照により波形データを取得しています。このとき、LUT長(エントリ数)をN、サンプリング周波数をfs、出力周波数をfoとすると、「fo = fs / N * m」となります。mはサンプリングポイントの増分で自然数に限られるため、foはfs / Nの整数倍にしか設定できません。これ以外の任意の周波数で合成するには、fsを変えるかmに実数を与えるかしかありませんが、fsの変更は応用上の勝手が悪いので、実際の実装では後者が選択されます。しかし、mに非整数を与えた場合、LUTに存在しない(各データ間に位置するはずの)値を取得しなければならず、何らかの方法でそのサンプリングポイントの値を推定して作り出す(補間する)必要が出てきます。
なお、ここでは離散データであるLUTから連続波形を復元することについてのみ考えることとし、これにより合成されD-A変換された出力波形については何ら言及しません。
補間方法には次に示すようなものがあり、システムリソースと要求仕様のトレードオフでどれかが選択されます。図2にいくつかの補間方法によって復元された連続値正弦波を示します。
補間しない: しないわけではありませんが、単にサンプリングポイントに最も近い(または位相の端数切り捨て)LUTエントリの値をもってサンプル値とします。0次ホールド(ZOH)とも呼ばれ、復元波形はDAC出力に似た階段状となります。ZOHは演算を行わないので、CPU負荷は最も軽くなります。
幾何学的な補間: スプライン補間などに代表される、各LUTエントリの間を幾何学的に結ぶ方法です。1次スプライン補間(FOH)は直線補間とも呼ばれます。FOHの場合、サンプリングポイント前後の2つのLUTエントリの値からのみサンプル値を推定し、サンプル毎に2回の積和演算が必要になります。
フィルタによる補間: sin(x)/x補間などに代表されるFIRフィルタによる補間です。フィルタ係数を選ぶことでZOHやFOHを含む任意の特性にすることもできます。サンプル毎にタップ数回の積和演算を行うため、CPUで実行するには負荷がかなり高くなります。
補間エラーは、復元波形に乗るノイズとなって現れます。図3にそのエラー成分の波形を示します。ZOHでは波形の傾きの大きさに比例してエラーが大きくなっています。直線で近似するFOHでは波形が直線に近い部分でエラーは小さく、カーブする部分で大きくなる傾向があります。sin(x)/x補間は原波形を正確に復元していて、図からはエラーを読み取ることはできません。
この例では分かりやすいようにLUT長を16サンプルとしましたが、これは極端な例で実際にはもっと長い(少なくとも256サンプルの)LUTが使われます。
LUTを長くして波形のサンプル密度を高くすれば補間エラーが小さくなっていくのは容易に想像できるでしょう。図4にLUT長とノイズレベルの関係を示します。
ZOHではLUT長が2倍になるごとにノイズレベルが6dBずつ低下しています。FOHでは12dBずつと、LUT長の影響はより顕著です。それに対し、sin(x)/x補間の場合はLUT長の影響はあまり受けず、補間精度はそのフィルタ係数に依存しているようです。この例では、sin(x)/x補間の実装の一種であるLanczosフィルタ(a=7)を使用しています。
ファンクションジェネレータでは、正弦波だけではなく矩形波や三角波などの代表的な波形や任意波形も出力する必要があります。非正弦波には多くの高調波が含まれるほか、連続的であるとも限りません。それが補間にどう影響するのか示したのが図5です。この例では矩形波の場合を示していますが、再現性の高いsin(x)/x補間で矩形波らしからぬ大きなリンギングが現れています。一方、精度の悪いはずのZOHが最も「それらしい」矩形波となっています。どうしたことでしょうか。
実は、ZOHのそれらしい矩形波はLUTのデータには無い「偽の波形」で、たまたまエラーがツボに嵌ってそれっぽく見えているだけなのです。ポイント数NのLUTデータには、サンプリング定理から「基本周波数 * N / 2」以上の周波数成分(基本周波数はLUT長を周期とする周波数)の情報は存在しません。また、その周波数成分は離散的(有限数)です。有限の周波数成分で矩形波など不連続な波形を表現しようとするとこのようなリンギングが現れるのはよく知られていて(ギブス現象)、sin(x)/x補間の波形はそれを単に正確に復元した結果と言えます。とは言っても、信号の物理特性より波形そのもに意味を求める場合はこのようなリンギングは受け入れがたいものがあります。これは、sin(x)/x補間がダメというわけではなく、理論上正確な補間が常に最適というわけではないということです。汎用ファンクションジェネレータ向けとしては、リンギングを発生せずどの波形でもそこその再現性のあるフィルタ係数(FOHやガウシャンフィルタなど)の方が適しているかも知れません。