Skip to Content

Controlling a stepper motor with an ESP32 PLC

How to control a stepper motor using a driver and the ESP32 PLC PWM outputs
June 15, 2026 by
Controlling a stepper motor with an ESP32 PLC
Boot & Work Corp. S.L, Ricard Franch Argullol

Complete guide: Choosing an industrial controller

To control a stepper motor with an ESP32 PLC, connect a stepper driver such as the TB6600 between the PLC and the motor. One PWM output of the PLC sends the step pulses (PUL), a second output sets the direction (DIR) and a third one enables the driver (ENA). The pulse frequency sets the speed: rpm = frequency × 60 / (200 × microsteps) for a 1.8° motor. On the ESP32 PLC 21, 42 and 58 the Q0.x outputs come from a PCA9685 chip, so the step frequency goes from 24 Hz to 1525 Hz: 3.6 to 228.9 rpm with 2 microsteps. The PLC does not count the pulses, so the position is open loop and comes from the time the pulses run.

This guide shows the wiring, the driver settings and two Arduino sketches for the setup we used in our lab, a 17HS16-2004S NEMA 17 motor and a TB6600 microstep driver: one moves the motor at a constant speed and the other one adds an acceleration ramp.

What speed can an ESP32 PLC give a stepper motor?

The speed depends on the microstep setting of the driver and on the frequency range of the PWM output. On the ESP32 PLC 21, 42 and 58, Q0.0 to Q0.7 are outputs of a PCA9685 PWM chip at I2C address 0x40, with a 25 MHz clock and a 12-bit counter. The Industrial Shields ESP32 boards package (version 2.7.1) accepts frequencies from 24 Hz to 1525 Hz on these outputs (1526 Hz is rejected by the core; a request for 1525 Hz gives the highest frequency the chip makes, 1525.9 Hz). With a 200-step motor:

MicrostepsPulses per turnLowest speed (24 Hz)Highest speed (1525.9 Hz)
1 (full step)2007.2 rpm457.8 rpm
24003.6 rpm228.9 rpm
48001.8 rpm114.4 rpm
816000.9 rpm57.2 rpm
1632000.45 rpm28.6 rpm
3264000.22 rpm14.3 rpm

Two details of the PCA9685 change how you write the program:

  • The frequency moves in steps. The chip can only make 25 MHz / (4096 × (prescaler + 1)), with a whole prescaler. Near the top of the range the steps are large: 1525.9, 1220.7, 1017.3, 871.9 and 762.9 Hz are consecutive values. If you ask for 333 Hz you get 339 Hz, so the motor turns 1.8 % faster than requested. The sketches below calculate the real frequency and use it to time the movement.
  • The frequency is the same for the whole chip. Changing it for Q0.0 changes it for every PWM output of that PCA9685. Use the other outputs of the chip as on/off signals, like DIR and ENA here, or for loads that do not depend on the frequency.

On the ESP32 PLC 14, Q0.0 to Q0.3 are direct pins of the ESP32 with its own LEDC PWM, so the 24 to 1525 Hz limit of the PCA9685 does not apply to them. The sketches in this post are written for the PCA9685 outputs.

How a stepper motor and its driver work

A stepper motor turns in fixed angular steps instead of turning continuously. The 17HS16-2004S has a step angle of 1.8°, which is 200 steps per turn. Each pulse that the driver receives moves the motor one step, or one fraction of a step in microstep mode, so the number of pulses sets the position and the pulse frequency sets the speed.

The PLC cannot power the motor coils directly. The driver receives three logic signals and switches the coil current:

  • PUL (pulse): one pulse per step or microstep.
  • DIR (direction): clockwise or counterclockwise.
  • ENA (enable): with the inputs wired as in this guide, a high signal disables the driver and frees the motor, and a low signal enables it.

NEMA 17 17HS16-2004S motor specifications

SpecificationValue
TypeBipolar
Holding torque0.45 Nm
Rated current2 A per phase
Phase resistance1.1 Ω
Inductance2.6 mH
Step angle1.8° (200 steps per turn)

TB6600 driver settings

The driver has six DIP switches. S1 to S3 select the microsteps and S4 to S6 the current. The table printed on the driver used in this test gives these combinations:

MicrostepsPulses per turnS1S2S3
1200ONONOFF
2 (2/A)400ONOFFON
4800ONOFFOFF
81600OFFONOFF
163200OFFOFFON
326400OFFOFFOFF

For the 2 A motor, set the current to 2.0 A (2.2 A peak): S4 ON, S5 OFF, S6 OFF. The sketches use 2 microsteps. Driver versions can print a different table, so always follow the label of your driver.

Wiring the ESP32 PLC to the TB6600 driver

FromToFunction
Q0.0PUL+Step pulses
Q0.1DIR+Direction
Q0.2ENA+Enable
PLC GNDPUL-, DIR-, ENA-Common of the signals
Motor blue / redA- / A+Coil A
Motor black / greenB- / B+Coil B
24 Vdc power supplyPLC Vdc and driver VCCPower

The outputs of the ESP32 PLC take their voltage from QVdc. In this setup QVdc is connected to 5 V and COM(-) to GND, so the signals reach the driver at 5 V. The same 24 Vdc supply feeds the PLC and the driver. Check the colours of your motor cable in its datasheet: they change between manufacturers.

Wiring diagram: 24 V supply to the ESP32 PLC and the TB6600, outputs Q0.0, Q0.1 and Q0.2 to PUL+, DIR+ and ENA+, and the motor coils to A and B
Wiring of the power supply, the ESP32 PLC, the TB6600 driver and the NEMA 17 motor.

Arduino sketch: constant speed

Install the Industrial Shields ESP32 boards package in the Arduino IDE and select your ESP32 PLC model. The package already includes analogWriteSetFrequency() and analogWrite() for the PCA9685 outputs, so no extra library is needed. The first version of this post used the Adafruit PWM Servo Driver library on the same chip; with the current package it is not necessary.

The function moveRevs() receives the turns (negative to turn the other way) and the speed in rpm. It sets the frequency, starts the pulses with a 50 % duty cycle (2048 of 4095), waits the time that the real frequency needs to send the pulses and stops them.

// Stepper motor at constant speed with an ESP32 PLC and a TB6600 driver
// Q0.0 -> PUL+, Q0.1 -> DIR+, Q0.2 -> ENA+, GND -> PUL-, DIR-, ENA-

#define STEP_PIN Q0_0
#define DIR_PIN  Q0_1
#define ENA_PIN  Q0_2

#define MOTOR_STEPS 200                         // 1.8 degree motor
#define MICROSTEPS  2                           // DIP switches of the driver
#define STEPS_REV   (MOTOR_STEPS * MICROSTEPS)  // 400 pulses per turn

// Q0.x outputs come from a PCA9685 (25 MHz clock, 12-bit).
// The chip can only make the frequencies 25 MHz / (4096 x (prescaler + 1)).
// This returns the real frequency that analogWriteSetFrequency() will set.
float realFrequency(uint32_t hz) {
  uint32_t prescaler = (((25000000UL << 4) / (4096UL * hz)) >> 4) - 1;
  return 25000000.0 / (4096.0 * (prescaler + 1));
}

// Turns the motor 'revs' turns (negative = other direction) at 'rpm'.
// Returns the number of pulses sent, calculated from the real frequency.
float moveRevs(float revs, float rpm) {
  uint32_t hz = (uint32_t)(rpm * STEPS_REV / 60.0 + 0.5);
  if (hz < 24 || hz > 1525) {
    Serial.println("Speed out of range for this microstep setting");
    return 0;
  }
  float realHz = realFrequency(hz);
  unsigned long runMs = (unsigned long)(fabs(revs) * STEPS_REV * 1000.0 / realHz + 0.5);

  analogWriteSetFrequency(STEP_PIN, hz);
  digitalWrite(DIR_PIN, revs >= 0 ? HIGH : LOW);
  digitalWrite(ENA_PIN, LOW);                   // LOW = driver enabled
  analogWrite(STEP_PIN, 2048);                  // 50 % duty: pulses start
  delay(runMs);
  analogWrite(STEP_PIN, 0);                     // pulses stop
  digitalWrite(ENA_PIN, HIGH);                  // HIGH = driver disabled

  Serial.print("Requested Hz: "); Serial.print(hz);
  Serial.print("  real Hz: ");    Serial.print(realHz);
  Serial.print("  time ms: ");    Serial.println(runMs);
  return runMs * realHz / 1000.0;
}

void setup() {
  Serial.begin(115200);
  pinMode(DIR_PIN, OUTPUT);
  pinMode(ENA_PIN, OUTPUT);
  digitalWrite(ENA_PIN, HIGH);
}

void loop() {
  moveRevs(1.5, 50);    // 1.5 turns forward at 50 rpm
  delay(500);
  moveRevs(-1.5, 50);   // and back
  delay(500);
}

With 2 microsteps, 50 rpm asks for 333 Hz. The PCA9685 gives 339.08 Hz, so 1.5 turns take 1769 ms instead of 1800 ms.

Arduino sketch: acceleration ramp

A load with inertia can lose steps if the motor starts or stops at full speed. This sketch follows a trapezoidal profile: every 30 ms it calculates the speed allowed by the acceleration from the start and by the braking to the end, and it updates the frequency. It counts the pulses sent from the real frequency of each period, so it stops when the target is reached.

// Stepper motor with acceleration ramp, ESP32 PLC + TB6600 driver
// Same wiring as the constant speed example

#define STEP_PIN Q0_0
#define DIR_PIN  Q0_1
#define ENA_PIN  Q0_2

#define MOTOR_STEPS 200
#define MICROSTEPS  2
#define STEPS_REV   (MOTOR_STEPS * MICROSTEPS)

#define MIN_HZ      24        // lowest PCA9685 frequency
#define MAX_HZ      1525      // highest PCA9685 frequency
#define UPDATE_MS   30        // speed update period

float realFrequency(uint32_t hz) {
  uint32_t prescaler = (((25000000UL << 4) / (4096UL * hz)) >> 4) - 1;
  return 25000000.0 / (4096.0 * (prescaler + 1));
}

// Moves 'revs' turns with a trapezoidal profile.
// maxRpm: cruise speed. accel: acceleration in pulses per second squared.
void moveRamp(float revs, float maxRpm, float accel) {
  float target = fabs(revs) * STEPS_REV;          // pulses to send
  float maxHz = maxRpm * STEPS_REV / 60.0;
  if (maxHz > MAX_HZ) maxHz = MAX_HZ;
  if (maxHz < MIN_HZ) maxHz = MIN_HZ;

  float done = 0;                                  // pulses sent so far
  uint32_t lastHz = 0;
  float realHz = 0;

  digitalWrite(DIR_PIN, revs >= 0 ? HIGH : LOW);
  digitalWrite(ENA_PIN, LOW);

  while (done < target) {
    float left = target - done;
    // speed allowed by the acceleration (from start) and the braking (to end)
    float vUp = sqrt(2.0 * accel * done);
    float vDown = sqrt(2.0 * accel * left);
    float v = min(maxHz, min(vUp, vDown));
    if (v < MIN_HZ) v = MIN_HZ;

    uint32_t hz = (uint32_t)(v + 0.5);
    if (hz != lastHz) {
      analogWriteSetFrequency(STEP_PIN, hz);
      if (lastHz == 0) analogWrite(STEP_PIN, 2048);
      lastHz = hz;
      realHz = realFrequency(hz);
    }

    // last period: wait only the time the remaining pulses need
    unsigned long waitMs = UPDATE_MS;
    float pulses = realHz * UPDATE_MS / 1000.0;
    if (pulses > left) {
      waitMs = (unsigned long)(left * 1000.0 / realHz + 0.5);
      pulses = left;
    }
    delay(waitMs);
    done += pulses;
  }

  analogWrite(STEP_PIN, 0);
  digitalWrite(ENA_PIN, HIGH);
}

void setup() {
  Serial.begin(115200);
  pinMode(DIR_PIN, OUTPUT);
  pinMode(ENA_PIN, OUTPUT);
  digitalWrite(ENA_PIN, HIGH);
}

void loop() {
  moveRamp(2, 120, 2000);    // 2 turns, up to 120 rpm, 2000 pulses/s2
  delay(500);
  moveRamp(-2, 120, 2000);
  delay(500);
}

Each frequency change is an I2C write to the PCA9685, so the speed changes in steps every 30 ms and not pulse by pulse. With a high acceleration the steps are large and the movement is less smooth. Lower the acceleration or update more often if the motor vibrates or loses steps.

What we checked and what you should check

Both sketches compile to an object file without warnings against the Industrial Shields ESP32 package 2.7.1, for the ESP32 PLC 21 and the ESP32 PLC 19R (compiler test only, not a full build with the Arduino IDE). We also ran their timing logic on a PC with simulated outputs: 1.5 turns at 50 rpm give 599.8 of 600 pulses, and a ramp of 2 turns gives 800.0 of 800 pulses. These new versions have not been tested on a motor yet, so check them on your machine at low speed first.

  • The position is open loop. A pulse can be lost when the PWM starts or stops, and the error adds up over many moves. Use a home sensor on an input of the PLC to reset the position at the start of each cycle.
  • If the motor vibrates without turning, lower the speed or the acceleration, or raise the current of the driver up to the rated current of the motor.
  • If you need more speed, use fewer microsteps: the frequency limit stays the same and each pulse moves the motor further.

FAQ about stepper motors and the ESP32 PLC

Can an ESP32 PLC drive a stepper motor without a driver?

No. The outputs of the PLC give logic signals. A driver such as the TB6600 switches the current of the motor coils and makes the microsteps.

Which outputs of the ESP32 PLC can send the step pulses?

Any PWM output. On the ESP32 PLC 21, 42 and 58, Q0.0 to Q0.7 are PCA9685 outputs from 24 Hz to 1525 Hz. Use the other outputs of the same chip for DIR, ENA or on/off loads, because the frequency is shared.

How do I calculate the speed of the motor?

rpm = frequency × 60 / pulses per turn. With 2 microsteps a 200-step motor needs 400 pulses per turn, so 1000 Hz is 150 rpm. Remember that the PCA9685 rounds the frequency up to the next value that it can make.

Does the ESP32 PLC know the position of the motor?

Not with these sketches. They calculate the position from time and frequency. For a known position, add a home sensor, or use an encoder input if the machine needs a closed loop.

Can I use the Adafruit PWM Servo Driver library?

It controls the same PCA9685, but the Industrial Shields package already initializes the chip and has analogWriteSetFrequency(). Using one interface for the chip avoids two pieces of code writing different settings to it.

Take it further

Stepper motors in real machines: a bottle labelling conveyor, a laser research rig and the driver for your own prototype.

​Search in our Blog

Controlling a stepper motor with an ESP32 PLC
Boot & Work Corp. S.L, Ricard Franch Argullol June 15, 2026
Share this post
Tags

Looking for your ideal Programmable Logic Controller?

Take a look at this product comparison with other industrial controllers Arduino-based. 

We are comparing inputs, outputs, communications and other features with the ones of the relevant brands.

PLC Comparison