A rotary encoder is the usual knob for menus and set-points — a thermostat, a timer, a pump run time. The KY-040 module gives two out-of-phase signals (CLK and DT) plus a push button.
On the NodeMCU, read it with interrupts on D5 and D6, mark the handler IRAM_ATTR as the ESP8266 requires, and put the button on D7 with the internal pull-up. D0 is out — it has no interrupt support.
Wiring
| NodeMCU ESP8266 | KY-040 rotary encoder | Wire | Notes |
|---|---|---|---|
| 3V3 | + | red | The module’s CLK/DT pull-ups go to +, so 3V3 keeps them at the ESP8266’s level. |
| GND | GND | black | |
| D5 | CLK | yellow | D5 = GPIO14, interrupt on change. |
| D6 | DT | green | D6 = GPIO12, interrupt on change. |
| D7 | SW | blue | D7 = GPIO13, internal pull-up; press = LOW. |
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
- Every interrupt handler on the ESP8266 must be marked
IRAM_ATTR. Without it the code runs from flash, and the board crashes with an “ISR not in IRAM” error as soon as it boots. - D0 (GPIO16) can’t take interrupts, so it is no use for CLK or DT. D3, D4 and D8 can, but a turned knob can leave a contact closed at reset and pull a boot pin the wrong way. D5/D6/D7 are the safe trio.
- Interrupts fire on both CLK and DT, and a 16-entry table accepts only valid quadrature steps. Contact bounce produces invalid transitions that count as zero — so no capacitors or delays are needed.
- A KY-040 gives 4 transitions per click, hence
position / 4. Some cheap encoders give 2 — change the divisor if one click moves the count by 2 or by half. - The sketch copies
positionwith interrupts off. A 32-bit read is atomic on the ESP8266, but the habit matters once you read more than one shared variable.
Code
// KY-040 rotary encoder on a NodeMCU (ESP8266), read with interrupts.
// + → 3V3, GND → GND, CLK → D5 (GPIO14), DT → D6 (GPIO12), SW → D7 (GPIO13).
const uint8_t ENC_CLK = D5;
const uint8_t ENC_DT = D6;
const uint8_t ENC_SW = D7;
volatile int32_t position = 0; // in quarter-steps
volatile uint8_t lastState = 0;
// ISRs must sit in IRAM on the ESP8266, or the board crashes when flash is busy.
void IRAM_ATTR onEncoder() {
// Quadrature table: +1 / -1 for valid transitions, 0 for contact bounce.
static const int8_t table[16] = {0, -1, 1, 0, 1, 0, 0, -1, -1, 0, 0, 1, 0, 1, -1, 0};
uint8_t s = (digitalRead(ENC_CLK) << 1) | digitalRead(ENC_DT);
position += table[(lastState << 2) | s];
lastState = s;
}
void setup() {
Serial.begin(115200);
Serial.println();
pinMode(ENC_CLK, INPUT); // the module has 10 kΩ pull-ups on CLK and DT
pinMode(ENC_DT, INPUT);
pinMode(ENC_SW, INPUT_PULLUP); // many modules leave the SW pull-up unfitted
lastState = (digitalRead(ENC_CLK) << 1) | digitalRead(ENC_DT);
attachInterrupt(digitalPinToInterrupt(ENC_CLK), onEncoder, CHANGE);
attachInterrupt(digitalPinToInterrupt(ENC_DT), onEncoder, CHANGE);
Serial.println("Turn the knob; press it to reset to 0");
}
void loop() {
static int32_t shown = 0;
noInterrupts();
int32_t steps = position / 4; // one detent = 4 quarter-steps on a KY-040
interrupts();
if (steps != shown) {
shown = steps;
Serial.printf("Position %d\n", shown);
}
if (digitalRead(ENC_SW) == LOW) {
noInterrupts();
position = 0;
interrupts();
shown = 0;
Serial.println("Reset to 0");
while (digitalRead(ENC_SW) == LOW) delay(10); // wait for release; delay() feeds the watchdog
}
}// KY-040 rotary encoder on a NodeMCU (ESP8266), read with interrupts.
// + → 3V3, GND → GND, CLK → D5 (GPIO14), DT → D6 (GPIO12), SW → D7 (GPIO13).
const uint8_t ENC_CLK = D5;
const uint8_t ENC_DT = D6;
const uint8_t ENC_SW = D7;
volatile int32_t position = 0; // in quarter-steps
volatile uint8_t lastState = 0;
// ISRs must sit in IRAM on the ESP8266, or the board crashes when flash is busy.
void IRAM_ATTR onEncoder() {
// Quadrature table: +1 / -1 for valid transitions, 0 for contact bounce.
static const int8_t table[16] = {0, -1, 1, 0, 1, 0, 0, -1, -1, 0, 0, 1, 0, 1, -1, 0};
uint8_t s = (digitalRead(ENC_CLK) << 1) | digitalRead(ENC_DT);
position += table[(lastState << 2) | s];
lastState = s;
}
void setup() {
Serial.begin(115200);
Serial.println();
pinMode(ENC_CLK, INPUT); // the module has 10 kΩ pull-ups on CLK and DT
pinMode(ENC_DT, INPUT);
pinMode(ENC_SW, INPUT_PULLUP); // many modules leave the SW pull-up unfitted
lastState = (digitalRead(ENC_CLK) << 1) | digitalRead(ENC_DT);
attachInterrupt(digitalPinToInterrupt(ENC_CLK), onEncoder, CHANGE);
attachInterrupt(digitalPinToInterrupt(ENC_DT), onEncoder, CHANGE);
Serial.println("Turn the knob; press it to reset to 0");
}
void loop() {
static int32_t shown = 0;
noInterrupts();
int32_t steps = position / 4; // one detent = 4 quarter-steps on a KY-040
interrupts();
if (steps != shown) {
shown = steps;
Serial.printf("Position %d\n", shown);
}
if (digitalRead(ENC_SW) == LOW) {
noInterrupts();
position = 0;
interrupts();
shown = 0;
Serial.println("Reset to 0");
while (digitalRead(ENC_SW) == LOW) delay(10); // wait for release; delay() feeds the watchdog
}
}Library: Encoder (Paul Stoffregen).
Expected serial output
Turn the knob; press it to reset to 0
Position 1
Position 2
Position 3
Position 2
Reset to 0Turn the knob; press it to reset to 0 Position 1 Position 2 Position 3 Position 2 Reset to 0
Common errors
Crash at boot: “ISR not in IRAM!”
The handler is missing IRAM_ATTR. See the full fix.
Count goes the wrong way
Swap the CLK and DT wires (or the pin constants).
Button reads pressed all the time
SW is on D3/D4/D8 or not connected. Use D7 with INPUT_PULLUP as in the sketch.