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.
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 = 4076is 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.