If you are trying to read an RC receiver channel, an ultrasonic sensor, or a raw PWM signal, you need to measure arduino pulse width—the exact duration a digital pin stays HIGH. The default answer most beginners find is the pulseIn() function. But if you have ever watched your serial monitor freeze or your control loop stutter, you have already discovered the fatal flaw of blocking I/O.
The direct answer for 90% of hobbyist and robotics projects: Use Hardware External Interrupts (attachInterrupt()) on Pins 2 or 3. It is non-blocking, highly accurate, and frees up your main loop for PID calculations or motor control. Below is the exact decision framework, the bulletproof code, and the debugging steps when your readings drop to zero.
The Decision Path: Which Pulse Width Method to Choose
Not all pulse width measurement techniques are created equal. The right choice depends entirely on your channel count and your main loop's timing budget. Use this decision matrix to pick your approach.
| Scenario | Method | Verdict & Trade-offs |
|---|---|---|
| 1 channel, low priority (e.g., single HC-SR04 ultrasonic sensor) | pulseIn() |
Acceptable. Blocks the CPU for up to the timeout duration. Fine if your loop doesn't need strict timing. |
| 1-2 channels, RC receiver, strict main loop timing | attachInterrupt() |
DEFAULT PICK. Non-blocking. Fires an ISR on pin state change. Requires atomic variable reading in the main loop. |
| >2 channels, or sub-microsecond precision required | Timer1 Input Capture (ICP1) | Advanced. Uses hardware timers to timestamp edges without CPU intervention. Best for custom flight controllers. |
Parts List & Pin Mapping
This guide and the accompanying code target the Arduino Nano V3 (ATmega328P, 16MHz, 5V logic). If you are using an ESP32 or Raspberry Pi Pico, the interrupt logic remains the same, but you must use a logic level shifter (3.3V to 5V) or ensure your receiver outputs 3.3V PWM.
| Component | Exact Variant / Model | Notes |
|---|---|---|
| Microcontroller | Arduino Nano V3 (ATmega328P) | Must be 5V/16MHz for standard micros() timing. |
| RC Receiver | FlySky FS-iA6B or FrSky XM+ | Outputs standard 50Hz PWM (1000-2000µs). |
| Power Supply | 5V 2A UBEC | Do not power receivers directly from the Nano's 5V pin if servos are attached. |
| Wiring | 22 AWG Silicone Wire | Keep signal wires under 6 inches to avoid EMI jitter. |
Pin Mapping Table
| Signal / Function | Arduino Nano Pin | Receiver / Sensor Pin |
|---|---|---|
| Channel 1 PWM Input | D2 (INT0) | CH1 Signal |
| Channel 2 PWM Input | D3 (INT1) | CH2 Signal |
| Ground Reference | GND | GND (Crucial: Common ground required) |
The Code: Non-Blocking Interrupt Measurement
Below is the complete, compilable code for reading a pulse width using hardware interrupts.
// Arduino Pulse Width Measurement via External Interrupts
// Target: Arduino Nano V3 (ATmega328P, 16MHz)
// Author: ElectricalFlux
const byte PULSE_PIN = 2; // Must be an interrupt-capable pin (2 or 3 on Uno/Nano)
// Volatile variables modified by the ISR
volatile unsigned long riseTime = 0;
volatile unsigned long pulseWidth = 0;
volatile bool pulseReady = false;
void setup() {
Serial.begin(115200);
pinMode(PULSE_PIN, INPUT_PULLUP); // Use INPUT_PULLUP if receiver has open-drain output
// Attach interrupt: triggers on any edge change (HIGH to LOW, or LOW to HIGH)
attachInterrupt(digitalPinToInterrupt(PULSE_PIN), isrPulse, CHANGE);
Serial.println("Pulse width measurement started.");
}
void loop() {
// CRITICAL EMBEDDED STEP: Atomic read of 32-bit volatile variables.
// On 8-bit AVR, reading a 32-bit int takes multiple clock cycles.
// If the ISR fires mid-read, you get a 'torn' (garbage) value.
noInterrupts();
unsigned long currentPW = pulseWidth;
bool isReady = pulseReady;
if (isReady) {
pulseReady = false; // Reset flag
}
interrupts();
if (isReady) {
// Error handling: Validate RC PWM bounds (typically 800us to 2200us)
if (currentPW >= 800 && currentPW <= 2200) {
Serial.print("Valid Pulse Width: ");
Serial.print(currentPW);
Serial.println(" us");
} else if (currentPW > 0) {
Serial.print("Out of bounds noise detected: ");
Serial.print(currentPW);
Serial.println(" us (Ignored)");
}
}
// Non-blocking timeout check: If no pulse for 100ms, signal failsafe
static unsigned long lastPulseTime = millis();
if (isReady) lastPulseTime = millis();
if (millis() - lastPulseTime > 100) {
// Serial.println("FAILSAFE: Signal Lost");
}
}
// Interrupt Service Routine (ISR)
void isrPulse() {
if (digitalRead(PULSE_PIN) == HIGH) {
// Rising edge: record the start time
riseTime = micros();
} else {
// Falling edge: calculate width if we have a valid start time
if (riseTime > 0) {
pulseWidth = micros() - riseTime;
pulseReady = true;
riseTime = 0; // Reset to prevent double-triggering on noise
}
}
}
Why this code works: Notice the noInterrupts() and interrupts() block in the main loop. According to the Arduino attachInterrupt documentation, failing to protect multi-byte volatile variables on 8-bit microcontrollers results in random, massive spikes in your data. This atomic copy prevents that.
Troubleshooting: Why is my Pulse Width Reading Zero or Jittering?
When your serial monitor misbehaves, do not guess. Follow this ranked diagnostic path based on the exact output you are seeing.
Symptom 1: Serial monitor prints: Pulse Width: 0 us (or failsafe triggers immediately)
First three things to check:
- Common Ground: The Arduino GND and the RC Receiver/Sensor GND must be tied together. A floating ground will result in no recognizable voltage threshold crossing.
- Pin Capability: Verify you are using Pin 2 or Pin 3. Pins like D4 or D7 on the Nano do not support external hardware interrupts via
attachInterrupt(). - Signal Voltage: Use a multimeter to measure the signal pin. Standard RC receivers output 3.3V or 5V. If you are using a 3.3V sensor with a 5V Arduino, the
INPUT_PULLUPmight be fighting the sensor. Switch to standardINPUTor use a level shifter.
Symptom 2: Serial monitor prints: Pulse Width: 1452 us (jittering ±80us)
If your baseline should be a steady 1500µs but it is bouncing wildly, you are experiencing EMI (Electromagnetic Interference) or ISR tearing.
- Cause A (Most Likely): Missing the atomic read. If you removed the
noInterrupts()block from the code above to 'simplify' it, the 8-bit CPU is reading the 32-bitpulseWidthvariable while the ISR is actively writing to it. Put the atomic block back. - Cause B: Long Signal Wires. PWM signals are high-impedance and highly susceptible to noise from brushless motor ESCs. Keep signal wires under 6 inches, or use shielded cable.
- Cause C: Power Supply Sag. If the receiver's BEC (Battery Eliminator Circuit) is sagging under load, the logic HIGH voltage drops, shifting the exact microsecond the Arduino registers the edge. Power the receiver from a dedicated 5V UBEC.
Extending or Simplifying the Build
Depending on your final application, you may need to scale this setup up or strip it down.
How to Simplify (The Hardware Bypass)
If you do not want to write interrupt code and just want serial data, buy a dedicated PWM-to-Serial decoder. The Pololu RC Receiver to Serial Adapter (approx. $15) reads up to 8 channels of PWM and outputs a clean UART byte stream. You simply use Serial.read() on the Arduino. This offloads all timing constraints to the dedicated chip.
How to Extend (Multi-Channel and ESC Generation)
If you need to read 6 channels for a rover, external interrupts on a Nano will bottleneck (only 2 pins available). You have two paths:
- Pin Change Interrupts (PCINT): Use the pulseIn alternative library like
EnableInterruptto fire ISRs on any digital pin. Be warned: PCINTs require manual bitmask checking to determine which pin changed state, adding slight latency. - Switch to SBUS/CRSF: Modern RC systems use serial protocols (SBUS via inverted UART, or CRSF via standard UART) which pack 16 channels into a single wire. If you are designing a custom robot in 2026, abandon raw PWM reading and use an SBUS inverter board with the
SBUSArduino library.
For generating ESC pulses (outputting pulse width), do not use the interrupt method in reverse. Use the standard Servo.h library, which utilizes Timer1 under the hood to guarantee a precise 50Hz, 1000-2000µs pulse train without burdening the CPU.






