A speed sensor Arduino setup typically relies on counting electrical pulses from a rotating shaft to calculate RPM or linear speed. For most bench and robotics applications, the most reliable and cost-effective choice is an LM393-based slotted optocoupler module. Unlike mechanical encoders that suffer from contact bounce, or Hall effect sensors that require precise magnetic alignment, a slotted optocoupler uses an infrared beam to detect physical slots in an encoder wheel, providing clean digital pulses up to 20 kHz.

This guide targets the Arduino Uno R3 (ATmega328P) and walks through the exact hardware selection, interrupt-driven wiring, and the specific debugging steps required when your Serial Monitor inevitably reads zero.

Choosing the Right Speed Sensor Module

Not all speed sensors are created equal. Selecting the wrong module for your target RPM will result in missed pulses or noisy data. Below is a spec-sheet comparison of the four most common modules you will encounter in 2026.

Module Type Sensor IC Operating Voltage Max Frequency Output Type Avg Price (2026)
LM393 Slotted Optocoupler (FC-03) LM393 Comparator + IR 3.3V - 5V ~20 kHz Open-Collector / Push-Pull $1.50
KY-024 Linear Hall Effect A1302 / 49E 4.5V - 6V ~10 kHz Analog + Digital $2.00
KY-003 Hall Magnetic A3144 4.5V - 24V ~100 kHz Open-Collector $1.00
KY-040 Rotary Encoder Mechanical Switches 3.3V - 5V ~1 kHz Quadrature Digital $1.80

The Verdict: For measuring motor shaft speed (typically 1,000 to 10,000 RPM), the LM393 Slotted Optocoupler wins. The KY-040 mechanical encoder will fail catastrophically at high RPMs due to switch bounce and physical inertia, while the KY-003 Hall sensor requires you to glue tiny neodymium magnets to your shaft with exact polarity alignment. The LM393 simply needs a slotted disc to break the IR beam.

Hardware BOM and Pin Mapping

To build this circuit, you need to isolate your motor power from your logic power. Running a 130-size DC motor directly off the Arduino's 5V regulator will cause brownouts and reset the microcontroller every time the motor experiences a load spike.

Parts List

  • Microcontroller: Arduino Uno R3 (ATmega328P, 16MHz crystal)
  • Sensor: LM393 Slotted Optocoupler Module (FC-03 variant with 4 pins: VCC, GND, DO, AO)
  • Target: 5V 130-size DC Motor equipped with a 20-slot encoder disc
  • Motor Driver: L298N or TB6612FNG (to drive the motor independently)
  • Power: 5V 2A USB power supply or a dedicated 7-12V barrel jack supply
  • Wiring: 22 AWG stranded silicone wire

Pin Mapping Table

LM393 Module Pin Arduino Uno R3 Pin Notes
VCC 5V Do not use 3.3V; the IR LED forward voltage requires ~1.2V, leaving insufficient headroom for the LM393 comparator.
GND GND Must share a common ground with the Arduino and motor driver.
DO (Digital Out) Pin 2 (INT0) Pin 2 is hardware interrupt 0 on the Uno. Do not use analog polling.
AO (Analog Out) Not Connected Leave floating. AO outputs a variable voltage based on the potentiometer threshold, which is useless for RPM counting.
Callout Tip: Potentiometer Tuning
The FC-03 module has a small blue trimpot. Before wiring it to the Arduino, power the module and place the encoder disc in the slot. Use a multimeter on the DO pin and turn the trimpot until the output cleanly snaps between 0V and 5V as you manually rotate the disc. If it's tuned wrong, ambient room light will trigger false pulses.

Wiring and Interrupt-Driven Code

Beginners often try to read speed sensors using digitalRead() inside the main loop(). This fails at high RPMs. If a motor spins at 6,000 RPM with a 20-slot disc, it generates 2,000 pulses per second (one pulse every 0.5 milliseconds). If your loop() takes 1ms to execute, you will miss half your pulses.

The solution is a Hardware Interrupt. The Arduino attachInterrupt() function pauses the main program the microsecond a pulse arrives, increments a counter, and resumes.

Complete Compilable Code

This code targets the Arduino Uno R3. It calculates RPM every 500ms using non-blocking timers and includes error handling for sensor timeouts.

#include <Arduino.h>

// --- HARDWARE CONFIGURATION ---
const int SENSOR_PIN = 2;       // INT0 on Arduino Uno R3
const int SLOTS = 20;           // Number of slots in the encoder disc
const unsigned long CALC_INTERVAL = 500; // Calculate RPM every 500ms
const unsigned long TIMEOUT_MS = 2000;   // Error threshold for stalled motor

// --- VOLATILE VARIABLES ---
// Must be volatile because they are modified inside an Interrupt Service Routine (ISR)
volatile unsigned long pulseCount = 0;
volatile unsigned long lastPulseTime = 0;

unsigned long lastCalcTime = 0;

// --- INTERRUPT SERVICE ROUTINE ---
void countPulse() {
  pulseCount++;
  lastPulseTime = millis();
}

void setup() {
  Serial.begin(115200);
  while (!Serial); // Wait for serial monitor on native USB boards (safe for Uno)
  
  // INPUT_PULLUP ensures the pin doesn't float if the LM393 open-collector output is high-Z
  pinMode(SENSOR_PIN, INPUT_PULLUP);
  
  // Attach interrupt on the FALLING edge (when the IR beam is broken)
  attachInterrupt(digitalPinToInterrupt(SENSOR_PIN), countPulse, FALLING);
  
  lastCalcTime = millis();
  lastPulseTime = millis();
  Serial.println("Speed Sensor Initialized. Waiting for pulses...");
}

void loop() {
  unsigned long currentTime = millis();
  
  // Non-blocking timer for RPM calculation
  if (currentTime - lastCalcTime >= CALC_INTERVAL) {
    
    // CRITICAL: Disable interrupts while reading multi-byte volatile variables
    noInterrupts();
    unsigned long count = pulseCount;
    unsigned long lastPulse = lastPulseTime;
    pulseCount = 0; // Reset counter for next interval
    interrupts();
    
    // Calculate Time Delta
    float timeSec = (currentTime - lastCalcTime) / 1000.0;
    
    // Calculate RPM: (Pulses / Slots) = Revolutions. Revolutions / Time = RPS. RPS * 60 = RPM.
    float rps = (float)count / SLOTS / timeSec;
    float rpm = rps * 60.0;
    
    // --- ERROR HANDLING & TIMEOUT LOGIC ---
    // If no pulses were counted, check if the motor is actually stalled or if the sensor died
    if (count == 0) {
      if (currentTime - lastPulse > TIMEOUT_MS) {
        Serial.println("ERR: SENSOR_TIMEOUT - Check alignment, trimpot, or motor power");
      } else {
        Serial.println("RPM: 0.0 (Motor Stalling or Below Threshold)");
      }
    } else {
      Serial.print("RPM: ");
      Serial.println(rpm, 1); // Print with 1 decimal place
    }
    
    lastCalcTime = currentTime;
  }
}

Debugging: Why Your Serial Monitor Reads Zero

When you upload this code and the Serial Monitor spits out ERR: SENSOR_TIMEOUT - Check alignment, trimpot, or motor power, do not immediately rewrite the code. The math is sound; the physics or the wiring is failing. Here are the first three things to check when it fails, ranked by probability.

1. The Trimpot Threshold is Misaligned (80% of failures)

The LM393 is a comparator. It compares the voltage drop across the phototransistor to a reference voltage set by the blue trimpot. If the ambient light in your room is bright, or the IR LED is slightly degraded, the reference voltage might be set too low. The comparator will output a constant HIGH, and the falling edge interrupt will never trigger.
The Fix: Connect a multimeter to the DO pin. Manually spin the motor. Adjust the trimpot with a small Phillips screwdriver until you see the voltage snap cleanly between ~0.1V and ~4.8V. If it hovers at 2.5V, your disc is not fully breaking the beam.

2. Incorrect Interrupt Pin Mapping (15% of failures)

The code explicitly uses digitalPinToInterrupt(2). On the Arduino Uno R3, Pin 2 is INT0 and Pin 3 is INT1. However, if you swapped to an Arduino Mega 2560 to get more pins, Pin 2 is not an interrupt pin (INT0 on a Mega is Pin 21). If you migrated this code to a Mega without changing the pin definition, the ISR will never fire.
The Fix: Verify your board variant. For Uno/Nano/Pro Mini, use Pin 2 or 3. For Mega, use Pin 2, 3, 18, 19, 20, or 21.

3. Ground Loop and Back-EMF Noise (5% of failures)

If your RPM reading is erratic (e.g., jumping from 1,000 to 15,000 RPM randomly), your logic ground is likely polluted by the DC motor's back-EMF noise. The LM393 is highly susceptible to ground bounce.
The Fix: Ensure the motor power ground and the Arduino ground are tied together at a single star point. Add a 0.1µF ceramic capacitor directly across the DC motor's physical terminals to suppress brush arcing noise.

Scaling the Build: Simplify or Extend

Once you have a stable RPM reading on the bench, you will likely need to adapt the project for a specific form factor or network requirement.

Simplify: Migrating to ATtiny85

If you are building a standalone tachometer and don't need Serial debugging, the ATtiny85 is a $1.20 alternative. However, the ATtiny85 does not have dedicated external interrupt pins like the Uno. You must use Pin Change Interrupts (PCINT). You will need to include the EnableInterrupt library and map the sensor to PB2 (Physical Pin 7). The math remains identical, but you will output the RPM to a 4-digit TM1637 I2C display instead of the Serial Monitor.

Extend: ESP32 and Hardware Pulse Counting

If you are logging motor data to a cloud dashboard via MQTT, the Arduino Uno's 8-bit architecture will bottleneck your network requests. Upgrading to an ESP32-WROOM-32 changes the architecture entirely.

Instead of using software interrupts (attachInterrupt), the ESP32 features a dedicated Hardware Pulse Counter (PCNT) peripheral. The PCNT counts pulses in silicon, completely independent of the CPU. This means even if your ESP32 is busy negotiating a TLS handshake with AWS IoT, it will not miss a single encoder pulse. You will use the esp32-hal-pcnt.h library, configure the PCNT unit to count on the rising edge, and read the hardware register directly in your main loop.

Summary Card: Best Practices for Speed Sensors
  • Always use Hardware Interrupts (or hardware counters) for RPM tracking. Polling fails at high speeds.
  • Protect your variables. Use volatile for ISR variables and noInterrupts() when reading them in the main loop.
  • Tune the trimpot under the exact lighting conditions your final project will operate in.
  • Isolate noise with decoupling capacitors on the motor and a shared star-ground topology.