Hardware Timers and Non-Blocking Delays in Embedded C

The first program most people write blinks an LED with a delay function. It works until the second job arrives: read a button, blink a second LED at a different rate, receive serial data. A blocking delay freezes the CPU, so every other job waits. The solution is to let a hardware timer keep time in the background and to write code that checks the time instead of waiting for it.

This article explains how timers count, how to calculate prescaler and reload values, how to build a millisecond tick, and how to write non-blocking delays that survive counter overflow.

How a Hardware Timer Counts

A timer is a counter register clocked from the system clock through a prescaler, which divides the clock by a chosen value. The counter increments (or decrements) on each prescaled clock. When it reaches a reload or compare value it wraps to zero, sets an update or overflow flag and can raise an interrupt. Other channels can compare the count against a value to generate PWM, or capture the count when an input pin changes to measure pulse width.

On STM32 general-purpose timers, the prescaler register PSC divides by PSC + 1 and the auto-reload register ARR gives a period of ARR + 1 counts, so:

f_update = f_timer_clock / ((PSC + 1) x (ARR + 1))

Example: f_timer_clock = 72 MHz, want a 1 Hz update event
  PSC = 7199  -> counter clock = 72 000 000 / 7200 = 10 000 Hz
  ARR = 9999  -> update rate   = 10 000 / 10 000  = 1 Hz

Example: want a 1 kHz tick (1 ms) from 72 MHz
  PSC = 71    -> counter clock = 1 MHz
  ARR = 999   -> update rate   = 1 MHz / 1000 = 1 kHz

Other families use the same idea with different register names. On the AVR ATmega328P, Timer1 in CTC mode with a prescaler N and compare value OCR1A gives f = f_clk / (N x (OCR1A + 1)). With a 16 MHz clock, N = 64 and OCR1A = 249, the compare match occurs every 1 ms. Always check whether the prescaler and period registers count from zero or one in your part's manual.

ARM Cortex-M cores also include SysTick, a 24-bit down-counter built into the core. CMSIS provides SysTick_Config(SystemCoreClock / 1000) to generate a 1 ms interrupt, and vendor libraries use it for functions like HAL_GetTick.

Why Blocking Delays Do Not Scale

ApproachCPU during delayOther jobsAccuracy
Busy loop counting to N100% wastedStoppedChanges with clock, compiler and optimisation
delay_ms() on a timer tickWasted waitingStopped (only ISRs run)Good
Non-blocking check of the tickFree for other workRun normallyGood, plus loop jitter
Timer interrupt doing the workFreeRun normallyBest, but ISR rules apply

A busy loop is also fragile because an optimising compiler may delete an empty loop entirely unless the counter is volatile.

Non-Blocking Timing with a Tick Counter

The pattern has two parts. A timer ISR increments a 32-bit counter every millisecond. The main loop remembers when an action last happened and compares the elapsed time with the interval. The program below runs on a PC: the loop advances a simulated tick by one each iteration, starting just below the 32-bit limit to prove that overflow is handled.

Two LEDs blinking at different rates without blocking, with a tick that overflows during the test
#include <stdio.h>
#include <stdint.h>
#include <stdbool.h>

/* On hardware: incremented in a 1 ms timer or SysTick ISR */
static volatile uint32_t g_ticks_ms = 0xFFFFFF00UL;  /* near overflow */

static uint32_t millis(void)
{
    return g_ticks_ms;  /* atomic on 32-bit cores; use a critical section on 8-bit */
}

typedef struct {
    uint32_t last;
    uint32_t interval;
} soft_timer_t;

static void soft_timer_start(soft_timer_t *t, uint32_t interval_ms)
{
    t->last = millis();
    t->interval = interval_ms;
}

/* Returns true once per interval. Unsigned subtraction is wrap-safe. */
static bool soft_timer_expired(soft_timer_t *t)
{
    uint32_t now = millis();
    if ((uint32_t)(now - t->last) >= t->interval) {
        t->last += t->interval;   /* keeps the period exact, no drift */
        return true;
    }
    return false;
}

int main(void)
{
    soft_timer_t led_a, led_b;
    bool a = false, b = false;

    soft_timer_start(&led_a, 100);
    soft_timer_start(&led_b, 250);

    for (int i = 0; i < 600; i++) {
        g_ticks_ms++;                         /* simulate the 1 ms ISR */

        if (soft_timer_expired(&led_a)) {
            a = !a;
            printf("t=%10lu  LED A %s\n", (unsigned long)millis(), a ? "on" : "off");
        }
        if (soft_timer_expired(&led_b)) {
            b = !b;
            printf("t=%10lu  LED B %s\n", (unsigned long)millis(), b ? "on" : "off");
        }
        /* button scanning, UART handling etc. would run here */
    }
    return 0;
}

Why the Subtraction Survives Overflow

A 32-bit millisecond counter wraps after 2^32 ms, which is about 49.7 days. Code that compares now >= last + interval breaks at the wrap, because last + interval overflows to a small number and the condition is suddenly true or stuck false. Unsigned subtraction does not have this problem: C defines unsigned arithmetic modulo 2^32, so now - last is always the true elapsed time as long as it is less than 49.7 days.

last = 0xFFFFFFF0 (4 294 967 280)
now  = 0x00000010 (16)           <- counter has wrapped
now - last = 0x00000020 = 32 ms  <- correct elapsed time

The cast matters if the tick type is ever narrower than int. On a 32-bit core, two uint16_t values are promoted to signed int before subtraction, so after a wrap the difference becomes negative unless it is cast back to the tick type. Keeping the cast protects the code against such a change.

Updating with t->last += t->interval instead of t->last = now keeps a fixed long-term rate. If the main loop is late by 3 ms one time, the next event comes 3 ms sooner, so the average period stays exact. For a user-interface timeout, where catching up makes no sense, t->last = now is the better choice.

Common Mistakes

  • Using a signed int tick that overflows into undefined behaviour after about 24.8 days at 1 ms per count (2^31 ms).
  • Comparing absolute times (now > deadline) instead of elapsed time.
  • Off-by-one in PSC or ARR, producing 1.0001 kHz instead of 1 kHz. Remember the +1 on STM32.
  • Forgetting that the timer's input clock may differ from the CPU clock. On many STM32 parts the timer clock is doubled when the APB prescaler is not 1.
  • Reading a multi-byte tick on an 8-bit AVR without disabling interrupts, giving occasional wild values.

Summary

A hardware timer divides the clock with a prescaler and counts to a reload value, giving an update rate of f_clk divided by the product of the two. Use it to drive a 1 ms tick, and write every delay as a non-blocking check of elapsed time using unsigned subtraction. Your main loop then stays free for buttons, communication and state machines, and the code keeps working after the tick wraps.