Volume 12 Beginner 5 sub-modules ~25 min read

Time: Delays, Timers and PWM

Every embedded program has to measure time, and the first way anyone learns is the worst one. A delay loop stops the whole chip. This volume replaces it with a tick counter, shows the subtraction that keeps working after the counter wraps, and ends with a state machine that does several things at once without ever waiting.

You will learn
  • Why a busy-wait delay stops everything, measured against the alternative
  • What a hardware timer does that a loop cannot, and what SysTick is for
  • The non-blocking pattern, and the wraparound bug that hides in it for 49 days
  • How PWM dims an LED, and how to choose the prescaler and period
  • How to write a state machine in C that runs several things at once
You need
  • Volume 11: interrupts, and sharing a counter with a handler
  • Volume 02: unsigned arithmetic and wraparound

12.1 Busy-wait delays and their problems

A busy-wait delay does not wait. It works, flat out, doing nothing, and nothing else in the program can run until it is finished.

Almost everybody's first embedded program contains one:


void delay_ms(uint32_t ms)
{
    volatile uint32_t spin = ms * 8000u;      /* roughly, on this chip, today */
    while (spin > 0u) {
        spin--;
    }
}

for (;;) {
    led_on();
    delay_ms(500);
    led_off();
    delay_ms(500);
}

It blinks. It is also the most expensive line of code in the file. For those 500 milliseconds the processor is fully occupied counting down, and every button, byte and sensor reading that happens meanwhile is simply not seen.

Measured, not asserted

Here is the same job done twice. Blink an LED every 500 ms, and answer a button. Four presses arrive during a two-second run.


1. blink with delay_ms(500)
   blinked at 0 500 1000 1500 ms
   noticed 0 of 4 presses
   every press began and ended inside a delay

2. blink by watching a 1 ms tick counter
   blinked at 0 501 1002 1503 ms
   noticed 4 of 4 presses, the worst 2 ms after it began
   and the loop was free the whole time to do other work

Both blinked four times. One answered nothing and the other answered everything. The second version is a few milliseconds late on each blink, because its loop takes 3 ms per pass, and no LED has ever complained about that.

Think of it like this

A busy-wait is standing at the front door for ten minutes because you are expecting a parcel. A tick counter is glancing at the clock now and then while you carry on with your day.

The other two problems

The timing is a guess. ms * 8000 was true for one compiler, one optimisation level and one clock speed. Turn on -O2, or run the chip at a different frequency, and every delay in the program changes length at once.

And it burns power. A processor spinning in a loop draws full current. A processor asleep waiting for a timer interrupt draws almost none, which on a battery is the difference between weeks and days.

When a delay really is fine

A one-off wait at start-up, where nothing else needs to happen yet. A sensor that needs 10 ms to settle after power-up, a display that needs 50 ms after reset. Those are honest uses. Inside a main loop, almost never.

Common mistake

Leaving volatile off the spin variable. Without it the compiler sees a loop with no effect and deletes the whole thing, and the delay becomes zero. Volume 10 showed that happening in assembly.

Quick check

Your loop uses delay_ms(200) and a serial byte arrives every 5 ms. What happens?

Show the answer

Answer: B. Forty bytes arrive during one delay, and the receive register holds one. Unless an interrupt is collecting them, thirty-nine are overwritten before the loop looks again.

12.2 Hardware timers and SysTick

A timer is a counter in hardware. It counts on its own, whether or not your program is paying attention, and it never drifts because the compiler changed.

Every microcontroller has several. They all share the same three parts.

Part What it does
Prescaler divides the incoming clock, to slow the counting down
Counter counts up, one step per prescaled clock
Auto-reload the value the counter wraps back to zero at

The counting is free. Nothing in your program runs to make it happen, so a timer keeps perfect time while the processor is busy elsewhere, or asleep.

Working out the numbers

One formula covers it:


tick rate  =  timer clock  /  (prescaler + 1)  /  (auto-reload + 1)

Both registers hold one less than the number you want, because the count includes zero. That off-by- one catches everybody once.


timer clock       72000000 Hz
wanted PWM rate   1000 Hz
steps of control  1000
so prescaler      71  (divides the clock by 72)
and period        999
check: 72000000 / 72 / 1000 = 1000 Hz

SysTick: the one that is always there

Arm Cortex-M cores include a small timer called SysTick, meant for exactly one job: interrupting at a steady rate so that software can keep a count of time.


volatile uint32_t ticks;             /* written by the handler, read by everyone */

void SysTick_Handler(void)
{
    ticks++;
}

/* at start-up: interrupt 1000 times a second */
SysTick_Config(SystemCoreClock / 1000u);

That handler is one line, which is exactly what Volume 11 asked for. Everything else reads ticks and works out the rest.

Remember

The tick counter is the one piece of shared state nearly every embedded program has. It is volatile because a handler writes it. On a 32-bit chip a uint32_t read cannot be torn, and only the handler writes it, so reading it needs no protection at all.

Why not just read the timer's counter register

You can, and for short intervals it is better - a hardware counter gives you microseconds rather than milliseconds. Two things to watch. The register is usually 16 bits on small timers, so it wraps every few milliseconds and can only measure short gaps. And it is a peripheral register, so it must be volatile and read once into a local variable.

The usual arrangement is both: SysTick for everything measured in milliseconds, and a free-running hardware counter when you need to time something in microseconds.

Quick check

A timer clock is 48 MHz. You want an interrupt every millisecond, using a period of 1000. What prescaler value goes in the register?

Show the answer

Answer: A. 48 MHz divided by 1000 Hz divided by 1000 counts needs a divide of 48. The register holds one less than the divide, so it gets 47. The period register gets 999 for the same reason.

12.3 Non-blocking timing with ticks

Do not wait for the time to pass. Let it pass, and check whether it has.

The pattern replaces every delay in a main loop, and it is four lines.


static uint32_t last_blink;

for (;;) {
    uint32_t now = ticks;

    if ((uint32_t)(now - last_blink) >= 500u) {
        led_toggle();
        last_blink = now;
    }

    /* anything else you like, every pass */
    check_button();
    update_display();
}

Each job keeps its own last variable, so a loop can run a dozen of them at different rates without any of them knowing about the others.

The subtraction is not a style choice

This is the most important line in the volume, so it is worth being certain about. These two look equivalent:


if ((uint32_t)(now - last) >= interval) {
    /* ... */
}

if (now >= last + interval) {
    /* ... */
}

They agree for about seven weeks. Then the tick counter passes 0xFFFFFFFF, wraps to zero, and they stop agreeing.


3. the counter has just wrapped past zero
  long enough            last=0xFFFFFF80 now=0x00000020 gap=160
                         subtracting says DUE       adding says not yet   <-- they disagree

  the gap really is 160 ms, so the interval has passed
  adding computes last + interval = 0xFFFFFFE4
  now has wrapped back to a small number, and will not climb
  back up to that value for another 49 days

The interval has passed. The subtracting version says so. The adding version compares a small number against a huge one and decides it is not time yet. It goes on deciding that until the counter climbs all the way round again.

49.7 days

At a 1 kHz tick, a uint32_t wraps after 4294967296 ms, which is 49.7 days. That is long enough that the bug never appears on your desk, in testing, or during the demo. It appears in a device that has been running since it was installed.

And it is worse than a bug that always fails, because sometimes it works:


4. the same bug, hiding: this time the sum wraps as well
  long enough            last=0xFFFFFFE0 now=0x00000050 gap=112
                         subtracting says DUE       adding says DUE
  here last + interval = 0x00000044, which wrapped too, so adding
  happens to give the right answer
Why subtracting works

Unsigned arithmetic in C is modular: every result is taken modulo 2^32, wrapping silently, and the standard guarantees it. So now - last is not "a possibly negative number" - it is the true gap, already wrapped back into range.

If last is 0xFFFFFF80 and now is 0x20, then now - last is computed as 0x20 - 0xFFFFFF80 + 2^32, which is 0xA0, or 160. The two wraps cancel.

The only rule is that the gap you are measuring must be shorter than half the counter's range, so that "160 ms ago" cannot be confused with "49 days ago". For millisecond ticks and intervals of seconds, that is never in question.

Common mistake

Storing the next due time instead of the last done time. next_due = now + interval has the same overflow in it, and the same 49-day fuse. Store when it last happened, and subtract.

Quick check

Which is safe against the tick counter wrapping?

Show the answer

Answer: C. Only the unsigned subtraction. Casting to signed makes the overflow undefined behaviour rather than wrapping, and both of the others compute last + interval, which is the value that overflows.

12.4 PWM: dimming an LED

PWM makes an LED half bright by turning it fully on and fully off, faster than the eye can follow.

A digital pin has two states, on and off. There is no half. But if it is on for half of every thousandth of a second, the eye averages it, and the LED looks half lit.

The hardware does it with a counter and one more register. The counter runs up to the period and starts again. The pin is high while the counter is below the compare value.

A PWM counter ramping past a compare value, and the pin it drives period 0 pin high pin low compare 60% on 40% off one cycle repeats, thousands of times a second
Figure 12.1 - The counter ramps from zero to the period and starts again. While it is below the compare line the pin is high, and above it the pin is low. Moving the compare line up or down is the only thing that changes, and the hardware does all of this with no software running at all.

Printed one count at a time, with a period of twenty, it is unmistakable:


compare =  0   duty   0%   |....................|
compare =  5   duty  25%   |#####...............|
compare = 10   duty  50%   |##########..........|
compare = 15   duty  75%   |###############.....|
compare = 20   duty 100%   |####################|

The duty cycle is compare / period, and that really is all it is. Your program writes one register to change brightness, and the hardware does the switching forever afterwards.

Choosing the frequency

Two numbers to respect:

  1. Above about 100 Hz an LED stops flickering to the eye. Above 1 kHz it stops flickering on camera too
  2. Above about 20 kHz a motor or buzzer stops whining, because the switching is out of hearing

Then pick the period for the number of brightness steps you want. A period of 1000 gives you 1000 steps, which is far more than anyone can see. A period of 255 gives 256 steps and fits a byte.

Equal steps do not look equal

Set an LED to 10% and then 20%, and the jump looks enormous. Go from 80% to 90% and it looks like nothing happened. The eye judges brightness by ratio.


level   duty, in equal steps   duty, gamma corrected
 0/7            0.0%                   0.0%
 1/7           14.3%                   1.4%
 2/7           28.6%                   6.4%
 3/7           42.9%                  15.5%
 4/7           57.1%                  29.2%
 5/7           71.4%                  47.7%
 6/7           85.7%                  71.2%
 7/7          100.0%                 100.0%

The fix is gamma correction: put the corrected values in a const lookup table and index it with the level you want. Volume 09 showed exactly such a table sitting in .rodata, costing flash and no RAM at all.

Common mistake

Writing the compare register at any old moment. If it changes half way through a cycle, that cycle comes out the wrong length and the LED flickers or the motor jerks. Timers have a preload or shadow register for this: the new value is accepted, and the hardware applies it at the start of the next cycle.

Quick check

An LED is driven at 50% duty with a period of 1000 and a PWM frequency of 1 kHz. You double the period to 2000 and leave the compare at 500. What changes?

Show the answer

Answer: B. Duty is compare over period, so 500/2000 is 25% instead of 50%. And the counter now takes twice as long to go round, so the frequency halves. Changing the period changes both.

12.5 State machines in C

A state machine is how one loop does several timed things at once without ever waiting for any of them.

Once delays are gone, the question becomes how to express something that has stages. A pedestrian crossing holds each colour for a set time and reacts to a button. Written with delays it would block for twenty seconds at a stretch. Written as a state machine it blocks for nothing.

The shape in C is an enum of states, a variable holding the current one, and a switch.


typedef enum {
    TRAFFIC_GREEN, TRAFFIC_AMBER, TRAFFIC_RED, TRAFFIC_RED_AMBER
} light_state_t;

static light_state_t state      = TRAFFIC_GREEN;
static uint32_t      entered_at = 0u;

static uint32_t held_for(uint32_t now)
{
    return (uint32_t)(now - entered_at);      /* the same safe subtraction */
}

static void crossing_step(uint32_t now)       /* one pass, never waits */
{
    switch (state) {
    case TRAFFIC_GREEN:
        if (wants_cross && held_for(now) >= MIN_GREEN_MS) {
            enter(TRAFFIC_AMBER, now);
        }
        break;

    case TRAFFIC_AMBER:
        if (held_for(now) >= AMBER_MS) {
            wants_cross = false;
            enter(TRAFFIC_RED, now);
        }
        break;

    case TRAFFIC_RED:
        if (held_for(now) >= RED_MS) {
            enter(TRAFFIC_RED_AMBER, now);
        }
        break;

    case TRAFFIC_RED_AMBER:
        if (held_for(now) >= RED_AMBER_MS) {
            enter(TRAFFIC_GREEN, now);
        }
        break;

    default:
        enter(TRAFFIC_RED, now);              /* an impossible state is a safe one */
        break;
    }
}

enter records the new state and the moment it started, so every held_for is measured from the right instant.

State diagram of a pedestrian crossing with four light states GREEN AMBER RED RED+AMBER button, and 5 s after 3 s after 8 s after 2 s no request reset
Figure 12.2 - The same machine the switch statement above describes. Each arrow is one if statement, and every condition is a time that has passed - with the button also needed to leave GREEN. The gold arrow marks the state it starts in.

Run it against a simulated clock and the sequence falls out:


     0 ms   start in GREEN
  2000 ms   button pressed
  5000 ms   GREEN     -> AMBER
  8000 ms   AMBER     -> RED
 16000 ms   RED       -> RED+AMBER
 18000 ms   RED+AMBER -> GREEN
 20000 ms   button pressed
 23000 ms   GREEN     -> AMBER

the loop ran 341 times and never once waited

The button was pressed at 2000 ms and the lights did not change until 5000 ms, because traffic is promised five seconds of green. The machine remembered the request in the meantime. That is the thing a state machine gives you that a sequence of delays cannot.

This is a whole course on its own

State machines are the backbone of firmware, and there is far more to them: Moore and Mealy, encoding, minimisation, and how to make one safe against impossible states. The State Machines from Zero course covers all of it from the beginning, in hardware and in C.

Habits worth copying

Common mistake

Putting a delay_ms inside one of the cases, just for a moment, to get something working. The whole machine stops during it, including every other job in the loop. If a state needs to pause, that is what the timestamp is for: it is already a state with a duration.

Quick check

Why does crossing_step record entered_at every time the state changes?

Show the answer

Answer: C. Each state's exit condition is an elapsed time. Storing the moment the state began turns "wait three seconds" into "has it been three seconds", which needs no waiting at all.

What you learned

Practice

Practice 1

Rewrite this so it does not block, and so a button can be read at the same time.

for (;;) { led_on(); delay_ms(100); led_off(); delay_ms(900); }

Show the solution

The LED is on for 100 ms out of every 1000. Keep that as two states, or as one comparison against the position within the second.


static uint32_t cycle_start;

for (;;) {
    uint32_t now = ticks;
    uint32_t into_cycle = (uint32_t)(now - cycle_start);

    if (into_cycle >= 1000u) {
        cycle_start = now;
        into_cycle = 0u;
    }
    led_set(into_cycle < 100u);

    check_button();          /* now runs a few thousand times a second */
}

Note cycle_start = now rather than cycle_start += 1000. Both are defensible: adding keeps the average period exact even if a pass is late, while assigning cannot run away if the loop is badly delayed. For a blinking LED it does not matter; for a control loop, adding is usually right.

Practice 2

A timer clock is 16 MHz. You want a PWM frequency of 20 kHz with 400 steps of control. Work out the prescaler and period register values, and check the result.

Show the solution

The counter must tick 400 times per PWM cycle, 20000 times a second, so the timer needs 20000 × 400 = 8000000 counts per second.

16000000 / 8000000 = 2, so the clock is divided by 2, and the prescaler register holds 1.

The period register holds 400 - 1 = 399.

Check: 16000000 / 2 / 400 = 20000 Hz.

20 kHz is above hearing, which is why this is the usual choice for a motor. 400 steps is plenty: you cannot hear or see the difference between 0.25 per cent apart.

Practice 3

This has been running fine for six weeks and then stops reacting. Find the bug.

if (ticks > last_sample + SAMPLE_MS) { take_sample(); last_sample = ticks; }

Show the solution

Two bugs, and the second is the one that waited six weeks.

last_sample + SAMPLE_MS overflows when last_sample is near the top of a uint32_t. After the counter wraps, ticks is a small number and the sum is a huge one, so the condition is false and stays false for another 49 days.

Six weeks is 42 days, which is suspiciously close to 49.7 - almost certainly the first wrap.


if ((uint32_t)(ticks - last_sample) >= SAMPLE_MS) {
    take_sample();
    last_sample = ticks;
}

The smaller bug: > rather than >= makes every interval one tick longer than asked for. Harmless here, and worth fixing anyway.

Also read ticks once into a local. It is written by an interrupt, so the two reads in the original can return different values.

Practice 4

A design needs an LED that fades smoothly from off to full over two seconds and back, while a serial port keeps receiving. Sketch the structure. What runs where?

Show the solution

Three pieces, none of them waiting.

The hardware timer produces the PWM. Set it up once at start-up, above 1 kHz, with a period giving as many steps as the table has. After that it needs no attention.

An interrupt collects serial bytes into a ring buffer, as Volume 11 built.

The main loop updates the fade. Every 10 ms or so it moves to the next brightness level and writes the gamma-corrected value from the table into the compare register. Four seconds there and back at 10 ms per step is 400 steps, so a 200-entry table used forwards then backwards fits neatly.


if ((uint32_t)(now - last_step) >= 10u) {
    last_step = now;
    level += direction;
    if (level == 0u || level == LEVELS - 1u) {
        direction = -direction;
    }
    TIM3->CCR1 = gamma_table[level];
}

Nothing in this waits, so the serial data keeps flowing and the loop stays free.

Practice 5

Add a state to the crossing so that the button is ignored for ten seconds after a crossing finishes, to stop somebody holding up traffic repeatedly. Where does it go?

Show the solution

It does not need a new state. It needs one more condition on the transition out of GREEN, and the timestamp is already there.


case TRAFFIC_GREEN:
    if (wants_cross && held_for(now) >= MIN_GREEN_MS) {
        enter(TRAFFIC_AMBER, now);
    }
    break;

Raising MIN_GREEN_MS from 5 to 15 seconds does exactly what was asked, because GREEN is entered the moment the previous crossing ends, and held_for is measured from then.

Adding a separate state would also work and is worth considering if the behaviour needs to differ in other ways - a different light pattern, or a "wait" sign for pedestrians. If the only difference is a duration, prefer the extra condition. Every state you add doubles the number of interactions somebody has to reason about.

Interview corner

Interview question 1

The wraparound question

"Why is if (now - last >= interval) preferred over if (now >= last + interval)?"

Show the solution

"Because the second one breaks when the tick counter wraps. Unsigned arithmetic in C is modular, so now - last gives the true elapsed time even across a wrap - the two wraps cancel. But last + interval can overflow past the top of the range. A freshly wrapped now is then a small number compared against a huge one, so it reports not yet and keeps doing so.

At a 1 kHz tick a uint32_t wraps after 49.7 days, so this is a bug that survives every test and then appears in an installed device. The rule I follow is never to add to a timestamp, only ever to subtract two of them."

Interview question 2

Delays

"When is it acceptable to use a blocking delay?"

Show the solution

"During start-up, before the system has anything else to do - waiting for a sensor or display to settle after power-up. And sometimes inside a driver for a hardware requirement measured in microseconds, where the alternative costs more than it saves.

Inside a main loop, essentially never, because everything else stops. I would also make sure the delay variable is volatile, or the compiler will delete the loop. And I would not let the timing depend on the optimisation level, which means using a timer rather than a counted loop wherever accuracy matters."

Interview question 3

PWM

"How would you dim an LED from a microcontroller with only digital pins?"

Show the solution

"PWM. Set a timer running with a period and a compare value, and the pin is high while the counter is below the compare. The duty cycle is compare over period, and the eye averages it, so it looks like a brightness.

I would pick the frequency above about 1 kHz so there is no flicker, even on camera. Then I would choose the period for the number of steps I want, and 256 or 1000 are both common. The part people miss is that equal steps of duty do not look equal, because perception is roughly a power law. So I would put gamma-corrected values in a const table in flash, and index it with the level. I would also write the compare register through the timer's preload, so the change takes effect at a cycle boundary rather than glitching mid-cycle."

Interview question 4

Structure

"How do you make one main loop do several timed things at once?"

Show the solution

"A tick counter and one last timestamp per job. Each pass of the loop checks each job with a subtraction and does the work if its interval has elapsed. Nothing blocks, so all of them make progress and adding another costs four lines.

For anything with stages rather than a fixed period, I use a state machine. An enum, a current state, the time the state was entered, and a switch that does at most one transition per pass. That keeps the timing logic and the sequencing in one readable place, and it keeps the loop free for everything else. Work that genuinely cannot wait goes in an interrupt instead, and hands data to the loop through a queue."

Next, Volume 13 puts all of this together. Writing a peripheral driver properly: a clean header, a hardware layer you can swap out, error handling that does not lie, and a driver for a real sensor over I2C.

Key words from this volume

Every word below has a plain-English entry in the glossary.