ESP32S3 A4988 ステッパモータ駆動
モータ指令値に応じた周波数のパルス列を生成するC++クラス
タイマー割込みを利用してパルスを生成

2026.7.6 2025.7.20 Coskx Lab  

1 はじめに

パルス列生成クラスで発生したパルス列で,ステッパモータドライバIC A4988を使用して,小型ステッパモータを駆動します。
モータ指令値に応じた周波数のパルス列を生成するクラスをC++で作成しました。
このクラスの初期化時点で,指令周期(モータ指令値が与えられる周期,例えば5000μsec(=5msec))と最短パルス周期長(例えば40μsec), タイマー割込み周期(例えば20μsec)を与えます。(タイマー割り込みに関しては「8 タイマー割込み」を参照してください。)
その後,指令周期(5msec)でモータ指令値が与えられることを想定しています。
最短パルス周期長はタイマー割込み周期の3倍程度にする必要があり, そのため,マイコンの持っているPWM発生機構を使用する場合より,最短パルス周期は長くなります。




ステッパモータドライバIC A4988を使用して,小型ステッパモータを駆動します。
小型ステッパモータはパルス列の周波数が高すぎる(パルス周期が短すぎる)と追随できなくなるためにも, 最短パルス周期長を指定しておく必要があります。
A4988にのpulse端子にパルス列を与えるとdir端子への入力値(H or L)に応じてステッパモータは 決められたステップ角度で正回転あるいは逆転します。
原理的な説明は次のWebページを参照してください。

≫モータドライバA4988でステッピングモータ制御モータ

1/16マイクロステップ駆動を採用します。そのため,通常のモータ1ステップ動作は1.8度の回転ですが,ここでは0.1125度(=1.8度の1/16)回転になります。

参考 モータドライバIC A4988基板実装(秋月電子販売など)


参考 使用しているモータ
名称: 3DプリンターTitan Stepper Motor (AMAZON)
モーターサイズ(L * W * H):42 * 42 * 23 mm
フェーズ:2フェーズ
電圧:4.1V
電流:1A /相
抵抗値:4.1±10%Ω/相
インダクタンス:4.1±20%mH /相
保持トルク:13N.cm以上(18.5oz.in)
ディテントトッグ:2.0N.cm(2.85oz.in)
絶縁材クラス:B
ステップ角:1.8°±5%
重量:132g

2 使用環境

3 実験用配線

次のように配線しました。
DIR端子,STEP端子,ENABLE端子はそれぞれマイコンのD0,D1,D6に接続しました。
1/16マイクロステップ設定のためMS1,MS2,MS3は,3つともH(VDD)に接続します。
RESET端子,SLEEP端子は,それぞれH(VDD),H(VDD)に接続します。
DIR端子はHLの区別でモータを正転逆転の指示を与えます。
STEP端子にパルス信号を与えるごとに,設定された角度(例えば1/16マイクロステップ設定では 1.8÷16 = 0.1125度)だけモータが回転します。



ENABLE端子は通常Lに設定します。 ステッパモータは停まっているときも動いているときも常時電流が流れています。(停まっているときに保持トルクを発生しています。)
ステッパモータを操作していないときで,保持トルク不要の場合は,電流を流さないようにしたいです。そのときは,ENABLE端子にHを与えます。
マイコンの出力端子を一つ使って,プログラムでA4988のENABLE端子にL,H信号を与えるようしています。
なお,モータ用電源は12Vを使いました。

4 電流制限の設定

説明は次のWebページを参照してください。

≫モータドライバA4988でステッピングモータ制御

5 モータ駆動デモプログラム

モータ駆動の細かなところはパルス生成クラスStepperMotorESP32_2sに任せているので,メイン側の記述は簡素になります。

・setup()内では,initialize()でピン割り当てを知らせ,setPeriods()で指令周期と最短パルス周期長,タイマー割り込み周期を与えます。
enableDriver()はENABLE端子にLを与えます。
initializeTimerInterrupt()でタイマー割り込みに関する設定を行います。(タイマー割り込みに関しては「8 タイマー割込み」を参照してください。)

・loop()ではdelay(Controlperiod / 1000);を使って,5msec間隔で起動するようにしています。(setPeriods()で与えた指令周期と一致させる)
プログラム上ではメイン側loop()で,モータ指令値を設定表(Motor_Drive_dutyRatios[])から取り出して-0.8から0.8まで変化させています。
ただし,指令値が変化するのはloop()の200回起動ごとになるため,モータへの指令値が変化するのは,1秒ごとです。

・stepper.drive(dutyRatio_Value);
はモータ指令値dutyRatio_Valueをでパルス生成クラス側に伝えます。
モータ指令値は-1.0から1.0までが有効で,正の値の場合は正転,負の場合は逆転します。絶対値が大きいほどモータは速く回ります。(絶対値が大きいほどパルス間隔は短い。)

・stepcount = stepper.getcount();
は,直前のstepper.drive(dutyRatio_Value);の時刻から現時刻までの指令周期間(5msec)に,いくつのパルスが与えられたかをしまします。(そのパルス数分だけモータが回転したことがわかります。1パルスで0.1125度回転する。)
この値はモータが指令周期5msec間に回った角度に対応するため,モータ軸の回転角速度に対応した値になります。またこの値を累積すれば,モータ軸の回転角度に対応した値になります。


パルス生成はクラスStepperMotorESP32_2s内で次のように行われています。
パルスは20μ秒間隔のタイマ割り込みによって,生成されます。
例えば周期100μsecのパルス信号は,「タイマ割り込み一回目で信号端子をHにし,二回目で信号端子をLにし,そのあと三回のタイマ割り込みでは何もしない」という動作で作られます。パルス周期は20μsecの整数倍にしかならず,最短パルス周期は40μsecと限定されます。
関数drive()内では与えられたモータ指令値の絶対値(0.0から1.0まで)から,パルス周期数(タイマ割り込み何回分でパルスを作るか)を計算します。パルス周期数はパルス周期÷割り込み周期(20μsec)で求められます。
パルス周期数は指令値の逆比例で求められます。
最小パルス周期はパルス周期数=2のときで。これはモータ指令値1.0に対応させます。 パルス周期数は,(2÷モータ指令値)を整数化したものとなります。

ただし5000μsec(5msec)を制御単位として(Controlperiod = 5000;),動作させるため,最大パルス周期はその1.5倍程度として75000μsecにするため,最大パルス周期=75000μsec,最大パルス周期数は3750になります。

関数drive()はパルス周期数を変数に保存したら,timer割り込み関数にパルス周期数を受け取るよう指示を出します。
timer割り込み関数は指示を受けたらパルス周期数を受け取り,パルスを生成します。
・駆動パルスは次のような関係になります。
 指令値絶対値が小さい パルス間隔が長い パルス周波数が低い
 指令値絶対値が大きい パルス間隔が短い パルス周波数が高い


ArduinoIDEを作業では,クラス定義ファイル(StepperMotorESP32_2s.cpp,StepperMotorESP32_2s.h)を含む次の3つのファイルを同一作業フォルダに入れてください。
3つのファイルは次からダウンロードできます。

    3つのファイルのダウンロード


  ステッパモータの動作テスト用プログラム本体(メイン)

//steppermotorA4988withclass2_singleDEMO.ino
#include <StepperMotorESP32_2s.h>

StepperMotorESP32_2s stepper;

const uint8_t PinDIR  = D0; // 右モータdir出力に使用する PIN番号
const uint8_t PinSTEP = D1; // 右モータpulse出力に使用する PIN番号
const uint8_t PinEnable = D6; // 左右モータドライブEnable(負論理)に使用する PIN番号
const uint32_t Controlperiod = 5000; //[us] =5[ms]
const uint32_t MinPulseperiod = 40;  //[us]
const uint32_t InterruptPeriod = 20; //[us]

hw_timer_t * timer = NULL;

void ARDUINO_ISR_ATTR onTimer() {
  stepper.ISRhandler();
}

void initializeTimerInterrupt() {
  timer = timerBegin(1000000); //// Set timer frequency to 1Mhz
  timerAttachInterrupt(timer, &onTimer); // Attach onTimer function to our timer.
  timerAlarm(timer, InterruptPeriod, true, 0); //20カウントなので20μsがタイマー割り込み間隔
}

void setup() {
  Serial.begin(115200);
  //while (!Serial)
  //  delay(10); // will pause Zero, Leonardo, etc until serial console opens
  delay(100);

  stepper.initialize(PinDIR, PinSTEP, PinEnable);
  stepper.disableDriver();
  stepper.setPeriods(Controlperiod, MinPulseperiod, InterruptPeriod); //50[msec] 40[us] 20[us]
  stepper.enableDriver();
  initializeTimerInterrupt();
}

void loop() {
  const double Motor_Drive_dutyRatios[] = {-0.8, -0.6, -0.4, -0.2, 0., 0.2, 0.4, 0.6, 0.8};
  const static int nRatios = sizeof(Motor_Drive_dutyRatios)/sizeof(double);
  static int increment = 1; //デューティー比番号dutyRatioIndex変更用変数
  static int dutyRatioIndex = (nRatios>>1);
  static int n=0;
  int stepcount;
  String str;

  double dutyRatio_Value = Motor_Drive_dutyRatios[dutyRatioIndex];
  stepper.drive(dutyRatio_Value);

  stepcount = stepper.getcount();

  n++;
  if (n == 1000000 / Controlperiod) {
    n = 0;

    str = "motor duty = " + String(dutyRatio_Value, 2);
    Serial.println(str);
    str = "steps = " + String(stepcount);
    Serial.println(str);

    dutyRatioIndex += increment;
    if (nRatios-1 <= dutyRatioIndex) increment = -1;
    if (dutyRatioIndex < 1) increment = 1;
  }

  delay(Controlperiod / 1000); //[msec]
}

6 実行の様子

実行時にシリアルモニタには次のように表示されます。
motor duty = xxxx ...............モータ指令値
steps = xxx .....................直前の指令周期内(5msec)に発生したパルス数

モータ指令値と直前の指令周期内に生成されたパルス数は,綺麗に比例していません。
これは整数除算上の切り捨てが原因と考えられます。
-0.2から0.8の範囲での指令値で,指令周期内(5msec)に0から125個のパルスを連続発生している様子がわかります。

motor duty = 0.00
steps = 0
motor duty = 0.20
steps = 25
motor duty = 0.40
steps = 50
motor duty = 0.60
steps = 83
motor duty = 0.80
steps = 125
motor duty = 0.60
steps = 83
motor duty = 0.40
steps = 50
motor duty = 0.20
steps = 25
motor duty = 0.00
steps = 0
motor duty = -0.20
steps = -25
motor duty = -0.40
steps = -50
motor duty = -0.60
steps = -83
motor duty = -0.80
steps = -125
motor duty = -0.60
steps = -83
motor duty = -0.40
steps = -50
motor duty = -0.20
steps = -25
motor duty = 0.00
steps = 0

指令値が0.2のときと,0.8のときの出力波形は次のようになりました。


7 考察

ケース1
指令周期と最小パルス周期が次のように設定されている場合を考えます。
Controlperiod = 5000; //[us] =5[ms]
MinPulseperiod = 40; //[us]
モータ指令値(絶対値)に対するパルス周波数の関係をまとめると,次の表のようになります。
タイマー割り込みでパルスを生成することによって制御周期が柔軟になっているため, 絶対値が0に近い指令値に対するパルス長の離散性が小さくなり自由度が高くなりました。
しかし,最小パルス周期は40μsec(タイマ割り込み周期の2倍)ですが,パルス周期は20μsecごとにしか増やせないため, 絶対値が0.3より大きな指令値に対応するパルスの生成が以下のように離散性が強くなり分解能が低くなりました。

モータ指令値割込回数パルス周期[μsec]パルス周波数[Hz]
1.024025000
0.924025000
0.824025000
0.724025000
0.636016666
0.548012500
0.4510010000
0.361208333
0.2102005000
0.1204002500
0.05408001250
0.02100020000500
0.012004000250
割込回数:1パルスを生成するのに要する20μsecの割り込み回数

ケース2
ケース1の設定では生成パルス周波数が高すぎるように思いました。 そこで指令周期と最小パルス周期が次のように設定されている場合を考えます。
Controlperiod = 10000; //[us] =10[ms]
MinPulseperiod = 60; //[us]
モータ指令値(絶対値)に対するパルス周波数の関係をまとめると,次の表のようになります。
絶対値が0.3より大きな指令値に対応するパルスの生成が以下のように離散性がケース1に比べて緩和されています。

モータ指令値割込回数パルス周期[μsec]パルス周波数[Hz]
136016666
0.936016666
0.836016666
0.748012500
0.6510010000
0.561208333
0.471407142
0.3102005000
0.2153003333
0.1306001666
0.05601200833.3
0.021503000333.3
0.013006000166.6
割込回数:1パルスを生成するのに要する20μsecの割り込み回数

8 タイマー割込み

通常の関数はどこかから呼び出されて動作が開始しますが,タイマ割り込み関数はあらかじめ設定した時間ごと(割り込み周期)に自動的に動作が開始します。
他の関数が作業中であっても押しのけて実行し,実行が終わったら元の関数が再開する仕組みになっています。
タイマ割り込みの設定では,タイマ割り込み周期を設定し,タイマ割り込み関数を登録する必要があります。
タイマ割り込みはクラス内では記述できず,メイン側でしか記述できないので, おまじないのようなタイマ割り込みの仕組みもメイン側の記述に含まれています。 (「5 モータ駆動デモプログラム」のプログラムコードの青字のところです)
timerAlarm(timer, InterruptPeriod, true, 0); でタイマ割り込み周期(InterruptPeriod)を設定し,
timerAttachInterrupt(timer, &onTimer); で,タイマ割り込み関数onTimerを登録しています。
そうすることで,割り込み周期20μ秒ごとに関数onTimer()が起動します。関数onTimer()はstepper.ISRhandler();を実行するため, stepper(クラスStepperMotorESP32_2sのインスタンス)でISRhandler()が呼び出され,パルス生成の作業が行われるようになります。
なお,割り込み周期は20μ秒に設定します。これより短い設定ではマイコンに負担がかかりすぎと思われます。

タイマー割込みで実行される関数はできるだけ短時間で作業を終了させる必要があります。(タイマー割込み関数中でループやdeleayなどは使いません)
タイマー割込み関数が長時間居座ると速,本来の作業を行う時間が少なくなってしまいます。また,当該関数が作業終了前に次のタイマー割り込みが起動してしまうと,暴走する危険があります。

タイマー割り込み関数onTimer()に「ARDUINO_ISR_ATTR」という修飾がついています。
これは,この関数をRAM領域に配置するという意味です。通常のフラッシュメモリよりRAMに配置した方が高速動作ができるからです。

9 まとめ

Xiao ESP32S3およびモータドライバA4988でステッパモータを駆動しました。
駆動ではあらかじめ指定した指令周期ごとにモータ指令値を変更するパルス生成クラスを用いて,プログラミングの負担を減らしています。
ここで使用しているパルス生成クラスでのPWM信号発生は,20μsecごとに起動するタイマ割り込み関数がソフトウェアで生成しています。
モータ指令値の絶対値が1に近づいた時の生成パルス周波数の上限が,最短パルス周期を60μsecに設定してもその逆数にしかならないため, モータ指令値と回転速度の関係が綺麗にはなりません。モータ指令値の絶対値が1に近づいたときは線形性が悪いと考えられます。
制御対象によってはモータ指令値の絶対値が1に近づいたときの特性より指令値が0に近いときの方が大事かもしれませんので,そのような対象には有利だと思います。