If you are relying on delay() to time your sensor reads or pulse outputs, your microcontroller is effectively paralyzed while it waits. Mastering Arduino timers and interrupts is the dividing line between a blinking LED hobbyist and an embedded systems engineer. Hardware timers allow the ATmega328P to count clock cycles independently of your main code, while external interrupts let the chip instantly react to pin state changes without constantly polling.

In this guide, we will build a non-blocking precision tachometer and pulse generator targeting the Arduino Uno R3 (ATmega328P). We will bypass the Arduino core libraries and configure the hardware registers directly, then cover the exact debugging steps when your Interrupt Service Routine (ISR) inevitably misbehaves.

ATmega328P Hardware Timer and Interrupt Capabilities

The ATmega328P features three hardware timers. Understanding which one to use—and which ones the Arduino core is already hijacking—is critical before you write a single line of register code. If you overwrite Timer0, you break millis() and delay(). If you overwrite Timer1, you break the Servo library.

Timer Resolution Prescaler Options Default Arduino Core Usage Primary ISR Vector (Overflow)
Timer0 8-bit 1, 8, 64, 256, 1024 millis(), delay(), PWM on pins 5 & 6 __vector_16
Timer1 16-bit 1, 8, 64, 256, 1024 Servo library, PWM on pins 9 & 10 __vector_13
Timer2 8-bit 1, 8, 32, 64, 128, 256, 1024 tone(), PWM on pins 3 & 11 __vector_7

For high-precision timing, Timer1 is the undisputed choice. Its 16-bit resolution allows it to count up to 65,535 ticks before overflowing, compared to the 255 ticks of the 8-bit timers. According to the Microchip ATmega328P Datasheet, Timer1 also supports the Input Capture Unit (ICU), which is invaluable for measuring the exact duration of incoming pulses.

Bench Tip: Never use Timer0 for custom precision interrupts unless you are completely abandoning the Arduino core timing functions. Stick to Timer1 for 16-bit precision or Timer2 if you only need 8-bit resolution and aren't using the tone() function.

Project Build: Precision Tachometer & Pulse Generator

We will wire a rotary encoder to trigger external interrupts on every detent click, updating a target frequency variable. Simultaneously, Timer1 will run in CTC (Clear Timer on Compare Match) mode to generate a highly stable PWM pulse on Pin 9, completely independent of the main loop().

Parts List & Pricing (2026 Estimates)

  • Microcontroller: Arduino Uno R3 (Genuine or high-quality clone with ATmega16U2 USB chip) — ~$28 genuine / $14 clone.
  • Input: KY-040 Rotary Encoder Module (ensure it includes the 10kΩ pull-up resistors on the breakout board) — ~$3.
  • Verification: Logic Analyzer (e.g., Saleae Logic Pro 8 or a $12 generic 24MHz 8-channel clone) to verify pulse widths.
  • Wiring: 22 AWG solid core jumper wires, breadboard.

Pin Mapping Table

Component Pin ATmega328P Pin Arduino Digital Pin Function / Interrupt Vector
Encoder CLK PD2 2 External Interrupt 0 (INT0)
Encoder DT PD3 3 External Interrupt 1 (INT1) / Direction Read
Encoder SW / GND GND GND Common Ground
Timer1 Output PB1 9 OC1A (Hardware PWM / CTC Toggle)

Wiring Steps

  1. Connect the KY-040 GND and + (VCC) pins to the Arduino Uno R3 GND and 5V rails.
  2. Wire the encoder CLK pin to Arduino Digital Pin 2. This pin is hardwired to the INT0 interrupt vector.
  3. Wire the encoder DT pin to Arduino Digital Pin 3. We will read this inside the ISR to determine rotation direction.
  4. Connect your oscilloscope probe or logic analyzer channel 0 to Arduino Digital Pin 9 to monitor the hardware timer output.
  5. Double-check that no other peripherals are attached to Pins 9 or 10, as Timer1 controls both.

Compilable Register-Level Code

The following code targets the Arduino Uno R3 (ATmega328P). It uses direct register manipulation for Timer1 and the attachInterrupt() abstraction for the encoder. Notice the strict use of the volatile keyword and the absence of any blocking functions inside the ISR.

#include <avr/io.h>
#include <avr/interrupt.h>

// --- Pin Definitions ---
const uint8_t ENCODER_CLK_PIN = 2;  // INT0
const uint8_t ENCODER_DT_PIN  = 3;  // Read for direction
const uint8_t PWM_OUT_PIN     = 9;  // OC1A

// --- Shared Variables ---
// MUST be volatile since they are modified in ISR and read in main loop
volatile int16_t target_frequency_hz = 100; 
volatile bool frequency_updated = false;

// Bounds for our tachometer/generator
const int16_t MIN_FREQ = 10;
const int16_t MAX_FREQ = 5000;

void setup() {
  Serial.begin(115200);
  
  pinMode(ENCODER_DT_PIN, INPUT);
  pinMode(PWM_OUT_PIN, OUTPUT);
  
  // --- Configure External Interrupt (INT0 on Pin 2) ---
  // Trigger on falling edge to catch the detent click reliably
  attachInterrupt(digitalPinToInterrupt(ENCODER_CLK_PIN), encoderISR, FALLING);
  
  // --- Configure Timer1 for CTC Mode (Clear Timer on Compare Match) ---
  cli(); // Disable global interrupts during setup
  
  TCCR1A = 0; // Clear Timer1 control register A
  TCCR1B = 0; // Clear Timer1 control register B
  TCNT1  = 0; // Initialize counter value to 0
  
  // Set compare match register for target frequency
  // Formula: OCR1A = (16,000,000 / (Prescaler * Target_Freq)) - 1
  // We will update this dynamically, starting at 100Hz
  updateTimer1CompareRegister(100);
  
  // TCCR1B: WGM12 (CTC mode), CS11 (Prescaler = 8)
  TCCR1B |= (1 << WGM12) | (1 << CS11);
  
  // Enable Timer1 Compare Match A interrupt
  TIMSK1 |= (1 << OCIE1A);
  
  sei(); // Enable global interrupts
}

void loop() {
  // Main loop remains completely non-blocking
  if (frequency_updated) {
    // Critical section: disable interrupts briefly to read multi-byte volatile safely
    cli();
    int16_t current_freq = target_frequency_hz;
    sei();
    
    Serial.print("New Target Frequency: ");
    Serial.print(current_freq);
    Serial.println(" Hz");
    
    frequency_updated = false;
  }
}

// --- Interrupt Service Routines ---

// External Interrupt 0: Reads encoder direction and updates target
void encoderISR() {
  // Read direction pin (DT). If LOW, rotating clockwise; if HIGH, counter-clockwise
  uint8_t direction = digitalRead(ENCODER_DT_PIN);
  
  if (direction == LOW) {
    if (target_frequency_hz < MAX_FREQ) {
      target_frequency_hz += 10;
    }
  } else {
    if (target_frequency_hz > MIN_FREQ) {
      target_frequency_hz -= 10;
    }
  }
  
  // Recalculate hardware timer compare value
  updateTimer1CompareRegister(target_frequency_hz);
  frequency_updated = true;
}

// Timer1 Compare Match A ISR: Toggles Pin 9 (OC1A)
ISR(TIMER1_COMPA_vect) {
  // Toggle the hardware pin directly via PORTB for maximum speed
  // Pin 9 is PB1
  PORTB ^= (1 << PB1);
}

// --- Helper Function ---
void updateTimer1CompareRegister(int16_t freq) {
  // 16MHz clock / 8 prescaler = 2,000,000 ticks per second
  // We want to toggle twice per full wave, so divide freq by 2 for the toggle rate
  uint16_t compare_value = (2000000UL / (freq * 2)) - 1;
  
  // Safety bound to prevent overflow or zero-division
  if (compare_value > 65535) compare_value = 65535;
  OCR1A = compare_value;
}

Debugging: Fixing Vector Clashes and ISR Freezes

When working with bare-metal AVR interrupts, the compiler and the hardware will punish minor mistakes. Here is how to diagnose the most common failures.

The Compile Error: multiple definition of '__vector_13'

If you attempt to compile the code above but also include the standard Servo.h library, the compiler will halt and throw this exact error string:

C:\Users\...\AppData\Local\Temp\ccXXXXXX.ltrans0.ltrans.o: In function `__vector_13':
multiple definition of `__vector_13'
C:\Users\...\AppData\Local\Temp\ccXXXXXX.ltrans0.ltrans.o:(.text+0x0): first defined here
collect2.exe: error: ld returned 1 exit status

Ranked Causes & Fixes:

  1. Library Conflict (Most Likely): The Servo library relies on Timer1's __vector_13 to generate its 50Hz PWM pulses. You cannot use the Servo library and a custom ISR(TIMER1_COMPA_vect) simultaneously. Fix: Move your custom timer to Timer2 (using ISR(TIMER2_COMPA_vect)) or use the ESP32Servo library on an ESP32 board, which uses the LEDC peripheral instead of hardware timers.
  2. Duplicate ISR Definitions: You accidentally defined ISR(TIMER1_COMPA_vect) twice in your sketch or across multiple .cpp files. Fix: Search your project directory for duplicate vector names.
  3. Core Version Bug: Rarely, an outdated or forked Arduino AVR core might map a background task to Timer1. Fix: Update the "Arduino AVR Boards" package to the latest version via the Boards Manager.

Runtime Failures: The First 3 Things to Check When It Freezes

If your code compiles but the microcontroller locks up, reboots randomly, or ignores the encoder entirely, check these three items immediately:

  1. Missing the volatile Keyword: If target_frequency_hz is not declared as volatile, the GCC compiler will optimize the main loop() by caching the variable in a CPU register. The ISR will update the RAM value, but the main loop will never see the change. Always mark variables shared between an ISR and the main loop as volatile.
  2. Forgetting sei() in Setup: The ATmega328P ships with global interrupts disabled. If you configure TIMSK1 and attachInterrupt() but forget to call sei() at the end of your setup(), the hardware will trigger the interrupt flags, but the CPU will ignore them. The board won't freeze, but your interrupts will simply never fire.
  3. Blocking Code Inside the ISR: Never put Serial.print(), delay(), or Wire.requestFrom() inside an ISR. Interrupts are globally disabled while an ISR executes. If your ISR takes 2 milliseconds to print to the Serial monitor, you will drop incoming encoder clicks, and you risk a deadlock if the Serial buffer fills up and waits for an interrupt that is currently blocked. Keep ISRs under 5 microseconds.
Measurement Threshold: Use your oscilloscope to measure the ISR execution time. Toggle a spare digital pin HIGH at the start of the ISR and LOW at the end. On a 16MHz ATmega328P, a clean ISR like the one above should show a pulse width of less than 3µs on the scope.

Extending and Simplifying the Build

Depending on your project timeline and production requirements, you may need to scale this architecture up or abstract the complexity away.

How to Extend: Porting to the ESP32

If you need to log this tachometer data to an SD card via SPI or push it over MQTT via WiFi, the ATmega328P will bottleneck. The ESP32 handles Arduino timers and interrupts differently, utilizing dedicated hardware timer peripherals that don't conflict with WiFi or Servo libraries.

  • Use hw_timer_t *timer = timerBegin(0, 80, true); to configure a 1MHz tick rate (80MHz APB clock / 80 prescaler).
  • Attach the ISR using timerAttachInterrupt(timer, &onTimer, true);.
  • The ESP32's dual-core architecture allows you to pin the WiFi stack to Core 0 and your precision timing ISR to Core 1, eliminating network jitter from your pulse generation.

How to Simplify: Using the TimerOne Library

If direct register manipulation (TCCR1B |= (1 << WGM12)) feels brittle and you want to ensure compatibility across different AVR clock speeds without rewriting math, use the community-standard TimerOne library.

Instead of 20 lines of register configuration, you simply call:

#include <TimerOne.h>

void setup() {
  Timer1.initialize(10000); // 10,000 microseconds = 100Hz
  Timer1.attachInterrupt(timerISR);
}

This abstracts the prescaler and OCR1A math, automatically handling the 16-bit boundaries. The trade-off is a slight increase in flash memory usage and a few extra CPU cycles of overhead per interrupt, which is negligible for frequencies below 10kHz but matters in high-speed motor control applications.