Timer Interrupts: Precise Timing Without Blocking Your Main Loop

Understanding Interrupts in Microcontrollers and Microprocessors — Part 3


            

In part 1 and part 2 we covered what interrupts are and how to write safe ISRs. Now we tackle one of the most useful and widely used interrupt sources in embedded systems: hardware timers. Timer interrupts are the backbone of almost every real embedded application — from blinking an LED at an exact frequency, to sampling a sensor every 10 milliseconds, to generating audio waveforms, to driving a motor with precise PWM. Once you understand timer interrupts, a huge range of embedded problems become straightforward to solve.

The Problem With Software Timing

Before we look at hardware timers, let us understand why software timing is not good enough for most real applications.

The most common beginner approach to timing is the delay() function:

void loop() {
    doSomething();
    delay(100);   // wait 100 milliseconds
    doSomethingElse();
    delay(50);    // wait 50 milliseconds
}

This has two serious problems. First, delay() is a blocking call — while the processor is waiting, it cannot do anything else. No reading sensors, no responding to buttons, no communication. The processor is simply burning time in a loop. Second, the actual interval between executions of doSomething() is not 100 milliseconds — it is 100 milliseconds plus however long doSomething() and doSomethingElse() take to run. If those functions take variable amounts of time, your timing drifts and becomes unpredictable.

Hardware timer interrupts solve both problems completely. The timer runs independently of the CPU, counts clock cycles with crystal accuracy, and fires an interrupt at exact intervals — regardless of what the main program is doing.

How Hardware Timers Work

A hardware timer is a counter built into the microcontroller that increments (or decrements) automatically with each clock cycle, completely independently of the CPU. You configure it with a few key parameters:

  • Clock source: Usually the system clock or a divided version of it.
  • Prescaler: A divider that slows the timer’s counting rate. A prescaler of 64 means the timer increments once every 64 clock cycles.
  • Period (or compare value): The count value at which the timer fires an interrupt and resets back to zero.

The combination of prescaler and period lets you generate interrupts at virtually any frequency from very fast (microseconds) to very slow (seconds), regardless of the CPU clock speed.

The basic formula for the interrupt interval is:

Interrupt period = (Prescaler × Period) / Clock frequency

For example, on an Arduino Uno running at 16 MHz, with a prescaler of 256 and a period of 625:

Interrupt period = (256 × 625) / 16,000,000 = 0.01 seconds = every 10 milliseconds

Timer Interrupt Example: Arduino (AVR)

Arduino’s built-in delay() and millis() functions actually use Timer0 internally. For your own timer interrupts you typically use Timer1 (16-bit) or Timer2 (8-bit) to avoid conflicts. Here is how to set up Timer1 to fire every 100 milliseconds on an Arduino Uno:

volatile bool timerFired = false;

void setup() {
    Serial.begin(9600);

    // Configure Timer1 for CTC (Clear Timer on Compare) mode
    noInterrupts();              // Disable interrupts during setup

    TCCR1A = 0;                  // Clear control register A
    TCCR1B = 0;                  // Clear control register B
    TCNT1  = 0;                  // Reset timer counter

    OCR1A = 24999;               // Compare value for 100ms
                                 // (16MHz / 64 prescaler / 10Hz) - 1

    TCCR1B |= (1 << WGM12);     // CTC mode
    TCCR1B |= (1 << CS11) |
              (1 << CS10);      // Prescaler = 64

    TIMSK1 |= (1 << OCIE1A);    // Enable Timer1 compare interrupt

    interrupts();                // Re-enable interrupts
}

ISR(TIMER1_COMPA_vect) {        // Timer1 compare match ISR
    timerFired = true;
}

void loop() {
    if (timerFired) {
        timerFired = false;
        Serial.println("100ms tick!");
        // Do your timed work here
    }
}

This looks more complex than delay(100) but the payoff is significant: your main loop keeps running freely, and the 100ms tick is hardware-accurate regardless of what else is happening.

If you find the raw register configuration intimidating, the TimerOne library for Arduino wraps all of this into a cleaner API:

#include <TimerOne.h>

volatile bool timerFired = false;

void timerISR() {
    timerFired = true;
}

void setup() {
    Serial.begin(9600);
    Timer1.initialize(100000);       // Period in microseconds (100ms)
    Timer1.attachInterrupt(timerISR);
}

void loop() {
    if (timerFired) {
        timerFired = false;
        Serial.println("100ms tick!");
    }
}

Timer Interrupt Example: STM32 (HAL)

STM32 microcontrollers have multiple general-purpose timers (TIM2, TIM3, TIM4, etc.) as well as advanced timers (TIM1, TIM8) and basic timers (TIM6, TIM7). For periodic interrupts, a basic or general-purpose timer is ideal. CubeMX handles the prescaler and period calculation for you visually, then generates the init code.

// CubeMX generates MX_TIM2_Init() — here we show the key parameters
// Assuming 72 MHz system clock, we want 100ms (10 Hz) interrupt:
// Prescaler = 7199, Period = 999
// Interrupt period = (7200 * 1000) / 72,000,000 = 0.1s

volatile uint8_t timerFired = 0;

// In main(), after MX_TIM2_Init():
HAL_TIM_Base_Start_IT(&htim2);   // Start timer in interrupt mode

// HAL callback — implement this in your code
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) {
    if (htim->Instance == TIM2) {
        timerFired = 1;
    }
}

// In main loop:
while (1) {
    if (timerFired) {
        timerFired = 0;
        // Do timed work here
    }
}

STM32 HAL automatically clears the timer interrupt flag inside the IRQ handler it generates, so you do not need to do that manually in the callback. The NVIC must also have TIM2 interrupt enabled, which CubeMX configures when you enable the global interrupt for the timer in the GUI.

Timer Interrupt Example: ESP32

The ESP32 has four hardware timers (two in each timer group). The Arduino framework for ESP32 provides a clean API to configure them. Remember the IRAM_ATTR rule from Part 2 — it applies to timer ISRs as well.

hw_timer_t *myTimer = NULL;
volatile bool timerFired = false;

void IRAM_ATTR timerISR() {
    timerFired = true;
}

void setup() {
    Serial.begin(115200);

    // Timer 0, divider 80 (80MHz / 80 = 1MHz tick = 1us resolution)
    myTimer = timerBegin(0, 80, true);

    // Attach ISR
    timerAttachInterrupt(myTimer, &timerISR, true);

    // Set alarm for 100,000 ticks = 100ms, auto-reload
    timerAlarmWrite(myTimer, 100000, true);

    // Enable the alarm
    timerAlarmEnable(myTimer);
}

void loop() {
    if (timerFired) {
        timerFired = false;
        Serial.println("100ms tick on ESP32!");
    }
}

The divider of 80 brings the 80 MHz APB clock down to 1 MHz, giving 1 microsecond resolution per tick. Setting the alarm to 100,000 ticks therefore fires every 100 milliseconds. The true parameter in timerAlarmWrite() enables auto-reload so the timer repeats automatically.

Timer Interrupt Example: MSP430

The MSP430 Timer_A peripheral is extremely flexible and central to the MSP430’s low-power design. Here we configure Timer_A to fire every 1 second using the internal ACLK (auxiliary clock, typically 32,768 Hz from a watch crystal) — a common pattern in battery-powered designs.

#include <msp430.h>

volatile unsigned char secondTick = 0;

int main(void) {
    WDTCTL = WDTPW | WDTHOLD;       // Stop watchdog

    // Configure Timer_A0 with ACLK (32768 Hz), divider /1, up mode
    TA0CCR0  = 32767;                // Count to 32768 ticks = 1 second
    TA0CCTL0 = CCIE;                 // Enable compare interrupt
    TA0CTL   = TASSEL_1 |            // Clock source: ACLK
               MC_1     |            // Mode: up (count to CCR0)
               TACLR;                // Clear timer

    __bis_SR_register(GIE);          // Enable global interrupts

    while (1) {
        if (secondTick) {
            secondTick = 0;
            // Do something every second
        }
        // Optionally sleep here — wake on interrupt
        // __bis_SR_register(LPM3_bits + GIE);
    }
}

// Timer_A0 CCR0 interrupt
#pragma vector=TIMER0_A0_VECTOR
__interrupt void Timer_A0_ISR(void) {
    secondTick = 1;
    // No need to clear flag — CCR0 interrupt flag clears automatically
}

Notice the commented-out sleep line. In a real MSP430 application you would often put the processor into a low-power mode (LPM3) here, waking only when the timer interrupt fires. This is one of the most power-efficient patterns in all of embedded systems, and we will explore it fully in Part 6.

Timer Interrupt Example: Microchip PIC18 (XC8)

PIC18 has several timers. Timer0 and Timer1 are the most commonly used for periodic interrupts. Here Timer1 is configured with a 1:8 prescaler on a 4 MHz oscillator to generate an interrupt approximately every 500 milliseconds.

#include <xc.h>

// CONFIG bits omitted for brevity — set in your project configuration

volatile unsigned char timerFired = 0;

void __interrupt() high_priority Timer1_ISR(void) {
    if (TMR1IF) {
        TMR1IF = 0;             // Clear Timer1 interrupt flag — mandatory on PIC
        TMR1H = 0x0B;           // Reload high byte
        TMR1L = 0xDC;           // Reload low byte (0x0BDC = preload for ~500ms)
        timerFired = 1;
    }
}

void main(void) {
    // Timer1 setup: internal clock, 1:8 prescaler, 16-bit mode
    T1CON = 0b00110001;         // Prescaler 1:8, internal clock, timer ON

    TMR1H = 0x0B;               // Preload high byte
    TMR1L = 0xDC;               // Preload low byte

    TMR1IE = 1;                 // Enable Timer1 interrupt
    TMR1IF = 0;                 // Clear flag before enabling
    PEIE   = 1;                 // Enable peripheral interrupts
    GIE    = 1;                 // Enable global interrupts

    while (1) {
        if (timerFired) {
            timerFired = 0;
            // Do timed work here
        }
    }
}

PIC timers count up and overflow at 0xFFFF. Rather than setting a period register like STM32 or MSP430, you preload the timer with a starting value so it overflows — and triggers the interrupt — after the desired number of counts. You must reload that preload value inside the ISR each time, as shown above.

Comparing Timer Approaches Across Platforms

It is useful to see the key differences side by side:

  • Arduino AVR: Raw register configuration or TimerOne library. Timer0 is reserved by the Arduino framework for millis() and delay(), so use Timer1 or Timer2. 8-bit and 16-bit timers available.
  • STM32: Rich set of timers with CubeMX-generated init code. HAL callbacks abstract the ISR. Prescaler and auto-reload register make period calculation straightforward. Most capable timer hardware of the platforms covered here.
  • ESP32: Four 64-bit hardware timers with a clean Arduino-style API. 1 microsecond resolution easily achievable. Must use IRAM_ATTR.
  • MSP430: Timer_A is tightly integrated with the low-power subsystem. Using ACLK with a watch crystal gives extremely accurate, low-power periodic wakeups. Central to the MSP430 design philosophy.
  • PIC18: Timer preloading pattern is different from compare-register platforms. Flag must be manually cleared and preload value reloaded in every ISR call.

Practical Application: Non-Blocking LED Blink

To close this part, here is a practical demonstration of why timer interrupts matter. Below is the classic LED blink rewritten using a timer interrupt on Arduino, freeing the main loop to do other work simultaneously:

#include <TimerOne.h>

volatile bool toggleLED = false;
const int ledPin = 13;

void timerISR() {
    toggleLED = true;      // Signal main loop to toggle LED
}

void setup() {
    pinMode(ledPin, OUTPUT);
    Serial.begin(9600);
    Timer1.initialize(500000);         // 500ms interval
    Timer1.attachInterrupt(timerISR);
}

void loop() {
    if (toggleLED) {
        toggleLED = false;
        digitalWrite(ledPin, !digitalRead(ledPin));  // Toggle LED
    }

    // Main loop is completely free to do other work
    // while the LED blinks with hardware-accurate timing
    Serial.println("Main loop running freely...");
    delay(50);   // This delay does NOT affect LED timing at all
}

The LED blinks at exactly 1Hz (500ms on, 500ms off) driven by hardware, while the main loop prints a message every 50 milliseconds. The two tasks are completely independent. This is the core power of timer interrupts.

What Is Coming in Part 4

Now that you can generate precise periodic events with timer interrupts, the next important question is: what happens when multiple interrupts fire at the same time, or one fires while another ISR is running? That requires understanding interrupt priorities and nesting — which is the focus of Part 4. We will look closely at the STM32 NVIC as the most capable example, and compare how other platforms handle priority conflicts.

Previous and next posts

In Defense of Encapsulation

Last week, during a lecture on access specifiers, one of my brightest students stopped me mid-sentence. “Professor,” she said, “this is all obsolete. In the modern world we don’t need encapsulation. Look at Python — there is no private, no public, no ceremony, and it powers half the internet, all of machine learning, and most […]

Comments are closed.