If you need to measure rotational or linear velocity with a microcontroller, bypass the cheap KY-040 rotary knobs and single-channel hall effect sensors. The direct answer for reliable, high-resolution velocity tracking is a 600 PPR (Pulses Per Revolution) AB-phase optical incremental encoder paired with hardware interrupts. This setup gives you 2,400 state changes per revolution in full quadrature, providing the precision needed for PID motor control, robotics, and conveyor tracking.
This guide walks through wiring a 5V push-pull optical encoder to an Arduino Nano V3, writing an optimized interrupt service routine (ISR) using direct port manipulation to prevent dropped pulses at high RPM, and calculating real-world linear velocity.
Choosing the Right Velocity Sensor for Arduino
Not all velocity sensors are created equal. The right choice depends on your required resolution, environmental conditions, and maximum RPM. Below is a comparison of the most common sensor types used in embedded projects.
| Sensor Type | Resolution | Max RPM / Speed | Interface | Typical Cost (2026) | Best Use Case |
|---|---|---|---|---|---|
| Optical Quadrature (600 PPR) | 2400 counts/rev | ~3000 RPM | AB Phase (Push-Pull) | $25 - $45 | PID motor control, robotics |
| Magnetic Absolute (AS5048A) | 14-bit (16384 steps) | ~12000 RPM | SPI / I2C | $15 - $25 | Stepper closed-loop, gimbals |
| Hall Effect (KY-024 / 3144) | 1 pulse/rev | ~5000 RPM | Digital High/Low | $1 - $3 | Basic tachometers, anemometers |
| Doppler Radar (RCWL-0516) | N/A (Analog shift) | Line of sight dependent | Analog Voltage | $3 - $5 | Non-contact linear speed, fluid flow |
- Supply Voltage: 5V DC (4.5V to 5.5V acceptable)
- Current Draw: ~50mA max
- Output Type: Push-Pull (Totem-pole) — no external pull-up resistors required
- Max Response Frequency: 60 kHz (Allows up to 2500 RPM at 2400 counts/rev)
- Shaft Diameter: 6mm (Standard D-cut)
Parts List and Pin Mapping
This build targets the Arduino Nano V3 (ATmega328P, 16MHz). While the code will run on an Uno, the Nano's breadboard-friendly footprint makes it ideal for prototyping sensor rigs. Ensure your Nano uses the CH340 or FT232RL USB-to-serial chip; the bootloader behavior is identical for this code.
Bill of Materials
- 1x Arduino Nano V3 (ATmega328P)
- 1x 600 PPR AB-Phase Optical Encoder (5V Push-Pull output, e.g., CUI Devices AMT102 or equivalent)
- 2x 0.1µF ceramic decoupling capacitors
- 1x Shielded 4-core cable (22 AWG stranded) — critical for noise rejection
- 1x 5V 2A power supply (Do not power the encoder solely from the Nano's USB 5V rail if the motor is also running)
Wiring Pinout Table
| Encoder Wire Color | Signal | Arduino Nano Pin | Notes |
|---|---|---|---|
| Red | VCC | 5V | Add 0.1µF cap between 5V and GND at the Nano |
| Black | GND | GND | Must share common ground with motor driver |
| White | Channel A | D2 (INT0) | Hardware interrupt pin |
| Green | Channel B | D3 (INT1) | Hardware interrupt pin |
Interrupt-Driven Arduino Code
Standard digitalRead() functions inside an Interrupt Service Routine (ISR) take roughly 5-6 microseconds. At 3000 RPM, a 600 PPR encoder generates 120,000 interrupts per second (one every 8.3µs). Using standard functions will cause the ATmega328P to spend 70% of its CPU time just reading pins, leading to dropped pulses and erratic velocity readings.
The code below uses direct port manipulation (PIND) to read the pin states in under 1 microsecond. It also includes a timeout error handler to detect disconnected sensors.
#include <Arduino.h>
// --- PIN DEFINITIONS ---
#define ENCODER_PIN_A 2 // Must be hardware interrupt pin (INT0)
#define ENCODER_PIN_B 3 // Must be hardware interrupt pin (INT1)
// --- PHYSICAL CONSTANTS ---
const float WHEEL_DIAMETER_MM = 96.0; // Example: 96mm pololu wheel
const float WHEEL_CIRCUMFERENCE_M = (WHEEL_DIAMETER_MM * 3.14159) / 1000.0;
const int COUNTS_PER_REV = 2400; // 600 PPR * 4 (Full Quadrature)
// --- VOLATILE VARIABLES (Modified in ISR) ---
volatile long encoderCount = 0;
volatile unsigned long lastPulseMicros = 0;
// --- STATE VARIABLES ---
unsigned long lastCalcMillis = 0;
const unsigned long CALC_INTERVAL_MS = 100; // Calculate velocity every 100ms
const unsigned long SENSOR_TIMEOUT_MS = 1000; // 1 second without pulses
bool sensorConnected = true;
// --- ISR: Full Quadrature Decoding using Direct Port Manipulation ---
void readEncoder() {
// Read Port D (pins 0-7). PD2 is Pin 2, PD3 is Pin 3.
uint8_t pinState = PIND;
uint8_t valA = (pinState >> 2) & 0x01; // Extract bit 2 (Pin 2)
uint8_t valB = (pinState >> 3) & 0x01; // Extract bit 3 (Pin 3)
// XOR logic for direction detection
if (valA != valB) {
encoderCount++;
} else {
encoderCount--;
}
lastPulseMicros = micros();
}
void setup() {
Serial.begin(115200);
pinMode(ENCODER_PIN_A, INPUT_PULLUP);
pinMode(ENCODER_PIN_B, INPUT_PULLUP);
// Attach interrupts to both channels for 4x resolution
attachInterrupt(digitalPinToInterrupt(ENCODER_PIN_A), readEncoder, CHANGE);
attachInterrupt(digitalPinToInterrupt(ENCODER_PIN_B), readEncoder, CHANGE);
lastPulseMicros = micros();
lastCalcMillis = millis();
Serial.println("Velocity Sensor Initialized. Target: Arduino Nano V3.");
}
void loop() {
unsigned long currentMillis = millis();
// --- ERROR HANDLING: Sensor Timeout Check ---
// If no pulses for 1 second, and we aren't just starting up
if (currentMillis - (lastPulseMicros / 1000) > SENSOR_TIMEOUT_MS) {
if (sensorConnected) {
Serial.println("ERR: SENSOR_TIMEOUT - No pulses detected. Check wiring.");
sensorConnected = false;
}
} else {
if (!sensorConnected) {
Serial.println("INFO: Sensor reconnected.");
sensorConnected = true;
}
}
// --- VELOCITY CALCULATION (Non-blocking) ---
if (currentMillis - lastCalcMillis >= CALC_INTERVAL_MS) {
// Disable interrupts briefly to safely copy volatile variables
noInterrupts();
long currentCount = encoderCount;
encoderCount = 0; // Reset for next window
interrupts();
float timeDeltaSec = CALC_INTERVAL_MS / 1000.0;
// Calculate RPM
float revsPerSec = (float)currentCount / COUNTS_PER_REV / timeDeltaSec;
float rpm = revsPerSec * 60.0;
// Calculate Linear Velocity (m/s)
float linearVelocity = revsPerSec * WHEEL_CIRCUMFERENCE_M;
// Output Data
Serial.print("RPM: ");
Serial.print(rpm, 1);
Serial.print(" | Linear: ");
Serial.print(linearVelocity, 3);
Serial.println(" m/s");
lastCalcMillis = currentMillis;
}
}
Debugging: First Three Things to Check
When working with high-resolution encoders, hardware noise and software bottlenecks mimic each other. If your serial monitor outputs erratic data, follow this ranked troubleshooting path.
1. Symptom: "ERR: SENSOR_TIMEOUT" or Count Stuck at 0
Ranked Causes:
- Incorrect Interrupt Pins: You wired Channel A or B to a non-interrupt pin (like D4 or D5). On the Nano/Uno, only D2 and D3 support hardware interrupts. Move the wires.
- Power Starvation: The encoder's internal IR LEDs draw ~50mA. If powered from a weak USB hub, the voltage drops below 4.5V and the optical sensor shuts down. Measure the VCC pin with a multimeter under load; it must read >4.8V.
- Missing Common Ground: If the encoder is powered by an external 5V supply, its GND must be tied directly to the Arduino Nano's GND.
2. Symptom: Velocity spikes to max RPM randomly while motor is off
Exact Error String: RPM: 8500.0 | Linear: 41.22 m/s (when stationary).
Ranked Causes:
- EMI / Floating Pins: Unshielded wires are acting as antennas, picking up 60Hz mains hum or PWM noise from nearby motor drivers. Fix: Use shielded cable, ensure INPUT_PULLUP is active, and add 10nF ceramic capacitors between the signal pins (D2, D3) and GND to filter high-frequency noise.
- Mechanical Vibration: The encoder shaft is vibrating at a resonant frequency, causing the optical disk to dither back and forth across the sensor threshold. Fix: Use a flexible shaft coupler to isolate the encoder from motor shaft runout.
3. Symptom: Count misses at high RPM (Velocity caps out prematurely)
Ranked Causes:
- ISR Overhead: If you modified the code to use
digitalRead()instead ofPIND, the CPU cannot service the interrupts fast enough. Revert to the direct port manipulation code provided above. - Serial Print Blocking: If your
CALC_INTERVAL_MSis set too low (e.g., 10ms), theSerial.print()function blocks the main loop, delaying interrupt re-enabling. Keep the calculation interval at 50ms or higher.
Extending and Simplifying the Build
Depending on your project's end goal, you may need to scale this setup up or strip it down.
How to Simplify (Offload the CPU)
If your ATmega328P is already bogged down with complex kinematics or wireless communication, handling 120,000 interrupts a second will cause system instability. The Fix: Add a dedicated quadrature counter IC like the LS7366R. This 8-pin DIP chip handles the AB-phase decoding and 32-bit counting in hardware. You simply read the final count via SPI every 100ms. The Arduino CPU usage drops to near zero, and you completely eliminate missed pulse errors.
How to Extend (Move to ESP32)
If you are building a multi-wheel rover and need to track four encoders simultaneously, the Arduino Nano lacks sufficient hardware interrupt pins and CPU bandwidth. The Fix: Migrate to an ESP32-WROOM-32 dev board. The ESP32 features a dedicated PCNT (Pulse Counter) peripheral. The PCNT counts encoder pulses entirely in hardware background without firing a single CPU interrupt. You can map up to 8 independent encoders to the ESP32's PCNT units, freeing the dual-core 240MHz processor to run your ROS nodes or MQTT telemetry.
For deeper reading on quadrature decoding logic and hardware specifications, refer to the CUI Devices Rotary Encoder Application Notes and the official Arduino attachInterrupt() Documentation.






