The most common mistake beginners make when trying to measure a Pulse Width Modulation (PWM) signal is reaching for the analogRead() function. analogRead() samples DC voltage via the microcontroller's Analog-to-Digital Converter (ADC). A PWM signal, however, is a digital square wave rapidly switching between 0V and 5V (or 3.3V). If you feed a 50% duty cycle 5V PWM signal into an analog pin, the ADC will likely just read a fluctuating value near 512, giving you no usable data about the signal's frequency or precise duty cycle.
To properly arduino read pwm signals, you must measure the time the pin spends in the HIGH state versus the LOW state. This requires digital timing functions, not analog voltage sampling.
Decision Tree: Which PWM Reading Method to Choose
There are three primary ways to measure PWM on a microcontroller. Selecting the wrong one leads to blocked execution loops, missed pulses, or compiler errors. Use this decision matrix to pick your approach.
| Method | Best For | Blocking? | Precision | Verdict |
|---|---|---|---|---|
pulseIn() | Slow signals (RC servos at 50Hz), simple prototypes | Yes (halts CPU) | ~10µs | Use only if you have nothing else running in your loop(). |
| Hardware Interrupts | Signals 50Hz - 10kHz, multitasking (displays, motors) | No | ~4µs | DEFAULT PICK: Best balance of ease and non-blocking performance. |
| Input Capture (ICR1) | Ultrasonic sensors, high-frequency motor encoders (>10kHz) | No | ~0.06µs | Requires direct register manipulation. Overkill for standard hobby PWM. |
The Concrete Pick: For 90% of embedded projects (reading PC fan tachometers, RC receiver channels, or generic motor controller feedback), use Hardware Interrupts on Pin 2 or 3. The code below is built exclusively around this method.
Parts List and Pin Mapping
This build assumes you are reading a standard 5V logic-level PWM signal. If you are reading a 12V automotive or industrial PWM signal, you must step it down first (see the extension section).
| Component | Exact Variant / Specification | Quantity |
|---|---|---|
| Microcontroller | Arduino Uno R3 (DIP-28 ATmega328P, 16MHz crystal) | 1 |
| PWM Signal Source | Secondary Arduino Nano, 555 Timer Astable Module, or Digilent Analog Discovery 2 | 1 |
| Jumper Wires | 22 AWG solid core or standard Dupont male-to-female | 2 |
| Logic Level Shifter (Optional) | TXS0108E or 74HC4050 (Only if source is 12V or 3.3V to 5V) | 1 |
Pin Mapping Table
| Signal Source Pin | Arduino Uno R3 Pin | Notes |
|---|---|---|
| PWM Output | Pin 2 (INT0) | Must be an interrupt-capable pin. On the Uno R3, only Pin 2 and Pin 3 support hardware interrupts. |
| GND | GND | Critical: Source and Arduino must share a common ground reference. |
The Build: Hardware Interrupt Method (Non-Blocking)
The following C++ code uses an Interrupt Service Routine (ISR) triggered on the CHANGE state of Pin 2. It calculates the HIGH time and LOW time in microseconds, then derives the frequency and duty cycle. It includes robust error handling for signal loss and out-of-bounds calculations.
Serial.print() or delay() inside an ISR. ISRs must execute in microseconds. We only update volatile variables inside the ISR and do the math in the main loop().
/*
* PWM Reader using Hardware Interrupts
* Target: Arduino Uno R3 (ATmega328P)
* Measures Duty Cycle (%) and Frequency (Hz)
*/
// --- PIN DEFINITIONS ---
#define PWM_INPUT_PIN 2 // Must be INT0 (Pin 2) or INT1 (Pin 3) on Uno R3
// --- VOLATILE VARIABLES (Updated in ISR) ---
volatile unsigned long riseTime = 0;
volatile unsigned long fallTime = 0;
volatile unsigned long highTime = 0;
volatile unsigned long lowTime = 0;
volatile bool newDataAvailable = false;
// --- ERROR HANDLING STATE ---
unsigned long lastValidPulse = 0;
const unsigned long TIMEOUT_MS = 1000; // 1 second timeout
void setup() {
Serial.begin(115200);
pinMode(PWM_INPUT_PIN, INPUT);
// Attach interrupt to Pin 2, trigger on any state CHANGE
attachInterrupt(digitalPinToInterrupt(PWM_INPUT_PIN), pwmISR, CHANGE);
Serial.println("PWM Reader Initialized. Waiting for signal...");
lastValidPulse = millis();
}
void loop() {
unsigned long currentMillis = millis();
// 1. Check for Signal Timeout (Error Handling)
if (currentMillis - lastValidPulse > TIMEOUT_MS) {
Serial.println("Error: PWM timeout - no rising edge detected in 1000ms");
delay(500); // Throttle error messages
return;
}
// 2. Process Data if ISR flagged a complete cycle
if (newDataAvailable) {
// Disable interrupts briefly to safely copy multi-byte volatile variables
noInterrupts();
unsigned long copyHigh = highTime;
unsigned long copyLow = lowTime;
interrupts();
unsigned long totalTime = copyHigh + copyLow;
// Prevent division by zero
if (totalTime > 0) {
float dutyCycle = (copyHigh * 100.0) / totalTime;
float frequency = 1000000.0 / totalTime; // micros to Hz
// 3. Bounds Checking (Sanity Check)
if (dutyCycle < 0.0 || dutyCycle > 100.0) {
Serial.print("Error: Duty cycle out of bounds (Calculated: ");
Serial.print(dutyCycle);
Serial.println("%). Check for noise or missed edges.");
} else {
Serial.print("Freq: ");
Serial.print(frequency, 1);
Serial.print(" Hz | Duty: ");
Serial.print(dutyCycle, 1);
Serial.println(" %");
lastValidPulse = currentMillis; // Reset timeout timer
}
}
newDataAvailable = false;
}
// Main loop remains free for other tasks (e.g., driving motors, updating displays)
}
// --- INTERRUPT SERVICE ROUTINE ---
void pwmISR() {
if (digitalRead(PWM_INPUT_PIN) == HIGH) {
unsigned long currentTime = micros();
lowTime = currentTime - fallTime;
riseTime = currentTime;
} else {
unsigned long currentTime = micros();
highTime = currentTime - riseTime;
fallTime = currentTime;
newDataAvailable = true; // Flag main loop that a full cycle is captured
}
}
Debugging: First Three Things to Check When It Fails
When your serial monitor spits out errors or hangs, do not rewrite the code immediately. Hardware and wiring faults cause 95% of PWM reading failures. Check these three things first.
- Verify the Common Ground: If your PWM source (like a separate 555 timer circuit or a motor controller) is powered by a different battery or power supply than the Arduino, you must connect their GND pins together. Without a shared reference, the Arduino sees floating noise, resulting in erratic duty cycle readings (e.g., jumping from 12% to 88%).
- Confirm the Pin is Interrupt-Capable: If you changed
PWM_INPUT_PINto Pin 4 or Pin 7, the code will compile, butattachInterrupt()will silently fail to trigger. On the ATmega328P (Uno/Nano), only Pin 2 (INT0) and Pin 3 (INT1) support hardware interrupts. Consult the Arduino attachInterrupt() documentation for pin mappings on Mega or Leonardo boards. - Check Logic Voltage Levels: Feeding a 12V automotive PWM signal directly into Pin 2 will fry the ATmega328P's input protection diodes. Conversely, feeding a 3.3V signal from an ESP32 into a 5V Uno usually works, but noise margins are tight. Use a multimeter to verify the HIGH state voltage of your source before connecting it.
Common Error Strings and Ranked Causes
Error String: Error: PWM timeout - no rising edge detected in 1000ms
- Cause 1: Wire is disconnected or broken (Physical layer fault).
- Cause 2: PWM source is turned off or outputting a flat 0V/5V DC signal instead of a square wave.
- Cause 3: Signal frequency is too low (e.g., 0.1Hz), exceeding the 1000ms timeout threshold defined in the code.
Error String: Error: Duty cycle out of bounds (Calculated: 104.2%)
- Cause 1: High-frequency noise on the line causing the ISR to trigger multiple times per edge (contact bounce or EMI). Fix by adding a 10nF ceramic capacitor between Pin 2 and GND.
- Cause 2:
micros()rollover. Themicros()function overflows every ~70 minutes. If an edge happens exactly at the rollover boundary, the subtraction yields a massive number. The bounds-check in the code catches this and prevents a crash.
Compiler Error: fatal error: avr/interrupt.h: No such file or directory
- Cause: You copied direct register manipulation code (using
TCCR1AorICR1) intended for an AVR-based Uno and pasted it into an ESP32, Raspberry Pi Pico, or Arduino Uno R4 (Renesas ARM) environment. Stick to theattachInterrupt()API provided above, which is architecture-agnostic and works across all modern Arduino cores.
Extending and Simplifying the Build
How to Simplify (The Slow Signal Shortcut)
If you are strictly reading standard RC hobby servos or ESCs (which operate at exactly 50Hz, meaning a pulse every 20ms), and your loop() has no strict timing requirements, you can delete the ISR entirely and use pulseIn(). While pulseIn() blocks the CPU, a 50Hz signal only blocks it for a maximum of 20 milliseconds. For simple RC car steering projects, this is perfectly acceptable and reduces code complexity.
How to Extend (Handling High Voltage and Multiple Channels)
- Reading 12V/24V Industrial PWM: Do not use a simple resistor voltage divider for high-speed PWM; the parasitic capacitance of the resistors will round off the square wave edges, destroying your timing accuracy. Instead, use an optocoupler (like the 6N137) or a dedicated logic-level shifter IC to translate the 12V signal to a clean 5V logic edge.
- Reading Multiple PWM Channels: The Uno R3 only has two hardware interrupt pins (2 and 3). If you need to read a 6-channel RC receiver, you must switch to Pin Change Interrupts (PCINT), which can trigger on any digital pin but require more complex bitmasking in software. Alternatively, upgrade your hardware to an Arduino Mega 2560 (6 interrupt pins) or a Raspberry Pi Pico (all GPIOs support interrupts).
- The XY Problem (Filtering): If your actual goal was to control an analog device (like a 0-10V industrial dimmer) using an Arduino's PWM output, you don't need to 'read' PWM at all. You need to filter it. Pass your Arduino PWM output through a simple RC low-pass filter (e.g., 10kΩ resistor in series, 1µF capacitor to ground) to smooth the square wave into a true DC analog voltage.






