← All projects
Automation Beginner

28BYJ-48 Stepper Motor: Precise Rotation Without Libraries

The cheapest stepper motor with a ULN2003 driver. We write our own non-blocking half-step driver and control angle and speed with Serial commands. A base for blinds, feeders and turntables.

Difficulty
Beginner
Time
1 h
Category
Automation

Why a stepper beats a servo

A servo only turns 180°, while a stepper turns to any angle in either direction, as many revolutions as you like, accurate to one step. The 28BYJ-48 with its built-in 1:64 gearbox gives about 4096 half-steps per revolution (≈0.09° per step) and decent torque — enough to drive a roller blind or a dispenser.

Parts

  • ESP32 DevKit
  • 28BYJ-48 (5 V) motor + ULN2003 driver board (usually sold as a kit)
  • 5 V ≥ 500 mA supply

Wiring

ULN2003 ESP32
IN1 GPIO 19
IN2 GPIO 18
IN3 GPIO 5
IN4 GPIO 17
+ / − 5V / GND

The motor plugs into the white connector on the driver board. Driver and ESP32 grounds are shared.

How a stepper turns

There are four coils inside. Energising them in sequence "pulls" the rotor around. Half-step mode alternates between one and two active coils — 8 phases per cycle — for smoother motion and twice as many steps.

Code

const int PINS[4] = {19, 18, 5, 17};   // IN1..IN4

// Half-step sequence: bit 3 → IN1 ... bit 0 → IN4
const uint8_t SEQ[8] = {
  0b1000, 0b1100, 0b0100, 0b0110,
  0b0010, 0b0011, 0b0001, 0b1001
};
const long STEPS_PER_REV = 4096;

long position = 0;             // current position in steps
long target = 0;               // where we're heading
int phase = 0;
unsigned long stepIntervalUs = 1500;
unsigned long lastStepUs = 0;

void applyPhase(uint8_t mask) {
  for (int i = 0; i < 4; i++) {
    digitalWrite(PINS[i], (mask >> (3 - i)) & 1);
  }
}

void setRpm(float rpm) {
  rpm = constrain(rpm, 1.0f, 15.0f);   // the 28BYJ-48 skips steps above ~15 rpm
  stepIntervalUs = 60000000.0 / (rpm * STEPS_PER_REV);
  Serial.printf("Speed: %.1f rpm (%lu us/step)\n", rpm, stepIntervalUs);
}

void handleCommand(String cmd) {
  cmd.trim();
  if (cmd.startsWith("rpm ")) {
    setRpm(cmd.substring(4).toFloat());
  } else if (cmd == "home") {
    target = 0;
  } else if (cmd.length()) {
    float degrees = cmd.toFloat();       // "90", "-180", "720"
    target += lround(degrees * STEPS_PER_REV / 360.0);
    Serial.printf("Moving to %ld\n", target);
  }
}

void setup() {
  Serial.begin(115200);
  Serial.setTimeout(20);
  for (int p : PINS) pinMode(p, OUTPUT);
  setRpm(10);
  Serial.println("Commands: <degrees> | rpm <1-15> | home");
}

void loop() {
  if (Serial.available()) handleCommand(Serial.readStringUntil('\n'));

  if (position != target && micros() - lastStepUs >= stepIntervalUs) {
    lastStepUs = micros();
    int dir = target > position ? 1 : -1;
    phase = (phase + dir + 8) % 8;
    applyPhase(SEQ[phase]);
    position += dir;

    if (position == target) {
      applyPhase(0);    // cut the current so the motor doesn't heat up
      Serial.printf("Arrived at %ld (%.1f°)\n", position, position * 360.0 / STEPS_PER_REV);
    }
  }
}

Testing

Open the Serial Monitor (115200, "Newline") and send:

Command Action
90 turn 90° clockwise
-360 one full revolution backwards
rpm 5 change speed
home return to the zero position

The four LEDs on the ULN2003 board show which coils are energised — handy for debugging.

Things to know

  • Exact step count. The real gearbox isn't exactly 1:64 but ≈1:63.68, so STEPS_PER_REV = 4076 is more accurate. Irrelevant for blinds, significant for a clock.
  • Holding. We cut the current after stopping so the motor stays cool. If the load can back-drive the shaft (a vertical blind), keep the coils energised.
  • Zero after a reboot is unknown. For absolute positioning add a limit switch and a homing routine at start-up.
  • Need more torque or speed? Use a NEMA17 with an A4988/TMC2209 driver.

Ideas

  • Automatic blinds: open at sunrise, close at sunset (NTP + time calculation).
  • A turntable for 360° product photos: step, pause, shoot.
  • A pet feeder with an auger dispenser.