
ESP32 + Stepper 28BYJ-48 + ULN2003: Precise Stepper Motor Control
Control a 28BYJ-48 stepper motor through a ULN2003 driver using an 8-step half-step sequence, rotating precisely by angle or step count — a foundation for turntables and automatic valve control.
A stepper motor differs from a servo or DC motor in that it moves in precise, countable discrete steps — allowing accurate angular positioning without a position-feedback sensor (open-loop control), as long as it doesn't lose steps (falling out of sync from overload or spinning too fast).
The 28BYJ-48 + ULN2003 kit is the most popular combo for getting started: the ULN2003 driver (a Darlington array) boosts the 3.3V GPIO signal to enough current to drive the motor's 4 coils.
This project uses an 8-step half-step sequence for smoother rotation and better holding torque than full-step, supporting commands to rotate by full turns or by a precise angle over Serial.
Detailed guide
Control a 28BYJ-48 stepper motor through a ULN2003 driver using an 8-step half-step sequence, rotating precisely by angle or step count.
1. Introduction
A stepper motor differs from servos and DC motors in that it moves in discrete, countable steps — enabling precise angular positioning without a feedback sensor (open-loop control), as long as it doesn't lose steps (falling out of sync from overload or spinning too fast).
The 28BYJ-48 + ULN2003 kit is the most popular combo for getting started: the 28BYJ-48 is a 4-phase stepper motor with a 1:64 reduction gearbox (64 motor steps × 64 = 4096 actual steps per output-shaft revolution), while the ULN2003 is a Darlington driver board that boosts current, since the ESP32's GPIO (3.3V, ~20mA) can't directly drive the motor coils.
This project uses an 8-step half-step sequence (smoother and with better torque than 4-step full-step) to rotate by full turns or by a precise angle via Serial commands.
2. Components Needed
Component | Qty | Notes |
|---|---|---|
ESP32 DevKit V4 | 1 | The main control board |
ULN2003 + 28BYJ-48 — Stepper Motor | 1 | A driver + 5V stepper motor kit, 4096 actual steps per revolution |
3. Wiring Diagram
ULN2003 pin | ESP32 pin | Notes |
|---|---|---|
VCC | 5V (VIN) | The 28BYJ-48 needs ~200-300mA — don't use 3V3 |
GND | GND | A shared GND is mandatory |
IN1 | GPIO16 | Phase A |
IN2 | GPIO17 | Phase B |
IN3 | GPIO18 | Phase C |
IN4 | GPIO19 | Phase D |
All four IN1–IN4 pins use GPIO16–19, which aren't part of the boot-strapping pin group, so they're safe to use as outputs right from boot.
4. Sample Code
/*
* ESP32 + Stepper 28BYJ-48 + ULN2003 - Dieu khien dong co buoc chinh xac
* Board: ESP32 DevKit V4 (board_esp32_devkitc)
* Module: ULN2003 + 28BYJ-48 - Stepper Motor (mod_uln2003_stepper)
*
* Wiring:
* ULN2003 VCC -> ESP32 5V (VIN) [28BYJ-48 can dong ~200-300mA, KHONG dung 3V3]
* ULN2003 GND -> ESP32 GND
* ULN2003 IN1 -> ESP32 GPIO16
* ULN2003 IN2 -> ESP32 GPIO17
* ULN2003 IN3 -> ESP32 GPIO18
* ULN2003 IN4 -> ESP32 GPIO19
*
* 28BYJ-48: 4096 buoc/vong thuc te (64 buoc/vong dong co x 64 ti so banh rang).
* Dung 8-step half-step sequence (thu vien tu viet, khong can cai Stepper.h)
* de chay muot va giu momen xoan tot hon full-step.
*
* An toan phan cung:
* - ULN2003 la Darlington driver, tich hop san diode flyback bao ve cho 4 cuon
* day cua 28BYJ-48 - khong can diode ngoai.
* - Dong dinh muc moi cuon ~ 60-80mA, ULN2003 chiu toi 500mA/kenh -> an toan du du.
* - Cap nguon 5V rieng cho VCC neu dung nhieu servo/dong co cung luc de tranh sut ap
* lam ESP32 reset (brown-out).
*/
static const int IN1_PIN = 16;
static const int IN2_PIN = 17;
static const int IN3_PIN = 18;
static const int IN4_PIN = 19;
// Half-step 8-buoc sequence chuan cho ULN2003 + 28BYJ-48
static const uint8_t HALF_STEP_SEQ[8][4] = {
{1, 0, 0, 0},
{1, 1, 0, 0},
{0, 1, 0, 0},
{0, 1, 1, 0},
{0, 0, 1, 0},
{0, 0, 1, 1},
{0, 0, 0, 1},
{1, 0, 0, 1},
};
static const int STEPS_PER_REV = 4096; // half-step, so buoc thuc te 1 vong truc ra
int stepIndex = 0;
long targetSteps = 0; // vi tri muc tieu (so buoc tuyet doi)
long currentSteps = 0; // vi tri hien tai
unsigned long lastStepMicros = 0;
unsigned long stepIntervalMicros = 1500; // toc do quay (nho hon = nhanh hon)
void applyStep(int idx) {
digitalWrite(IN1_PIN, HALF_STEP_SEQ[idx][0]);
digitalWrite(IN2_PIN, HALF_STEP_SEQ[idx][1]);
digitalWrite(IN3_PIN, HALF_STEP_SEQ[idx][2]);
digitalWrite(IN4_PIN, HALF_STEP_SEQ[idx][3]);
}
void releaseCoils() {
digitalWrite(IN1_PIN, LOW);
digitalWrite(IN2_PIN, LOW);
digitalWrite(IN3_PIN, LOW);
digitalWrite(IN4_PIN, LOW);
}
void setup() {
Serial.begin(115200);
delay(300);
Serial.println();
Serial.println("=== ESP32 + Stepper 28BYJ-48/ULN2003 boot OK ===");
pinMode(IN1_PIN, OUTPUT);
pinMode(IN2_PIN, OUTPUT);
pinMode(IN3_PIN, OUTPUT);
pinMode(IN4_PIN, OUTPUT);
releaseCoils();
Serial.println("Lenh Serial: 'R' quay 1 vong thuan, 'L' quay 1 vong nguoc, 'A<deg>' quay den goc.");
Serial.println("{\"status\":\"ready\",\"steps_per_rev\":4096}");
}
void handleSerialCommand() {
if (!Serial.available()) return;
char cmd = Serial.read();
if (cmd == 'R' || cmd == 'r') {
targetSteps = currentSteps + STEPS_PER_REV;
Serial.println("{\"cmd\":\"cw_1rev\"}");
} else if (cmd == 'L' || cmd == 'l') {
targetSteps = currentSteps - STEPS_PER_REV;
Serial.println("{\"cmd\":\"ccw_1rev\"}");
} else if (cmd == 'A' || cmd == 'a') {
int deg = Serial.parseInt();
targetSteps = (long)((float)deg / 360.0f * STEPS_PER_REV);
Serial.printf("{\"cmd\":\"goto_deg\",\"deg\":%d}\n", deg);
}
while (Serial.available() && Serial.peek() != '\n') Serial.read();
if (Serial.available()) Serial.read();
}
void demoPattern() {
// Demo tu dong khi chua co lenh: quay 90 do moi 3 giay, lap 4 lan roi dao chieu
static unsigned long lastDemoMs = 0;
static int demoCount = 0;
static bool demoForward = true;
const unsigned long DEMO_INTERVAL_MS = 3000;
if (millis() - lastDemoMs < DEMO_INTERVAL_MS) return;
lastDemoMs = millis();
int stepsPerQuarter = STEPS_PER_REV / 4;
targetSteps += demoForward ? stepsPerQuarter : -stepsPerQuarter;
demoCount++;
if (demoCount >= 4) {
demoCount = 0;
demoForward = !demoForward;
}
}
void loop() {
handleSerialCommand();
demoPattern();
if (currentSteps != targetSteps && micros() - lastStepMicros >= stepIntervalMicros) {
lastStepMicros = micros();
if (currentSteps < targetSteps) {
stepIndex = (stepIndex + 1) % 8;
currentSteps++;
} else {
stepIndex = (stepIndex + 7) % 8;
currentSteps--;
}
applyStep(stepIndex);
} else if (currentSteps == targetSteps) {
releaseCoils(); // nha cuon day khi dung yen -> tiet kiem dien, giam nong
}
static unsigned long lastTelemetryMs = 0;
if (millis() - lastTelemetryMs >= 1000) {
lastTelemetryMs = millis();
float degNow = (float)currentSteps / STEPS_PER_REV * 360.0f;
Serial.printf("{\"steps\":%ld,\"deg\":%.1f,\"target_steps\":%ld}\n", currentSteps, degNow, targetSteps);
}
}
5. Code Walkthrough
HALF_STEP_SEQ is an 8-state table that energizes the 4 coils in the correct order for the 28BYJ-48 — each step moves the shaft by a tiny angle (360°/4096 ≈ 0.088°). applyStep() writes IN1–IN4 according to that table's rows.
The main loop compares currentSteps against targetSteps: if they differ, it advances one step in the right direction every stepIntervalMicros (1500µs); once it reaches the target, it calls releaseCoils() to de-energize all the coils — saving power and reducing motor heat when holding torque isn't needed.
The Serial commands R/L rotate exactly one revolution (4096 steps), while A<deg> converts an angle into an absolute step count. The demo automatically rotates 90° every 3 seconds, repeating 4 times before reversing direction, to illustrate cyclic motion.
6. Hardware Safety Notes
The ULN2003 is a Darlington driver with built-in flyback protection diodes for all 4 coils — no separate diodes needed like some other discrete drivers.
Each 28BYJ-48 coil draws around 60-80mA, while each ULN2003 channel handles up to 500mA — plenty of headroom for standard half-step operation, no extra heatsinking needed.
The 28BYJ-48 runs at 5V — it must be powered through the ESP32's 5V/VIN pin, not 3V3, which doesn't provide enough voltage/current for the driver to run reliably.
If you're running several motors/servos at once on the same weak 5V source (say, a computer's USB port), use a higher-capacity external 5V supply to avoid brown-out resets on the ESP32.
Always call releaseCoils() when the motor sits idle for a while — keeping continuous current through the coils when it's not needed just heats up the motor and driver unnecessarily.
7. Common Issues
Issue | Cause | Fix |
|---|---|---|
The motor vibrates but doesn't turn, or jerks in the wrong direction | IN1–IN4 wired in the wrong order, or the phase order is reversed relative to the HALF_STEP_SEQ table | Double-check the wire color order (typically blue-pink-yellow-orange on a standard 28BYJ-48 cable) matches IN1–IN4 |
The motor loses steps and doesn't rotate the full requested angle | stepIntervalMicros is too small (spinning too fast) for the motor to keep up, or the mechanical load is too heavy | Increase stepIntervalMicros (slow it down), or reduce the load on the output shaft |
The motor gets unusually hot while idle | releaseCoils() isn't being called, so the coils keep drawing current even while not moving | Make sure the currentSteps == targetSteps logic calls releaseCoils(), exactly as in the sample code |
8. Summary
This guide walked through controlling a 28BYJ-48 stepper motor via a ULN2003 driver using an 8-step half-step sequence, supporting full-turn or precise-angle rotation, and optimizing power by releasing the coils while idle.
This is the foundation for applications needing precise angular positioning without a servo, in the Motor Control category — such as camera turntables, valve open/close mechanisms, or a product display turntable.