見出し画像

STM32でSinusoidal-PWMをDMAで実装する方法


はじめに

 SPWMは最も基本的な変調の一つで、実験において任意のPWMゲート信号が必要になることがあります。本記事ではスイッチング周波数、変調周波数、変調度を可変可能なコントローラをSTM32を用いて実装する方法について説明します。

通常PWMでは、キャリアの頂点で事前計算したデューティサイクルを書き込むDouble update PWM, Single update PWMが望まれます。DSPやFPGAであれば可変させた周波数に対して決まった割り込みタイミングでのデータ更新は、その計算資源の豊富さから容易です。一方でマイコンではキャリアを生成するタイマーカウントのオーバーフローをトリガーにしたタイマー割込みによって同等の機能が実現できますが、キャリアの周波数の増加に伴ってデューティサイクルの計算猶予時間が減少し、適切にデータ更新を行うことが難しくなります。

そこで本記事ではDirect Memory Access (DMA)を用いてあらかじめ信号生成に用いるデータをメモリに格納しておき、タイマーのデューティサイクルを決定するCapture Compare Register (CCR)レジスタにCPUによる計算を行わずにメモリから直接書き込むことで、幅広い周波数帯のPWM信号をサクッと生成することを目指します。

下図にPWM信号生成の概略図を示します。UARTでST-Linkを通じてコンソールを接続し、printf/scanfでスイッチング周波数(キャリア周波数)、PWM変調周波数、変調率を制御します。Main関数内で信号出力のためのデータBuffer配列をあらかじめメモリに書き込みます。Timer 1が信号生成の主機能を担い、そのCCRレジスタはTimer 2によってDMAを通じて更新されます。また、生成された信号を確認するために、メモリに格納されたデータをDACを用いてアナログ信号として出力します。このDACの動作タイミングはもTimer 2のTRGOによって制御されるため、最終的に出力されるPWM信号およびDACによるアナログ信号は同期されます。

Fig. 1: Abstract diagram of PWM generation

本記事では初めにST-LINKを用いたUART通信によるprintf/scanfの実装から解説し、続いてハードウェア設定、最後にソフトウェア部分についてその実装方法を説明します。本記事における評価ボードはSTM32H7ZI2を使っています。


printf/scanfの実装

 ここではprintf/scanfの実装方法について説明します。STM32はUART通信をST-LINKというハードウェアを通じて行います(cf. Fig. 2)。したがって、まず初めにST-LINKをSTM32に接続し、適切な接続設定をハード、およびソフトで行う必要があります。

Fig. 2: Nucleo-144 board top layout (STMicroelectronics N.V., "STM32H7 Nucleo-144 User manual")

ハードウェア設定

 まず初めにSTM32のUARTがどのピンに設定されているかを確認する必要があります。Nucleoの各ボードの名称は、背面側に記載されています(cf. Fig. 3)。このボードの名前のデータシートを検索し(e.g. datasheet MB1364E)、USART通信について書かれているセクションを参照します。

Fig. 3: Nucleo-144 board bottom layout (STMicroelectronics N.V., "STM32H7 Nucleo-144 User manual")

ST-LINKを使用するためにジャンパーの設定が必要なことがあるので、データシートを注意深く読みます。STM32H7の場合初期設定でUSART3通信が有効になっている旨が上記データシートに記載されています。

データシートによると、$${\textit{PD8}}$$および$${\textit{PD9}}$$がUSARTに使用されていることが分かります。そこで、以下のようにCubeIDEを用いてピンアウトを設定します。

ModeをAsynchronousに、後デフォルト設定を利用します。後ほどターミナルからアクセスする際にBaud Rate, Word Length, Parity, Stop Bitsが必要になるので覚えておきます。

ソフトウェア設定

続いてソフトウェア側の設定を行います。UARTが有効になると、Hardware Abstraction Layer (HAL)を用いて以下のコードでRead/Wrinteが可能になります。

HAL_StatusTypeDef HAL_UART_Transmit(UART_HandleTypeDef *huart, const uint8_t *pData, uint16_t Size, uint32_t Timeout)
HAL_StatusTypeDef HAL_UART_Receive(UART_HandleTypeDef *huart, uint8_t *pData, uint16_t Size, uint32_t Timeout)

これらは\Drivers\STM32xxxx_HAL_Driver\Src\フォルダ内にある"stm32xxxx_hal_uart.c"内で定義されています。そこで、この関数を用いて以下のように標準入力とUARTをコンパイラに接続させます。

/* USER CODE BEGIN PFP */
#ifdef __GNUC__
#define PUTCHAR_PROTOTYPE int __io_putchar(int ch)
#define GETCHAR_PROTOTYPE int __io_getchar(void)
#else
#define PUTCHAR_PROTOTYPE int fputc(int ch, FILE *f)
#define GETCHAR_PROTOTYPE int fgetc(FILE *f)
#endif

/* printf implementation-----------------------------------------*/
PUTCHAR_PROTOTYPE
{
  HAL_UART_Transmit(&huart3, (uint8_t *)&ch, 1, HAL_MAX_DELAY);
  return ch;
}
/* scanf implementation-----------------------------------------*/
GETCHAR_PROTOTYPE
{
    uint8_t ch = 0;
    __HAL_UART_CLEAR_OREFLAG(&huart3);
	HAL_UART_Receive(&huart3,(uint8_t *)&ch, 1, HAL_MAX_DELAY);
	HAL_UART_Transmit(&huart3, (uint8_t *)&ch, 1, HAL_MAX_DELAY); 
	return ch;
}
/* USER CODE END PFP */

また、printf/scanfを利用するためにライブラリを読み込むのを忘れないようにしましょう。

/* USER CODE BEGIN Includes */
#include <stdio.h>
/* USER CODE END Includes */

上記のコードにおいて"huart3"‘となっている個所はUSART3を使用するためです。ハードウェアコンフィギュレーション後にコード生成をしたタイミングで以下のコードが自動生成されているはずです。

/* Private variables ---------------------------------------------------------*/
UART_HandleTypeDef huart3;

使用しているboardによって変わる可能性があるので、変数定義を確認しましょう。

STM32CubeIDEが自動生成するsyscalll.cでは、入力ストリームの内部バッファリングが有効になった際に予期しない動作が発生することが知られています[1]。この問題を回避するために、scanfの実効前にこの内部ストリームのバッファリングを以下のコードで無効にします。

int main(void)
{
  /* USER CODE BEGIN 1 */
	setvbuf(stdin, NULL, _IONBF, 0); //disable buffering for input stream
...

ターミナル設定

最後にターミナル設定を行います。まずデバイスマネージャーをSTM32をPCに接続した状態で開き、マイコンが接続されているCOMポートを確認します。下の図のケースではCOM8に接続されています。

CubeIDEのコンソールウィンドウで、"open console -> 3 Command Shel Console"を順に選択します。

Connection Typeを"Serial Port"に変更し、Connection nameを適当に設定したのち、New… を押して接続設定を行います。

先ほどハードウェア設定で行った情報を入力し、Finisihを押します。

実際にprintf/scanfを実行して動作を確認します。

printf/scanfでfloatを用いる場合は、project propertyからSettigng -> Tool Settings -> MCU Settingsと設定を開き、Use float with printf… および Use float with sacf… を有効にする必要があります。

MCU settings for activating float in printf and scanf.

以上でprintf/scanfの設定は終わりです。


SPWMの実装

ハードウェア設定

STM32H7Zには様々なタイマーが用意されています。中でもTimer 1, 8, 15はAdvaned TimerとしてComplementalなPWM信号を生成する機能があります
。そこで、Timer 1をPWM生成に、Timer 2をDMA制御に使います。少し動作が煩雑なので、ここで各タイマーの持つ役割を説明します。

Fig. 4: PWM waveforms of each timer.

まず、Timer 1, Timer 2は共にSystem Clockから供給されるTimer ClockでAARの値に到達するまでカウントアップされます。Timer 1はCCR1をデューティサイクルとしてその比較結果をPWM信号として出力します。Timer 2はカウントアップがオーバーフローしたタイミングでTRGOを出力し、DACを駆動するとともにメモリから正弦波データをCCR1に書き込みます。

Clock Configuration

したがって、AAR1の値はスイッチング周波数$${f_\mathrm{sw}}$$, クロック周波数$${f_\mathrm{clk}}$$およびその逆数$${T_\mathrm{sw}, T_\mathrm{clk}}$$を用いて

$$
AAR1=\frac{T_\mathrm{sw}}{T_\mathrm{clk}}=\frac{f_\mathrm{clk}}{f_\mathrm{sw}},
$$

とあらわされます。実際には、クロック周波数はPrescaler (PSC)によって逓倍されることと、オーバフローの際の1カウントアップを考慮する必要があり、実際のAAR1の値は

$$
AAR1=\frac{f_\mathrm{clk}}{f_\mathrm{sw}(PSC+1)}-1,
$$

とあらわされます。一方で、Timer 2は正弦波の周波数を$${f_\mathrm{pwm}}$$, 正弦波データのバッファサイズをBufferSizeとすると、正弦波データのすべてを正弦波一周期の間に送る必要があることから

$$
AAR2 = \frac{f_\mathrm{clk}}{f_\mathrm{pwm}\cdot \mathrm{BufferSize }\cdot(PSC+1)}-1,
$$

となります。以上が各Timerが担う役割と具体的な設定値になります。

ここからはCubeIDEで各TimerおよびDACを設定します。まずはTimer 1を有効にします。Clock SourceをInternal Clockに、Channel1をPWM Generation CH1 CH1Nにします。Counter Settingsなどは後ほどソフトウェア側から初期化するので、デフォルトの状態にしておきます。Adcanced Timer であるTimer 1のPWM生成にはたくさんの機能がありますが、最も標準的なPWM mode 1を使います。また、ピンアウト先をGPIO Settingsで確認しておきます。

続いてTimer 2を有効にします。Timer 1と同様にClock SourceをInternal Clockに、また、Channel1はOutput Compare CH1に設定します。またTrigger Output ParametersのTrigger Event SlectionをUpdate Eventに設定します。

さらに、DMA SettingsでDMA RequestをTime2_UPにします。これでDMAがDACと同様Timer 2のUpdate Eventで駆動されるようになります。Stream番号を選択し、DirectionをMemory to PeriheralにPriorityはDACよりも優先される必要があるのでHighにします。さらに、ModeをCircuilarに、FIFOの使用にチェックマークを入れます。

最後にDACの設定を行います。オシロスコープで波形を確認することが目的なので、OUT1の接続先をonly external pinに、さらにTriggerをTimer 2 Trigger Out Eventに設定します。この設定でTimer 2とDACが同期します。DMAの設定をTimer 2同様に行います。

以上でハードウェア設定は終了です、コード生成を行いましょう。

ソフトウェア設定

始めにバッファに関するプライベート変数を定義します。バッファサイズは正弦波の精度に影響を与えます。また、正弦波の値の上限値は、Timer 1はCCR1の上限、すなわちAAR1の値ですが、DACの上限は12 bitの上限の4096なので、個別にバッファを用意します。ダブルバッファで書き込むのでメモリが直接読み取るCCR_Value_Buffer, DAC_Value_Bufferおよびダブルバッファの_Updateを宣言します。

/* USER CODE BEGIN PV */
#define BufferSize	100
#define PI 3.1415926

ALIGN_32BYTES (uint32_t CCRValue_Buffer[BufferSize]);
ALIGN_32BYTES (uint32_t CCRValue_Buffer_Update[BufferSize]);
ALIGN_32BYTES (uint32_t DACValue_Buffer[BufferSize]);
ALIGN_32BYTES (uint32_t DACValue_Buffer_Update[BufferSize]);
/* USER CODE END PV */

続いてプライベート関数を定義します。gen_dataは設定したバッファサイズの正弦波データを生成します。変数amplitudeは0-1の変調率、所謂Modulation Indexです。update_bufferは連続したバッファアドレスを更新する関数です。HAL_DAC_ConvHalfCpltCallbackCh1およびHAL_DAC_ConvCpltCallbackCh1はライブラリで用意された割り込み関数で、DACのDMAの半分の転送が終わった際およびすべての転送が終わった際にコールバックされる関数です。この関数内にupdate_bufferを登録しておくことで、自動的にダブルバッファが半分ずつDACおよびCCR1に転送されます。これらのHALを利用できるように、"stm32h7xx_hal.h"のインクルードを忘れずに行いましょう。

/* USER CODE BEGIN Includes */
#include <stdio.h>
#include "stm32h7xx_hal.h"
/* USER CODE END Includes */
/* USER CODE BEGIN PFP */

// PRINTF SCANF IMPLEMENTATION BEGIN //
#ifdef __GNUC__
#define PUTCHAR_PROTOTYPE int __io_putchar(int ch)
#define GETCHAR_PROTOTYPE int __io_getchar(void)
#else
#define PUTCHAR_PROTOTYPE int fputc(int ch, FILE *f)
#define GETCHAR_PROTOTYPE int fgetc(FILE *f)
#endif

PUTCHAR_PROTOTYPE
{
	HAL_UART_Transmit(&huart3, (uint8_t *)&ch, 1, HAL_MAX_DELAY);
	return ch;
}

GETCHAR_PROTOTYPE
{
uint8_t ch = 0;
/* Clear the Overrun flag just before receiving the first character */
__HAL_UART_CLEAR_OREFLAG(&huart3);
/* Wait for reception of a character on the USART RX line and echo this
 *
 */
	HAL_UART_Receive(&huart3,(uint8_t *)&ch, 1, HAL_MAX_DELAY);
	HAL_UART_Transmit(&huart3, (uint8_t *)&ch, 1, HAL_MAX_DELAY);
	return ch;

}
// PRINTF SCANF IMGPLEMENTATION END //

// DATA GENERATION//
void gen_data(double amplitude, int f_clk, int f_sw, int pre_scaler){
	  for (int i = 0; i < BufferSize; i++) {
	          CCRValue_Buffer_Update[i] = (int)((amplitude*0.5*sin(i*2*PI/BufferSize) + 0.5)*(f_clk/(2*f_sw*(pre_scaler+1))-1));
	          DACValue_Buffer_Update[i] = (int)((amplitude*0.5*sin(i*2*PI/BufferSize) + 0.5)*(4096));
	      }
}
// DATA GENERATION END //

// DOUBLE BUFFERING //
void update_buffer(uint32_t start, uint32_t end){
	for (uint32_t i = start; i < end; i++) {
		CCRValue_Buffer[i] = CCRValue_Buffer_Update[i];
		DACValue_Buffer[i] = DACValue_Buffer_Update[i];
	  }
	SCB_CleanDCache_by_Addr((uint32_t*)CCRValue_Buffer, BufferSize * sizeof(uint32_t));
	SCB_CleanDCache_by_Addr((uint32_t*)DACValue_Buffer, BufferSize * sizeof(uint32_t));
}
void HAL_DAC_ConvHalfCpltCallbackCh1(DAC_HandleTypeDef *hdac) {
	// First Half Data Generation //
	update_buffer(0,BufferSize/2);
}
void HAL_DAC_ConvCpltCallbackCh1(DAC_HandleTypeDef *hdac) {

	// Later Half Data Generation //
	update_buffer(BufferSize/2,BufferSize);

}
// DOUBLE BUFFERING END //

/* USER CODE END PFP */

次に、メイン関数内で変数宣言を行います。キャッシュを有効化したのち、スイッチング周波数、PWM変調周波数など、計算に必要なパラメータを宣言します。

  /* USER CODE BEGIN 1 */
	/* Enable I-Cache---------------------------------------------------------*/
	SCB_EnableICache();

	/* Enable D-Cache---------------------------------------------------------*/
	SCB_EnableDCache();

	/* disable buffering for input stream-------------------------------------*/
	setvbuf(stdin, NULL, _IONBF, 0);

	/* Configure Initial Frequency for Timers---------------------------------*/
	int f_sw = 50000; 			// Hz
	int f_pwm = 100;			// Hz
	int f_clk = 200000000; 		// Hz
	int pre_scaler = 1;
	int deadtime = 100;			// ns
	float duty = 0.5;
	float amplitude = 0.9;

  /* USER CODE END 1 */

次に各ハードウェアを起動します。初期変数に基づいて各レジスタに値を代入し、HAL_Startを利用して各TimerおよびDMAを起動します。

 /* USER CODE BEGIN 2 */
  /* Timer 1 Configuration---------------------------------------------------------*/
  TIM1->PSC = pre_scaler;             								// Pre-scaler = 1
  TIM1->ARR = (int) f_clk/(f_sw*(pre_scaler+1))-1; 					// set frequency (fclk/((PSC+1)(ARR+1))=fsw)
  TIM1->CCR1 = (int) (TIM1->ARR+1) * duty;        					// duty cycle for CH1. Adjust as needed.
  TIM1->BDTR &= ~TIM_BDTR_DTG; 										// Clear dead-time bits
  TIM1->BDTR |= (int) deadtime / 5; 								// 5 ns = 1/fclk when fclk = 200 MHz

  /* Timer 2 Configuration---------------------------------------------------------*/
  TIM2->PSC = pre_scaler;             								// Pre-scaler = 1
  TIM2->ARR = (int) f_clk/((f_pwm*BufferSize)*(pre_scaler+1))-1;  	// set frequency (fclk/((PSC+1)(ARR+1))=fsin)
  TIM2->CR2 &= ~TIM_CR2_MMS; 										// Clear MMS bits
  TIM2->CR2 |= TIM_TRGO_UPDATE; 									// Trigger DMA on update

  /* Timer Start-------------------------------------------------------------------*/
  HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_1);  						// start PWM timer
  HAL_TIMEx_PWMN_Start(&htim1, TIM_CHANNEL_1); 						// start complementay PWM timer
  HAL_TIM_Base_Start(&htim2);										// start Timer 2

  /* DMA Configuration---------------------------------------------------------*/
  gen_data(amplitude, f_clk, f_sw, pre_scaler);						// initial data generation

  // Start DMA transfer from sine buffer to CCR1
  HAL_DMA_Start_IT(&hdma_tim2_up, (uint32_t)CCRValue_Buffer, (uint32_t)&(TIM1->CCR1), BufferSize);
 
  // Enable DMA request for Timer 2
  __HAL_TIM_ENABLE_DMA(&htim2, TIM_DMA_UPDATE);
  
  // Start DMA transfer from sine buffer to DAC
  HAL_DAC_Start_DMA(&hdac1, DAC_CHANNEL_1, (uint32_t)DACValue_Buffer, BufferSize, DAC_ALIGN_12B_R);

  /* USER CODE END 2 */

最後にループ部分で周波数の変更を待ち受けます。

  /* USER CODE BEGIN WHILE */
  printf("\033[H\033[J"); // Clear console window
  printf("\r\nSwitching frequency: %d kHz PWM frequency: %d Hz \r\n",f_sw/1000,f_pwm);
  while (1)
  {
    /* USER CODE END WHILE */

    /* USER CODE BEGIN 3 */
	  printf("Enter switching frequency in kHz: ");
	  scanf("%d", &f_sw);
	  printf("Enter PWM frequency in Hz: ");
	  scanf("%d", &f_pwm);
	  printf("Enter Amplitude of Sine Wave (0.0 - 1.0): ");
	  scanf("%f", &amplitude);
	  if(amplitude < 0.0f) amplitude = 0.0f;
	  if(amplitude > 1.0f) amplitude = 1.0f;

	  TIM1->ARR = (int) f_clk/(f_sw*1000*(pre_scaler+1))-1;
	  TIM2->ARR = (int) f_clk/((f_pwm*BufferSize)*(pre_scaler+1))-1;
	  gen_data(amplitude, f_clk, f_sw*1000, pre_scaler);

	  printf("\r\Switching: %d kHz PWM: %d Hz Amplitude: %.2f Deadtime: %d ns \r\n",f_sw,f_pwm,amplitude,deadtime);
  }
  /* USER CODE END 3 */

実装結果

 下の図はキャリア周波数10 kHz, PWM周波数100 Hzをバッファサイズ100で実装した際の測定波形です。100 Hzの波形が100 分割されるのでそのステップは100 usであり、わずかですがDACの出力が離散的に出力されているのが確認できます。またFFTの結果として、PWM出力信号が0 Hz, 100 Hz, 10 kHzにピークを持っている、すなわち設計通りに動作していることが確認できます。

$${f_\mathrm{sw}=10\ \rm{kHz}}$$, $${f_\mathrm{pwm}=100\ \rm{Hz}}$$, and Buffer size = 100

さらに同じ周波数条件でバッファサイズを100から1000に増加させると、離散的に見えていた正弦波が滑らかになっていることが確認できます。すなわち、バッファサイズが正弦波の精度を決めているということが分かります。

$${f_\mathrm{sw}=10\ \rm{kHz}}$$, $${f_\mathrm{pwm}=100\ \rm{Hz}}$$, and Buffer size = 1000

最後に、スイッチング周波数を500 kHz, PWM周波数を200 Hzに設定した結果を載せます。スペクトラムの結果から、502 kHz, 200.9 Hz と設定より僅かに高い値で駆動できていることを示しています。このように、どれだけスイッチング周波数を増加させてもDMAからの正弦波データの送信は独立しているため、そこそこの精度で正弦波を出力することができます。

$${f_\mathrm{sw}=500\ \rm{kHz}}$$, $${f_\mathrm{pwm}=200\ \rm{Hz}}$$, and Buffer size = 1000

おわりに

 本記事ではSTM32を用いて正弦波PWMを生成する手法を説明しました。通常はキャリアに同期させた割り込みでデューティサイクルを更新することが望ましいですが、割り込みタイミングを犠牲にすることで、DMAを使ってマイコンでサクッとスイッチング周波数の高いPWM信号を生成することができました。実は今回のコードではダブルバッファはほとんど意味がなく、将来のリアルタイム波形合成等を見据えて実装しておいたものになります。バッファに格納するデータを変えることで任意の波形をDACでも観測できるので、いろいろ試してみると面白いと思います。
 これまで様々なデバイスで信号生成をしてきたのですが、環境が変わるたびに忘れるので備忘録として残しておきます。似た境遇の人の役に立てば幸いです。

参考文献

  1.  Digikey TechForum, "Easy Use scanf on STM32", (2024 final access)

  2. STMicroelectronics Community "How to use the STM32CubeIDE terminal to send and receive data?", (2024 final access)


この記事が気に入ったらサポートをしてみませんか?