STM32F3、ADCとFIRとDAC。ついでにタイマとオペアンプ。


STM32F3でオーディオ信号をサンプリングし、FIRでフィルタリングして、DACで出力、というサンプルです。また、ADCとDACはTIMで駆動しています。

ハードウェア

Nucleo-F303REを使いました。他のチップでも大抵使えると思いますが、FIRを浮動小数点で処理するので、Cortex-M4Fコアが乗ったチップがいいと思います。

オーディオ信号を信号源として使いますが、オーディオ信号(ライン出力)はあまり強い信号ではありません。幸運なことにF3にはオペアンプが乗っていますから、これで増幅してからADCに入力します。
オペアンプは真面目に使おうとすると多機能な反面、回路が面倒になるので、今回は手抜きでPGAモードで使うことにします。ただしPGAモードでは信号の基準電圧がかなり重要になってきます。DCブロックのコンデンサの後で、分圧抵抗で適当な電圧に釣っておきます。今回は8倍で0.2V狙い(3.3V/2/8)の、3kΩと200Ωで分圧抵抗を組みました。

ピンアサイン

今回必要なのは、PA4のDAC1_OUT1、PA6のOPAMP2_VOUT/ADC2_IN3、PA7のOPAMP2_VINP、の3本です。

先程の回路で0.2Vを中心に適当な振幅でスイングするオーディオ信号を、まずPA7のVINPで受けます。オペアンプを通った後に、その出力はPA6のVOUTに出力されます。また、ADCの入力はVOUTと同じピンに設定したため、チップ内で接続が完了し、外部で配線は必要ありません。
DACから出力した信号はPA4から出力されます。今回はオシロで確認するだけで済ましていますが、実際にオーディオ信号として使うには、LPFやアンプのような回路がいろいろ必要になるはずです。

オペアンプ

PGA Not Connectedを選び、Gainを8に設定するだけです。他のゲインを使う場合は入力の分圧抵抗を変える必要があるので注意してください。

ADC

DMAタブでDMAを有効にしておきます(詳しくは後述)。

DAC

DMAもお忘れなく(後述)。

DMA

サーキュラモード、メモリのインクリメントが有効、Half Word、という感じの設定です。

TIM

タイマのクロックが72MHzなので、1500で割ってサンプリングレート48kHzとなります。

ソフトウェア

makefile

DSPライブラリの一部はバイナリで配布されているので、makefileに追加しておきます。

こんな感じになります(LIBS +=の行を追加)。

# libraries
LIBS = -lc -lm -lnosys 
LIBS += Drivers/CMSIS/Lib/GCC/libarm_cortexM4lf_math.a
LIBDIR = 
LDFLAGS = $(MCU) -T$(LDSCRIPT) $(LIBDIR) $(LIBS) -Wl,-Map=$(BUILD_DIR)/$(TARGET).map,--cref -Wl,--gc-sections

main.c

インクルード

以下を追加します。

#define ARM_MATH_CM4
#include <../Drivers/CMSIS/DSP/Include/arm_math.h>

メモリ定義

#define ADC_DAC_buff_len 256
int16_t ADC_buff[2][ADC_DAC_buff_len];
int16_t DAC_buff[2][ADC_DAC_buff_len];

float fir_coeff[] = {
// 省略
};
arm_fir_instance_f32 fir_instance;
float fir_buff[ADC_DAC_buff_len];
float fir_work_area[sizeof(fir_coeff) / sizeof(fir_coeff[0]) + ADC_DAC_buff_len - 1];

firで結構メモリを食っています。
firの係数はどこかで拾ってきてください。石川高専のDigital Filter Design Servicesあたりが使いやすくて便利だと思います。カンマをつけて生成すれば直接コピペして使えます。

コールバック/フィルタ周りの関数

void process_ADC_to_DAC(int16_t *const src, int16_t *const dst)
{
    arm_offset_q15(src, -2048, src, ADC_DAC_buff_len);
    arm_q15_to_float(src, fir_buff, ADC_DAC_buff_len);

    arm_fir_f32(&fir_instance, fir_buff, fir_buff, ADC_DAC_buff_len);

    arm_float_to_q15(fir_buff, dst, ADC_DAC_buff_len);
    arm_offset_q15(dst, +2048, dst, ADC_DAC_buff_len);
}

void HAL_ADC_ConvHalfCpltCallback(ADC_HandleTypeDef *const hadc)
{
    HAL_GPIO_WritePin(LD2_GPIO_Port, LD2_Pin, GPIO_PIN_SET);
    process_ADC_to_DAC(ADC_buff[0], DAC_buff[0]);
    HAL_GPIO_WritePin(LD2_GPIO_Port, LD2_Pin, GPIO_PIN_RESET);
}

void HAL_ADC_ConvCpltCallback(ADC_HandleTypeDef *const hadc)
{
    HAL_GPIO_WritePin(LD2_GPIO_Port, LD2_Pin, GPIO_PIN_SET);
    process_ADC_to_DAC(ADC_buff[1], DAC_buff[1]);
    HAL_GPIO_WritePin(LD2_GPIO_Port, LD2_Pin, GPIO_PIN_RESET);
}

メインの処理がここです(後述)。

開始

arm_fir_init_f32(&fir_instance, sizeof(fir_coeff) / sizeof(fir_coeff[0]),
                    fir_coeff, fir_work_area, ADC_DAC_buff_len);

HAL_OPAMP_Start(&hopamp2);

HAL_ADC_Start_DMA(&hadc2, (uint32_t *)ADC_buff, ADC_DAC_buff_len * 2);
HAL_DAC_Start_DMA(&hdac1, DAC_CHANNEL_1, (uint32_t *)DAC_buff,
                    ADC_DAC_buff_len * 2, DAC_ALIGN_12B_R);

HAL_TIM_Base_Start(&htim6);

main()の無限ループの前の適当なところで呼び出します。

解説

起動

まずarm_fir_init_f32でFIRを初期化しておきます。
その後、オペアンプを起動し、ADCとDACも起動し、TIMを開始します。簡単ですね!!!

コールバック

ADCのDMAから、半分転送完了と全転送完了の割り込みがかかり、HALがいろいろ処理したあとでコールバックが呼ばれます。コールバックの中ではLEDのピンをバタバタさせてデバッグ用にしています。
ADCとDACのバッファは2次元で定義してあるので、HalfかCpltかで処理する行を変えています。

RTOSとかを使う実際のアプリケーションでは、コールバック関数内でセマフォを操作して、スレッド内でフィルタ処理を呼ぶ、という感じになると思います(割り込み内で長時間の処理は厳禁です)。

フィルタ周り

ADC/DACのデータは符号付き16bit整数の配列として扱います。
FIRは浮動小数点で計算するので、整数<-->浮動小数点の変換が必要です。forを回してもいいですが、専用の関数が定義されているので、それを使います。
ただし、FIRでHPFを通すような使い方の場合、DCオフセットが問題になってきます。今回はFIRの前後で-2048と+2048のオフセットを与えています。浮動小数点で計算するより、16bit整数の段階で計算したほうが、SIMDが使える分早いと思います。

波形(オシロで眺めてみる)

この2枚は、48kSPS、tap=127、BPF(3-6kHz)、といった特性で、ホワイトノイズを入力しています。


上が広域の周波数特性、下がDC近くの周波数特性、です。
広域では24kHzを中心にミラーになって、それが48kHz毎に繰り返している感じです。おそらく、DACの出力(離散値)を直接見ているのが原因だと思います。DAC後段のLPFって重要ですね。
低周波側では、おおよそいい感じにフィルタとして機能しています。

下の方に188.10Hzと37.43%という表示があります。これはコールバックでジタバタさせたLEDを見ているプローブの表示です。
48kHz/256サンプル=187.5Hzなので、ほぼ正確なタイミングで割り込みが呼ばれていることがわかります(若干誤差があるのは、水晶ではなくHSIを使っているのが要因です)。
また、パルス幅が37%ですから、信号処理にコアの4割近いリソースを使っている、と判断できます。結構重いですね。


この2枚は、48kSPS、tap=31、LPF(12kHz)、といった特性です。


上の画像はホワイトノイズを与えています。
下の画像は正弦波を与え、信号が開始した瞬間をキャプチャしたものです。

下の画像を詳しく見ていきます。
紫(2ch)が入力、黄(1ch)がDAC出力です。
入力と出力の遅延は(バッファの長さ + タップ数/2) / サンプリング周波数になります。今回の場合はバッファが1順するのに512サンプル、タップ数は31なので、(512+31/2) / 48k = 10.99ミリ秒、となり、実測10.98ミリ秒と一致します。
結構遅延しますね。バッファを短くすれば遅延は小さくなりますが、頻繁に割り込みが発生し、RTOSを使うような場合は頻繁にコンテキストスイッチが発生するので、小さすくしすぎるとあまり良くありません。(蛇足ですが、テレビのデジタル放送で時報が消えたのは、デジタル処理ではこのように大量の遅延がいともたやすく発生してしまうためです)

ちなみに、パルス幅は13%程度です。タップ数が減った分、計算量もそれに見合うくらいには少なくて済んでいます。

その他

今回はADCの入力からDC成分をソフトウェアで取り除いていますが、STM32F3のADCであれば、ハードウェアでオフセットを計算できます。オフセット処理が1回無くなる分、気休め程度には処理時間は短くなります。本当に気休め程度ですけど。。


みなさんもSTM32とCubeMXで快適なマイコンライフを!!!