Loading header...

Timers and Non-Blocking State Machines

Delay loops are easy to understand, but they make firmware blind while time passes. During delay_ms(500), the program may miss button edges, receive-buffer overflow warnings, sensor deadlines, watchdog service windows, or low-power opportunities. Hardware timers and non-blocking state machines solve this by separating time measurement from behavior.

The main rule is simple: each service function checks whether something is due, performs a small amount of work, and returns quickly.

Learning Objectives

By the end of this lesson, you should be able to:

  • explain why blocking delays reduce responsiveness;
  • create a millisecond time base from a hardware timer;
  • write wraparound-safe elapsed-time checks using unsigned arithmetic;
  • model firmware behavior as a non-blocking state machine;
  • debug timing errors with GPIO pulses, logs, and controlled test inputs.

Blocking Delay Problem

led_on();
delay_ms(500);
led_off();
delay_ms(500);

This blink example works only because blinking is the only task. Add UART command input, button debounce, sensor sampling, communication timeouts, and watchdog service, and the blocking delay becomes a system-level bug.

flowchart LR DELAY["delay_ms running"] --> MISSED["other work waits"] MISSED --> RX["UART buffer fills"] MISSED --> BTN["button edge missed"] MISSED --> WDT["watchdog service late"]

Hardware Timer Time Base

A common pattern is a periodic interrupt that increments a tick counter.

#include <stdbool.h>
#include <stdint.h>

static volatile uint32_t system_ms;

void SysTick_Handler(void)
{
    system_ms++;
}

uint32_t millis(void)
{
    uint32_t snapshot;

    disable_interrupts();
    snapshot = system_ms;
    enable_interrupts();

    return snapshot;
}

The ISR owns the increment. Main code takes a short atomic snapshot. On many 32-bit MCUs an aligned 32-bit read is already atomic, but the critical-section version is portable and makes the intent clear for learners.

Choosing Tick Rate

The tick rate must fit the fastest timing decision the firmware needs to make. A 1 ms tick is common for user interfaces and slow control logic; motor control or power electronics may need hardware compare units, capture units, DMA, PWM interrupts, or a faster control-loop timer.

Requirement Typical timing method
LED blink, button debounce 1 ms system tick
UART receive timeout 1 ms or peripheral idle interrupt
servo pulse generation timer compare or PWM hardware
motor current loop dedicated high-rate control timer
timestamp input edge timer capture hardware

Wraparound-Safe Elapsed Time

Unsigned arithmetic lets a counter wrap naturally.

static bool elapsed(uint32_t now, uint32_t start, uint32_t interval)
{
    return (uint32_t)(now - start) >= interval;
}

For an N-bit counter:

$$
T_\text{wrap}=\frac{2^N}{f_\text{tick}}
$$

For a 32-bit millisecond tick:

$$
T_\text{wrap}=\frac{2^{32}}{1000}=4,294,967.296\ \text{s}=49.7\ \text{days}
$$

Keep each interval less than half the counter range. With a 32-bit 1 ms tick, that half-range is about 24.8 days.

Non-Blocking LED State Machine

typedef enum {
    LED_STATE_OFF = 0,
    LED_STATE_ON
} led_state_t;

typedef struct {
    led_state_t state;
    uint32_t last_change_ms;
    uint32_t off_time_ms;
    uint32_t on_time_ms;
} led_blinker_t;

void led_blinker_update(led_blinker_t *b, uint32_t now)
{
    switch (b->state) {
    case LED_STATE_OFF:
        if (elapsed(now, b->last_change_ms, b->off_time_ms)) {
            led_on();
            b->state = LED_STATE_ON;
            b->last_change_ms = now;
        }
        break;

    case LED_STATE_ON:
        if (elapsed(now, b->last_change_ms, b->on_time_ms)) {
            led_off();
            b->state = LED_STATE_OFF;
            b->last_change_ms = now;
        }
        break;

    default:
        led_off();
        b->state = LED_STATE_OFF;
        b->last_change_ms = now;
        break;
    }
}
stateDiagram-v2 [*] --> OFF OFF --> ON: off_time elapsed ON --> OFF: on_time elapsed OFF --> OFF: not due ON --> ON: not due

The function does not wait. It either changes state or returns immediately.

Main Loop Scheduler Pattern

int main(void)
{
    led_blinker_t status_led = {
        .state = LED_STATE_OFF,
        .last_change_ms = 0u,
        .off_time_ms = 500u,
        .on_time_ms = 500u,
    };

    for (;;) {
        uint32_t now = millis();

        led_blinker_update(&status_led, now);
        button_service(now);
        uart_service(now);
        sensor_service(now);
        watchdog_service();

        enter_sleep_until_interrupt();
    }
}

This is not an RTOS. It is a cooperative loop. Every service function must finish quickly enough that the loop period stays within the application's latency budget.

Worked Example: Button Debounce

A mechanical button can bounce for 5 ms to 20 ms. A non-blocking debounce state machine waits for a stable input without freezing the rest of the firmware.

typedef struct {
    bool stable_level;
    bool candidate_level;
    uint32_t changed_at_ms;
} debounce_t;

bool debounce_update(debounce_t *d, bool raw_level, uint32_t now)
{
    if (raw_level != d->candidate_level) {
        d->candidate_level = raw_level;
        d->changed_at_ms = now;
    }

    if ((raw_level != d->stable_level) &&
        elapsed(now, d->changed_at_ms, 20u)) {
        d->stable_level = raw_level;
        return true;
    }

    return false;
}

The return value reports a confirmed edge. The main loop can use that event to change modes, send a message, or start another state machine.

Timing Accuracy and Jitter

Software checks run after the main loop returns to them, so they have jitter. If a service function runs every 2 ms, a 100 ms timeout may fire at 100 ms to 102 ms. That is fine for human-interface timing and many communication timeouts, but not for precision waveform generation.

Use hardware compare or PWM when edge timing must be exact. Use the non-blocking loop to configure hardware and handle results.

Common Mistakes

  • Resetting start on every loop iteration, so the interval never expires.
  • Using signed arithmetic for a wrapping tick counter.
  • Doing too much work in the timer ISR.
  • Mixing delay_ms() into a cooperative loop.
  • Assuming a 1 ms tick gives 1 ms accuracy for all actions.
  • Letting one slow service function starve every other service.

Practical Checks

  • Unit-test elapsed() around wrap values such as 0xFFFFFFF0.
  • Log or scope the main-loop period under worst-case load.
  • Toggle a GPIO before and after each high-rate service function.
  • Check that watchdog service still happens when UART input is busy.
  • Confirm the clock source and timer prescaler against the datasheet.

Summary

Timers measure time; state machines decide behavior. Replacing blocking delays with elapsed-time checks makes firmware responsive, testable, and scalable. Use unsigned wraparound arithmetic, keep timer ISRs short, and reserve hardware compare/PWM features for timing that software polling cannot guarantee.

Further Reading

Mind Map

mindmap root((Timers and FSMs)) Core idea Measure time Never wait blindly Service then return Time base Hardware timer ISR Volatile tick counter Atomic snapshot Tick rate fits need Formulas T wrap equals 2 power N over f tick 32 bit 1 ms equals 49.7 days Elapsed equals now minus start Half range interval limit State machines Explicit states Due time per state Fast update function Default recovery state Applications LED blink Button debounce UART timeout Sensor sampling Watchdog service Practical checks Test wraparound Scope loop period Measure jitter Check prescaler Common mistakes Resetting start time Signed ticks Delay in loop Slow timer ISR