A rotary encoder is the knob on menus, volume controls and 3D-printer screens: it clicks round endlessly and reports each step and its direction on two pins, CLK and DT. The KY-040 module adds a push switch.
On the ESP32 DevKit V1, both outputs get an interrupt, and a small lookup table decides whether each change is a real step or contact bounce — no library needed. Power the module from 3V3 so its pull-ups keep the signals at 3.3 V. The sketch prints the position and direction, and pressing the knob resets it to zero.
Wiring
| ESP32 DevKit V1 | KY-040 rotary encoder | Wire | Notes |
|---|---|---|---|
| 3V3 | + | red | The module’s pull-ups go to +, so 3V3 keeps CLK and DT at 3.3 V. Not VIN. |
| GND | GND | black | |
| D32 | CLK | yellow | Encoder output A. |
| D33 | DT | green | Encoder output B. |
| D25 | SW | blue | Push switch to GND; the sketch turns on the internal pull-up. |
3.3–5 V part, 3.3 V board. Power the module from your board’s logic voltage (3.3 V on ESP32/Pico) — its pull-ups go to the + pin, so CLK and DT will never exceed that voltage.
Board quirks
- The sketch uses no library. Paul Stoffregen’s Encoder library is great on AVR, but its ESP32 support depends on the core version; an interrupt plus a 16-entry lookup table is short, portable and ignores contact bounce.
- Interrupt handlers on the ESP32 must be marked
IRAM_ATTRso they run from RAM; without it the board can crash with “Cache disabled but cached memory region accessed” during flash writes. - Don’t use D34–D39 for CLK/DT/SW unless you are sure your module has pull-ups on all three — those pins have no internal pull-ups, and SW usually has none on the module.
- Most KY-040 encoders give 4 transitions per click (20 clicks per turn). If yours moves 2 per click, change
/ 4to/ 2. - Power from 3V3. On VIN the module’s 10 kΩ pull-ups would lift CLK and DT to 5 V.
Code
// KY-040 style rotary encoder on an ESP32 DevKit V1 — no library, interrupt-driven.
// + → 3V3, GND → GND, CLK → D32 (GPIO32), DT → D33 (GPIO33), SW → D25 (GPIO25).
// A quadrature lookup table rejects contact bounce: invalid transitions count as zero.
constexpr uint8_t CLK_PIN = 32;
constexpr uint8_t DT_PIN = 33;
constexpr uint8_t SW_PIN = 25;
// Index = (previous AB << 2) | current AB. +1 / -1 for valid steps, 0 for bounce.
const int8_t QUAD[16] = {0, -1, 1, 0, 1, 0, 0, -1, -1, 0, 0, 1, 0, 1, -1, 0};
volatile uint8_t lastAB = 0;
volatile int32_t quarterSteps = 0;
void IRAM_ATTR onEncoder() {
uint8_t ab = (digitalRead(CLK_PIN) << 1) | digitalRead(DT_PIN);
quarterSteps += QUAD[(lastAB << 2) | ab];
lastAB = ab;
}
void setup() {
Serial.begin(115200);
pinMode(CLK_PIN, INPUT_PULLUP); // the module has 10 kΩ pull-ups to +; these just help
pinMode(DT_PIN, INPUT_PULLUP);
pinMode(SW_PIN, INPUT_PULLUP); // SW usually has no pull-up on the module
lastAB = (digitalRead(CLK_PIN) << 1) | digitalRead(DT_PIN);
attachInterrupt(digitalPinToInterrupt(CLK_PIN), onEncoder, CHANGE);
attachInterrupt(digitalPinToInterrupt(DT_PIN), onEncoder, CHANGE);
Serial.println("Rotary encoder: CLK 32, DT 33, SW 25 — turn or press the knob");
}
void loop() {
static int32_t lastPos = 0;
static bool lastSw = HIGH;
static uint32_t swChanged = 0;
noInterrupts();
int32_t pos = quarterSteps / 4; // KY-040: 4 transitions per detent
interrupts();
if (pos != lastPos) {
Serial.printf("Position %ld (%s)\n", (long)pos, pos > lastPos ? "clockwise" : "anticlockwise");
lastPos = pos;
}
bool sw = digitalRead(SW_PIN);
if (sw != lastSw && millis() - swChanged > 30) {
swChanged = millis();
lastSw = sw;
if (sw == LOW) {
Serial.println("Button pressed — position reset to 0");
noInterrupts();
quarterSteps = 0;
interrupts();
lastPos = 0;
}
}
}// KY-040 style rotary encoder on an ESP32 DevKit V1 — no library, interrupt-driven.
// + → 3V3, GND → GND, CLK → D32 (GPIO32), DT → D33 (GPIO33), SW → D25 (GPIO25).
// A quadrature lookup table rejects contact bounce: invalid transitions count as zero.
constexpr uint8_t CLK_PIN = 32;
constexpr uint8_t DT_PIN = 33;
constexpr uint8_t SW_PIN = 25;
// Index = (previous AB << 2) | current AB. +1 / -1 for valid steps, 0 for bounce.
const int8_t QUAD[16] = {0, -1, 1, 0, 1, 0, 0, -1, -1, 0, 0, 1, 0, 1, -1, 0};
volatile uint8_t lastAB = 0;
volatile int32_t quarterSteps = 0;
void IRAM_ATTR onEncoder() {
uint8_t ab = (digitalRead(CLK_PIN) << 1) | digitalRead(DT_PIN);
quarterSteps += QUAD[(lastAB << 2) | ab];
lastAB = ab;
}
void setup() {
Serial.begin(115200);
pinMode(CLK_PIN, INPUT_PULLUP); // the module has 10 kΩ pull-ups to +; these just help
pinMode(DT_PIN, INPUT_PULLUP);
pinMode(SW_PIN, INPUT_PULLUP); // SW usually has no pull-up on the module
lastAB = (digitalRead(CLK_PIN) << 1) | digitalRead(DT_PIN);
attachInterrupt(digitalPinToInterrupt(CLK_PIN), onEncoder, CHANGE);
attachInterrupt(digitalPinToInterrupt(DT_PIN), onEncoder, CHANGE);
Serial.println("Rotary encoder: CLK 32, DT 33, SW 25 — turn or press the knob");
}
void loop() {
static int32_t lastPos = 0;
static bool lastSw = HIGH;
static uint32_t swChanged = 0;
noInterrupts();
int32_t pos = quarterSteps / 4; // KY-040: 4 transitions per detent
interrupts();
if (pos != lastPos) {
Serial.printf("Position %ld (%s)\n", (long)pos, pos > lastPos ? "clockwise" : "anticlockwise");
lastPos = pos;
}
bool sw = digitalRead(SW_PIN);
if (sw != lastSw && millis() - swChanged > 30) {
swChanged = millis();
lastSw = sw;
if (sw == LOW) {
Serial.println("Button pressed — position reset to 0");
noInterrupts();
quarterSteps = 0;
interrupts();
lastPos = 0;
}
}
}Library: Encoder (Paul Stoffregen).
Expected serial output
Rotary encoder: CLK 32, DT 33, SW 25 — turn or press the knob
Position 1 (clockwise)
Position 2 (clockwise)
Position 3 (clockwise)
Position 2 (anticlockwise)
Button pressed — position reset to 0Rotary encoder: CLK 32, DT 33, SW 25 — turn or press the knob Position 1 (clockwise) Position 2 (clockwise) Position 3 (clockwise) Position 2 (anticlockwise) Button pressed — position reset to 0
Common errors
Counts go the wrong way
Swap the CLK and DT wires, or swap the pin numbers in the sketch.
Position jumps by 2 or skips
Your encoder has a different number of transitions per detent. Try / 2, and make sure both CLK and DT have interrupts attached.
Board resets with a Guru Meditation error when turning the knob
An ISR without IRAM_ATTR, or Serial.print inside the ISR. Keep the handler as short as the one in the sketch and print from loop().