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.
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
- Connect the KY-040
GNDand+(VCC) pins to the Arduino Uno R3 GND and 5V rails. - Wire the encoder
CLKpin to Arduino Digital Pin 2. This pin is hardwired to the INT0 interrupt vector. - Wire the encoder
DTpin to Arduino Digital Pin 3. We will read this inside the ISR to determine rotation direction. - Connect your oscilloscope probe or logic analyzer channel 0 to Arduino Digital Pin 9 to monitor the hardware timer output.
- 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:
- Library Conflict (Most Likely): The
Servolibrary relies on Timer1's__vector_13to generate its 50Hz PWM pulses. You cannot use the Servo library and a customISR(TIMER1_COMPA_vect)simultaneously. Fix: Move your custom timer to Timer2 (usingISR(TIMER2_COMPA_vect)) or use theESP32Servolibrary on an ESP32 board, which uses the LEDC peripheral instead of hardware timers. - Duplicate ISR Definitions: You accidentally defined
ISR(TIMER1_COMPA_vect)twice in your sketch or across multiple.cppfiles. Fix: Search your project directory for duplicate vector names. - 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:
- Missing the
volatileKeyword: Iftarget_frequency_hzis not declared asvolatile, the GCC compiler will optimize the mainloop()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 asvolatile. - Forgetting
sei()in Setup: The ATmega328P ships with global interrupts disabled. If you configureTIMSK1andattachInterrupt()but forget to callsei()at the end of yoursetup(), 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. - Blocking Code Inside the ISR: Never put
Serial.print(),delay(), orWire.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.
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.






