An arduino rotary encoder translates mechanical shaft rotation into digital pulses, giving you precise relative position tracking without the absolute limits of a potentiometer. Whether you are building a MIDI controller, a digital power supply interface, or a motorized camera slider, the underlying physics relies on quadrature decoding: reading two square waves (Phase A and Phase B) offset by 90 electrical degrees to determine both speed and direction.
This guide bypasses the generic "turn the knob" tutorials. We will compare the two most common hardware variants, build a hardware debouncing circuit, and write a raw, interrupt-driven state machine that catches missed steps and signal noise in real-time.
KY-040 vs EC11: Choosing the Right Module
Before wiring anything, you need to select the right physical encoder. The market is dominated by the cheap red KY-040 breakout boards and bare EC11 components. Here is how they actually perform on the bench.
| Specification | KY-040 (Red Breakout) | Generic EC11 (Bare) | Alps Alpine EC11 (Premium) |
|---|---|---|---|
| Detents per Rotation | 20 | 20 or 30 | 20, 24, or 30 |
| Pulses per Detent (PPD) | 2 (4 edges) | 2 (4 edges) | 1 or 2 (varies by suffix) |
| Max Operating RPM | ~60 RPM | ~100 RPM | 150+ RPM |
| Contact Rating | 5V / 10mA (max) | 5V / 10mA | 12V / 50mA |
| Integrated Pull-ups | Yes (usually 10kΩ) | No | No |
| Typical Price (2026) | $1.20 - $1.80 | $0.35 - $0.60 | $2.50 - $4.00 |
Hardware Wiring and Pin Mapping
Mechanical switch contacts bounce. When the internal wiper of the encoder hits a new detent, it physically rattles, creating micro-second voltage spikes. If you rely purely on software debouncing, you will miss counts at high speeds. We will use a hardware RC (resistor-capacitor) low-pass filter combined with the Arduino's internal Schmitt-trigger inputs.
Parts List
- Microcontroller: Arduino Uno R3 (ATmega328P) or Arduino Nano v3
- Encoder: KY-040 Module or bare EC11
- Resistors: 2x 10kΩ (only required if using bare EC11 without a breakout board)
- Capacitors: 2x 0.1µF (100nF) ceramic disc capacitors for hardware debouncing
- Wiring: 22 AWG solid core jumper wires
Pin Mapping Table
This mapping targets the Arduino Uno R3. Pins 2 and 3 are strictly required here because they are the only pins on the ATmega328P that support external hardware interrupts (INT0 and INT1).
| Encoder Pin | Arduino Uno R3 Pin | Function & Notes |
|---|---|---|
| CLK (Phase A) | D2 (INT0) | Primary quadrature signal. Connect 0.1µF cap from D2 to GND. |
| DT (Phase B) | D3 (INT1) | Secondary quadrature signal. Connect 0.1µF cap from D3 to GND. |
| SW (Push Button) | D4 | Active LOW. Enable internal pull-up in software. |
| + (VCC) | 5V | Do not exceed 5V on standard Uno/Nano boards. |
| GND | GND | Common ground reference. |
Interrupt-Driven Quadrature Code
Polling encoder pins inside the loop() function fails the moment you add delays, sensor reads, or display updates to your code. The Arduino will miss pulses, resulting in "drift" where the physical knob position no longer matches the software variable.
The code below uses hardware interrupts and a 16-state lookup table. This is the most robust method for decoding quadrature signals without external libraries. It includes built-in error handling: if electrical noise causes an invalid state transition, it logs an exact error string to the serial monitor and increments an error counter.
/*
* Arduino Rotary Encoder - Raw Interrupt Quadrature Decoder
* Target Board: Arduino Uno R3 (ATmega328P) / Nano v3
* Pins: CLK=D2, DT=D3, SW=D4
*/
#define PIN_CLK 2 // Must be interrupt-capable (INT0)
#define PIN_DT 3 // Must be interrupt-capable (INT1)
#define PIN_SW 4 // Push button
volatile long encoderPos = 0;
volatile uint32_t errorCount = 0;
uint8_t lastState = 0;
// 16-state lookup table for quadrature decoding
// Yields +1, -1, or 0 (invalid/no change)
const int8_t ENC_STATES[] = {
0, -1, 1, 0,
1, 0, 0, -1,
-1, 0, 0, 1,
0, 1, -1, 0
};
void setup() {
Serial.begin(115200);
// Configure pins
pinMode(PIN_CLK, INPUT_PULLUP);
pinMode(PIN_DT, INPUT_PULLUP);
pinMode(PIN_SW, INPUT_PULLUP);
// Read initial state to prevent false trigger on boot
lastState = (digitalRead(PIN_CLK) << 1) | digitalRead(PIN_DT);
// Attach interrupts to BOTH edges for maximum resolution
attachInterrupt(digitalPinToInterrupt(PIN_CLK), readEncoder, CHANGE);
attachInterrupt(digitalPinToInterrupt(PIN_DT), readEncoder, CHANGE);
Serial.println("Encoder initialized. Turn the shaft.");
}
void loop() {
// Non-blocking serial output
static long lastReportedPos = 0;
static uint32_t lastErrorReport = 0;
// Only print when position changes to avoid flooding the serial buffer
if (encoderPos != lastReportedPos) {
Serial.print("Position: ");
Serial.println(encoderPos);
lastReportedPos = encoderPos;
}
// Report errors periodically if they occur
if (errorCount > lastErrorReport && (millis() - lastReportedPos > 500)) {
Serial.print("Err: Quadrature State Skip detected. Total noise faults: ");
Serial.println(errorCount);
lastErrorReport = errorCount;
}
// Check push button (simple polling is fine for buttons)
if (digitalRead(PIN_SW) == LOW) {
delay(50); // Crude debounce for button only
if (digitalRead(PIN_SW) == LOW) {
encoderPos = 0;
Serial.println("Button pressed. Position reset to 0.");
while(digitalRead(PIN_SW) == LOW); // Wait for release
}
}
}
// Interrupt Service Routine (ISR)
void readEncoder() {
uint8_t currentState = (digitalRead(PIN_CLK) << 1) | digitalRead(PIN_DT);
// Combine last state and current state into a 4-bit index
uint8_t index = (lastState << 2) | currentState;
// Lookup the transition
int8_t step = ENC_STATES[index];
if (step != 0) {
encoderPos += step;
lastState = currentState; // Only update state on valid transitions
} else if (currentState != lastState) {
// If state changed but lookup yields 0, it's an invalid bounce/noise skip
errorCount++;
// Do NOT update lastState here, let it settle back to the valid state
}
}
Debugging: First 3 Things to Check When It Fails
If your serial monitor is stuck at zero, counting erratically, or throwing the Err: Quadrature State Skip fault continuously, do not rewrite the code immediately. Hardware and configuration mismatches cause 95% of encoder failures.
- Floating Pins (Missing Pull-ups): If you are using a bare EC11 instead of the KY-040 module, the module's built-in 10kΩ pull-up resistors are missing. The ATmega328P's internal pull-ups (enabled via
INPUT_PULLUP) are roughly 30kΩ-50kΩ, which can be too weak in noisy environments. Fix: Solder external 10kΩ resistors from the CLK and DT pins to 5V. - Interrupt Pin Mismatch: The code uses
digitalPinToInterrupt(). On the Uno R3, this maps strictly to D2 and D3. If you moved the wires to D4 and D5 to "free up" pins, the interrupts will never fire. Fix: Move CLK to D2 and DT to D3. (Note: On an Arduino Mega 2560, interrupt pins are 2, 3, 18, 19, 20, and 21). - Severe Contact Bounce (Missing Capacitors): If the serial monitor spams
Err: Quadrature State Skip detectedevery time you move the knob one detent, your mechanical contacts are bouncing violently, causing the ISR to read intermediate, physically impossible logic states. Fix: Solder a 0.1µF (100nF) ceramic capacitor directly across the CLK and GND pins, and another across DT and GND. This creates a hardware RC filter with a ~1ms time constant, absorbing the micro-second bounce before the microcontroller sees it.
Extending and Simplifying the Build
The raw state-machine approach above gives you total control and zero external dependencies, which is ideal for production firmware or tight memory constraints. However, depending on your end goal, you may want to pivot.
How to Simplify (The Library Route)
If you are rapidly prototyping and don't care about the 2KB of flash memory a library consumes, use the Encoder library by Paul Stoffregen. It is the industry standard for Arduino environments. It automatically handles pin-change interrupts (allowing you to use any digital pin, not just D2/D3) and abstracts the quadrature math into a simple myEnc.read() function. You can install it directly via the Arduino Library Manager.
How to Extend (Advanced Applications)
- I2C OLED Menu Navigation: Map the
encoderPosvariable to an array index. Use a modulo operator (menuIndex = encoderPos % TOTAL_ITEMS) to create a wrapping menu on an SSD1306 128x64 OLED display. - MIDI Pitch Bend: Mechanical encoders are perfect for MIDI controllers because they don't suffer from the "scratchy pot" noise of carbon-track potentiometers. Map the encoder position to the 14-bit MIDI Pitch Bend message (0 to 16383) and send it via the Arduino's hardware UART (TX pin) to a MIDI DIN breakout board.
- Velocity Sensitivity: By measuring the microsecond timestamp (
micros()) between valid ISR triggers, you can calculate the rotational velocity. Use this to implement "scroll acceleration"—turning the knob slowly increments by 1, but flicking it quickly increments by 10.
For deeper reading on microcontroller interrupt handling and timing constraints, refer to the official Arduino attachInterrupt() documentation. Always remember that code executing inside an ISR blocks the main loop; keep your ISR under 5 microseconds by strictly using bitwise math and volatile variables, leaving all serial printing and display updates to the main loop().






