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.
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;
}
}
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
starton 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 mstick gives1 msaccuracy for all actions. - Letting one slow service function starve every other service.
Practical Checks
- Unit-test
elapsed()around wrap values such as0xFFFFFFF0. - 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
- ARM CMSIS SysTick documentation
- Digi-Key Maker: State machines in embedded C
- Embedded Artistry: Patterns for non-blocking embedded systems