Rotary Encoders Explained: Reading Quadrature Position with an ESP32

Buttons tell you that something happened; rotary encoders tell you how far and which way. Learn quadrature A/B signals, interrupt-based decoding on an ESP32, converting counts to angle and RPM, fixing contact bounce, and the feedback-control builds this unlocks.

Written by Projectech7 min readPublished
For B.E./B.Tech Electronics and E&TC students adding position or speed feedback to motors, robot wheels, and user dials in IoT and embedded projects Topics: ESP32, Rotary Encoder, Quadrature Decoding, Arduino
Technical illustration of a rotary encoder with quadrature A and B signal waveforms and an ESP32 reading shaft position
Illustration generated for this guide.
In this guide

Rotary Encoders Explained: Reading Quadrature Position with an ESP32

Summary: Buttons tell you that something happened; rotary encoders tell you how far and which way. This guide explains quadrature signals, how to decode them on an ESP32 with interrupts, and how to turn raw counts into position, speed and RPM — plus the contact-bounce traps that corrupt readings.

The problem: your motor is blind

Spin a DC motor with PWM and it turns — but your code has no idea how far. For a line follower that's fine; for a robot arm joint, a conveyor belt, a CNC axis or a volume knob, you need feedback. Rotary encoders are the cheapest way to get it: a sensor that converts shaft rotation into digital pulses your microcontroller can count.

There are two families. This guide covers incremental encoders — the ones students actually buy (KY-040 modules, motor shaft encoders with slotted discs). They output quadrature pulses: your code counts pulses and infers direction. Absolute encoders output a unique digital code per position and remember it through power loss — better, but far more expensive, so they stay out of student budgets.

How quadrature works: two signals, 90 degrees apart

An incremental encoder has two outputs, A and B, which pulse in quadrature — identical square waves shifted by 90 degrees. The phase relationship tells you the direction:

  • Clockwise: A leads B (A rises while B is still low).
  • Counter-clockwise: B leads A (B rises while A is still low).

If you only watched channel A, you'd count pulses but never know the direction — a shaft that rocks back and forth would count up forever. The A/B pair solves this: each edge of either channel is one count, and the state of the other channel at that edge decides whether you add or subtract.

One more number matters: PPR (pulses per revolution). A KY-040 knob gives 20 PPR. A motor encoder disc with 11 slots on a 1:100 gearbox gives 1,100 counts per output-shaft revolution — enough to position a small arm to about 0.3 degrees per count. Always multiply: counts per rev = slots × 4 if you decode all four edges per cycle (×4 decoding), or × 2 or × 1 for simpler schemes.

Wiring: three wires and one rule

KY-040 style modules expose GND, VCC (5 V or 3.3 V), SW (the push-button, ignore it for now), CLK (channel A) and DT (channel B). For an ESP32:

  • CLK → GPIO 25, DT → GPIO 26 (any GPIOs work; these are interrupt-capable).
  • Most modules have pull-ups on board. If yours doesn't, enable the ESP32's internal pull-ups in code — floating encoder pins read noise.
  • The one rule: encoder outputs are 5 V on some modules. The ESP32 is a 3.3 V device. Power the module from 3.3 V if it works (most do), or add a level shifter. Back-feeding 5 V into a GPIO is how pins die.

Motor shaft encoders are simpler: two open-collector outputs that need external pull-ups (4.7 kΩ to 3.3 V) and a 3.3 V supply — check the datasheet before assuming 5 V tolerance.

Decoding with interrupts: the standard pattern

Polling digitalRead in loop() misses edges the moment your code does anything else. The standard pattern is pin-change interrupts on both channels. Here is a minimal, correct ESP32 sketch (Arduino framework):

volatile long encoderCount = 0;
const int pinA = 25, pinB = 26;

void IRAM_ATTR onEncoder() {
  // Read both channels at the edge; the other channel decides direction
  int a = digitalRead(pinA);
  int b = digitalRead(pinB);
  if (a == b) encoderCount++;
  else         encoderCount--;
}

void setup() {
  pinMode(pinA, INPUT_PULLUP);
  pinMode(pinB, INPUT_PULLUP);
  attachInterrupt(digitalPinToInterrupt(pinA), onEncoder, CHANGE);
  attachInterrupt(digitalPinToInterrupt(pinB), onEncoder, CHANGE);
  Serial.begin(115200);
}

void loop() {
  noInterrupts();
  long count = encoderCount;   // copy atomically, then release
  interrupts();
  Serial.println(count);
  delay(200);
}

Why this works and where students go wrong:

  1. volatile — without it the compiler may cache encoderCount in a register and your loop never sees updates.
  2. Atomic read — on a 32-bit ESP32, reading a long is single-cycle, but disabling interrupts around the copy is still the correct habit (and mandatory on 8-bit AVRs).
  3. Keep the ISR tiny. No Serial.print, no delay, no heavy maths inside the interrupt. Read, decide, update, exit.
  4. CHANGE on both pins gives ×4 decoding — four counts per quadrature cycle. If your counts look 4× too high, that's not a bug; divide by 4 or switch to RISING on one pin.

From counts to position, speed and RPM

Counts are raw material. Convert them in loop():

  • Angle: angleDeg = (count / countsPerRev) * 360. With a 1,100-count/rev gearbox encoder, one count is 0.327°.
  • Speed (counts/sec): sample the count twice, one second apart; the difference is counts/sec.
  • RPM: (countsPerSecond / countsPerRev) * 60. A 600-count/sec reading on a 1,100-count/rev encoder is about 32.7 RPM.

Two practical notes. First, overflow: a long holds about ±2 billion counts — a continuously spinning motor at 10,000 counts/sec overflows in ~2.5 days. Fine for a demo; for a logger, use 64-bit or reset the counter periodically. Second, noise at speed: above a few thousand edges per second, ISRs start missing edges. If RPM reads low at high speed, you're not seeing bad hardware — you're seeing interrupt overload. Lower the PPR, gear the encoder down, or move to a hardware quadrature decoder peripheral.

The bounce trap: why cheap knobs miscount

Mechanical encoder contacts bounce — each edge is really a burst of edges over a few milliseconds, exactly like button bounce. Symptoms: the count jumps by 3–5 per click, or the direction flickers. Fixes, in order of preference:

  1. Buy the right part: motor shaft encoders with slotted discs and optical/hall sensing don't bounce — mechanical KY-040 knobs do.
  2. Hardware filter: a small RC filter (1 kΩ + 100 nF) on each channel, followed by a Schmitt trigger (74HC14), cleans edges before they reach the GPIO.
  3. Software guard: ignore edges arriving within ~1–2 ms of the previous one on the same channel. For a human-turned knob this is invisible; for a fast motor it caps your measurable speed — know the trade-off.

Note this is the same bounce physics as push buttons, where the fix is a millis-based debounce state machine rather than delay() — the principle transfers, but encoders can't tolerate delay() at all.

Closing the loop: what encoders unlock

Once you can read position, three classic student builds become possible:

  • Speed control: compare measured RPM against a target and adjust PWM — the encoder turns open-loop guessing into feedback control.
  • Distance driving: drive a robot a set distance by counting wheel-encoder pulses instead of timing blind.
  • Position servo: on a DC gearmotor with an encoder, hold an angle — a poor student's servo, and excellent viva material because you can explain every layer.

FAQ

KY-040 vs motor shaft encoder — which do I buy?
A knob for user input (menu dial, volume): KY-040. Feedback from a rotating shaft (robot wheels, conveyor): a slotted-disc or magnetic motor encoder with a known PPR.

My counts drift even when the shaft is still. Why?
Floating or noisy inputs. Check pull-ups first, then look for long unshielded wires running next to motor leads — route encoder cables away from power wiring.

Can I read an encoder and run Wi-Fi on the same ESP32?
Yes — interrupts keep counting while Wi-Fi runs, which is exactly why ISRs beat polling. Just keep the ISR short so it never starves the Wi-Fi task.

Do I need a library?
For learning, no — the sketch above is the whole decoder. In a big project, a tested library saves time, but write the raw version once so you understand what the library hides.

Limitations

  • Incremental encoders lose position on power loss — your code must re-home (drive to a limit switch or a known index mark) at startup.
  • They measure relative motion, not absolute angle; backlash in gearboxes shows up as position error the encoder cannot see.
  • Very high PPR at high RPM overwhelms GPIO interrupts — match the encoder to the speed, or use hardware decoding.
  • Optical discs hate dust and misalignment; if counts drop out mid-demo, check the disc and sensor gap before the code.

More project guides

More in IoT & Embedded