Module 3 - Digital I/O & Timing

11. Non-Blocking Timing & State Machines

Run several timed tasks together without delay(), debounce a button alongside them, and sequence timed steps with a state machine.

Estimated time 2 sessions + optional extrasIn class Week 4 · Sessions 16–17

Learning outcomes

  • Explain why delay blocks other work in loop
  • Apply the millis pattern: remember, compare, act, stamp
  • Run independent timers for two LEDs at different rates
  • Debounce a button with millis without calling delay
  • Sequence timed steps with a state variable (a state machine)
  • Use unsigned long for every millis timestamp

Parts and preparation

Uno, two LEDs with 330 ohm resistors, one push-button and jumpers. For the state machine example: red, amber and green LEDs with 330 ohm resistors.

Before power: inspect wiring, confirm supply voltage and ensure all connected circuits share GND.

Why delay blocks

delay(ms) freezes the whole sketch until the wait ends. During that freeze, loop cannot read buttons, update a second LED, print Serial, or service sensors.

millis() returns milliseconds since the board started and keeps counting while your code runs. Instead of sleeping, you check whether enough time has passed since the last action for each task.

Two timelines blinking an LED every 700 ms: with delay(700) the sketch is stuck waiting and a button press is missed, with millis() loop() keeps running, toggles the LED when 700 ms have passed and sees the press almost at once
delay stops everything. millis lets many tasks share one loop.

The millis pattern

For each timed task: store the last time you acted, compare against the interval, act when due, then update the stamp.

Pseudocode: if (now - lastRun >= interval) { lastRun = now; doWork(); }

Code Along writes lastRun += interval; instead of lastRun = now;. Both work - += keeps the ticks exactly one interval apart even when loop() runs a little late.

Three steps: remember lastRun, compare now minus lastRun to interval, then act and stamp
Remember, compare, act, stamp. Skip when the interval is not due yet.
unsigned long now = millis();
if (now - lastFast >= 200) {
  lastFast = now;
  fastState = !fastState;
  digitalWrite(ledFast, fastState);
}

Independent timers

Each task needs its own lastRun variable and its own interval. The worked sketch blinks D5 every 200 ms and D6 every 700 ms while still watching a button.

Tasks share loop but do not wait for each other. If one LED rate looks wrong, check that task's interval and stamp - not the other task.

loop() reads unsigned long now = millis() once, then three tasks check their own timers: fast LED D5 with lastFast and 200 ms, slow LED D6 with lastSlow and 700 ms, and the button D2 with lastDebounce and 40 ms
Course build: fast LED, slow LED, and non-blocking debounce together.

Shared timeline

Imagine millis as a shared clock. Fast toggles happen often, slow toggles less often, and the button is checked on every loop pass. Empty gaps are not delay - loop is still spinning and checking.

Timeline from 0 to 1400 ms: the fast LED toggles every 200 ms, the slow LED every 700 ms, a bouncing button press is counted after 40 ms of quiet, and loop() passes run all the time; at 500 ms nothing is due so loop() moves on
Dots are actions. Between dots, loop is still running.
Optional extension - skip on your first pass through this lesson.

Debounce without delay

Mechanical buttons bounce. Lesson 10 used a millis debounce; here it sits beside the LED timers.

When the raw reading changes, restart lastDebounce. Only after about 40 ms of quiet, and only if the reading differs from the last stable state, accept the new edge. On a stable press to GND (LOW with INPUT_PULLUP), increment the press count.

A bouncing raw reading restarts the 40 ms stopwatch on every change until it stays steady for 40 ms and the press is counted, shown as three code steps: reading changed so restart lastDebounce, quiet for 40 ms, then accept and count
Restart the quiet timer on every raw change. Never delay while waiting.

State machines: one variable, many steps

A state machine stores which step a behaviour is in, in one variable. Each pass of loop() asks two questions: which state am I in, and is it time to leave it? Each state can last a different time and switch different outputs, which a plain blink cannot do.

Write the states as a table first - state, outputs, how long, next state - then turn each row into one branch. Record stateStart whenever the state changes, so elapsed time is measured from the start of the current step. The same shape runs alarm sequences, motor start-up and the practical assessment tasks.

StateOutputsLastsNext state
REDRed on4 sGREEN
GREENGreen on3 sAMBER
AMBERAmber on1 sRED
unsigned long elapsed = millis() - stateStart;

if (state == RED && elapsed >= 4000) {
  state = GREEN;
  stateStart = millis();
  showLights(false, false, true);
} else if (state == GREEN && elapsed >= 3000) {
  state = AMBER;
  stateStart = millis();
  showLights(false, true, false);
} else if (state == AMBER && elapsed >= 1000) {
  state = RED;
  stateStart = millis();
  showLights(true, false, false);
}
Optional extension - skip on your first pass through this lesson.

unsigned long and overflow

millis() returns unsigned long. Store every timestamp as unsigned long too. Using int will break as time grows.

After about 49 days millis rolls over to zero. The subtraction now - lastRun still works correctly with unsigned long arithmetic, so do not rewrite the pattern with signed types or absolute millis comparisons.

int holds up to 32,767 so it is full after about 33 seconds, unsigned long lasts about 49.7 days; after the roll-over to 0, now - lastFast still gives the right gap, like 11 to 1 o'clock being 2 hours
Match the type millis returns: unsigned long.
ItemCourse practice
Clockunsigned long now = millis();
Each task stampits own unsigned long lastX
Comparenow - lastX >= interval
UpdatelastX = now after the action

Wiring and safe build sequence

  1. D5 -> 330 ohm -> LED1 -> GND
  2. D6 -> 330 ohm -> LED2 -> GND
  3. Button from D2 to GND; use INPUT_PULLUP
Power rule: switch off before moving wires. Arduino I/O pins are control signals; high-current loads require a driver and suitable external supply.

Worked sketch

Download .ino
const byte ledFast = 5;
const byte ledSlow = 6;
const byte buttonPin = 2;

unsigned long lastFast = 0;
unsigned long lastSlow = 0;
unsigned long lastDebounce = 0;
bool fastState = false;
bool slowState = false;
bool lastStable = HIGH;
bool lastReading = HIGH;
unsigned int pressCount = 0;

void setup() {
  pinMode(ledFast, OUTPUT);
  pinMode(ledSlow, OUTPUT);
  pinMode(buttonPin, INPUT_PULLUP);
  Serial.begin(9600);
}

void loop() {
  unsigned long now = millis();

  if (now - lastFast >= 200) {
    lastFast = now;
    fastState = !fastState;
    digitalWrite(ledFast, fastState);
  }

  if (now - lastSlow >= 700) {
    lastSlow = now;
    slowState = !slowState;
    digitalWrite(ledSlow, slowState);
  }

  bool reading = digitalRead(buttonPin);
  if (reading != lastReading) {
    lastDebounce = now;
    lastReading = reading;
  }
  if ((now - lastDebounce) >= 40 && reading != lastStable) {
    lastStable = reading;
    if (lastStable == LOW) {
      pressCount++;
      Serial.print("Presses: ");
      Serial.println(pressCount);
    }
  }
}

How the code works

  1. Each LED has its own timer so the rates stay independent.
  2. The debounce window is 40 ms and does not call delay.
  3. Use unsigned long for every millis timestamp.
  4. INPUT_PULLUP: open reads HIGH, pressed to GND reads LOW.

Example 2: traffic light state machine

Download .ino

What this sketch is for: Red, green and amber LEDs step through a sequence with a different time for each state. loop() never waits, so a button or sensor could be added without changing the timing.

const byte redPin = 8;
const byte amberPin = 9;
const byte greenPin = 10;

const byte RED = 0;
const byte GREEN = 1;
const byte AMBER = 2;

byte state = RED;
unsigned long stateStart = 0;

void showLights(bool red, bool amber, bool green) {
  digitalWrite(redPin, red);
  digitalWrite(amberPin, amber);
  digitalWrite(greenPin, green);
}

void setup() {
  pinMode(redPin, OUTPUT);
  pinMode(amberPin, OUTPUT);
  pinMode(greenPin, OUTPUT);
  Serial.begin(9600);
  showLights(true, false, false);
  Serial.println("RED");
}

void loop() {
  unsigned long elapsed = millis() - stateStart;

  if (state == RED && elapsed >= 4000) {
    state = GREEN;
    stateStart = millis();
    showLights(false, false, true);
    Serial.println("GREEN");
  } else if (state == GREEN && elapsed >= 3000) {
    state = AMBER;
    stateStart = millis();
    showLights(false, true, false);
    Serial.println("AMBER");
  } else if (state == AMBER && elapsed >= 1000) {
    state = RED;
    stateStart = millis();
    showLights(true, false, false);
    Serial.println("RED");
  }
}

How the code works

  1. Named constants RED, GREEN and AMBER make the state variable readable.
  2. Each branch checks its own state and its own duration, then records stateStart for the next step.
  3. showLights() sets all three outputs at once, so no state can leave a stray LED on.
  4. Wiring: D8, D9 and D10 each through 330 ohm to the red, amber and green LED anodes; cathodes to GND.

Test and record evidence

Expected result: LED1 blinks faster than LED2 while button presses still print on Serial. The traffic light example prints RED, GREEN, AMBER in a 4 s / 3 s / 1 s cycle.

Practical evidence checklist

Common faults and checks
  • If both LEDs freeze when the button is held, a delay was introduced somewhere.
  • Missed presses usually mean the debounce time is too long or the button wiring is wrong.
  • Wrong blink rate: check that task's interval and that lastX is updated only when the action runs.
  • Confirm LED polarity and 330 ohm series resistors on D5 and D6.
  • Button: pinMode INPUT_PULLUP and switch to GND only.
  • State machine skips a state or races through: stateStart is not being updated when the state changes.
Challenge (extension - optional): Add a pedestrian button to the traffic light: a press during GREEN shortens the remaining green time to 2 s, without ever calling delay().

Check your understanding

Q1. Why avoid long delay calls?

Show answer

They stop loop from servicing other tasks.

Q2. Why use unsigned long with millis?

Show answer

millis returns unsigned long; correct overflow math needs matching types.

Q3. What does each timed task need?

Show answer

Its own lastRun stamp and its own interval.

Q4. When do you update lastFast?

Show answer

After the fast LED action runs, set lastFast = now.

Q5. What does the state variable in a state machine store?

Show answer

Which step of the sequence is running now.

Code Along 8: Time Without delay()

Practise in your browser

Write and test this lesson's code on a virtual Uno. Work through the 6 exercises in order. Press Test and each one checks itself.

Exercises passed 0 / 6About 90-120 minOpen as its own page

Why delay() gets in the way

delay(500) stops the whole sketch for half a second. While it waits the Uno cannot read a button, update a display or blink a second LED. With one LED that is fine; with two tasks it breaks.

The fix is to check the clock instead of waiting. millis() returns how many milliseconds have passed since the board started. Each task remembers when it last ran and acts again when enough time has passed:

  • if (millis() - lastRun >= interval) { lastRun += interval; ... }

loop() then runs thousands of times a second, and each task only does its work when it is due.

The lesson writes lastRun = now; instead of lastRun += interval;. Both work: += keeps the ticks exactly interval apart even if loop() is a little late, which is why this chapter uses it.

One millis clock shared by three independent tasks
One shared clock, independent tasks.
Exercise 8.2

Heartbeat message

Not started

Print alive to the Serial Monitor once every second, using the same millis() pattern - no delay(). A heartbeat like this tells you a long-running sketch has not frozen.

Sample output
alive
alive
alive
unsigned long lastBeat = 0;

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

void loop() {
  // Every 1000 ms: add 1000 to lastBeat and print alive

}

Turn on JavaScript to edit, run and test this code in the browser.

Exercise 8.3

Two LEDs, two speeds

Not started

The red LED on pin 9 already blinks every 300 ms. Add a second, independent timer so the green LED on pin 10 blinks every 700 ms. With delay() this is almost impossible; with two millis() timers it is straightforward.

const int LED_A = 9;    // red, 300 ms
const int LED_B = 10;   // green, 700 ms
unsigned long lastA = 0;
bool stateA = false;

void setup() {
  pinMode(LED_A, OUTPUT);
  pinMode(LED_B, OUTPUT);
}

void loop() {
  if (millis() - lastA >= 300) {
    lastA += 300;
    stateA = !stateA;
    digitalWrite(LED_A, stateA);
  }

  // Now the same again for LED_B, every 700 ms

}

Turn on JavaScript to edit, run and test this code in the browser.

Exercise 8.4

A button that answers instantly

Not started

This sketch blinks the onboard LED and lights the green LED on pin 8 while the button on pin 2 is held. Press Run and hold the button: the green LED reacts late, because delay() blocks the button check.

Rewrite the blinking with millis() so the green LED follows the button within a few milliseconds. The test presses at 1230 ms and releases at 1800 ms.

const int BUTTON = 2;
const int BUTTON_LED = 8;

void setup() {
  pinMode(LED_BUILTIN, OUTPUT);
  pinMode(BUTTON_LED, OUTPUT);
  pinMode(BUTTON, INPUT_PULLUP);   // pressed = LOW
}

void loop() {
  digitalWrite(LED_BUILTIN, HIGH);
  delay(500);
  digitalWrite(LED_BUILTIN, LOW);
  delay(500);

  if (digitalRead(BUTTON) == LOW) {
    digitalWrite(BUTTON_LED, HIGH);
  } else {
    digitalWrite(BUTTON_LED, LOW);
  }
}

Turn on JavaScript to edit, run and test this code in the browser.

State machines

A state machine remembers which step it is in with a variable, and moves to the next step when a condition is met - here, when enough time has passed. Each step can last a different length of time, which a plain blink cannot do. It is how Lesson 11 builds a traffic light without delay(), and how real controllers keep many tasks responsive.

Exercise 8.5

On for 2 s, off for 1 s

Not started

Make the onboard LED stay on for 2 seconds and off for 1 second, without delay(). state remembers which half you are in: 0 is on, 1 is off.

Use the lesson's shape: one if / else if branch per state. Each branch checks its state and how long it has lasted, then switches state, sets stateStart = millis(); and writes the LED.

int state = 0;                 // 0 = on, 1 = off
unsigned long stateStart = 0;

void setup() {
  pinMode(LED_BUILTIN, OUTPUT);
  digitalWrite(LED_BUILTIN, HIGH);
}

void loop() {
  unsigned long elapsed = millis() - stateStart;

  // If state is 0 and elapsed >= 2000: state = 1, stateStart = millis(), LED off
  // else if state is 1 and elapsed >= 1000: state = 0, stateStart = millis(), LED on

}

Turn on JavaScript to edit, run and test this code in the browser.

Exercise 8.6

Traffic light state machine

Not started

Now three states. Run a traffic light on pins 10 (green), 11 (yellow) and 12 (red): green for 3 s, yellow for 1 s, red for 3 s, then back to green. showState() already switches the right LED on.

Same shape as the last exercise, with three branches. The state after 2 (red) is 0 (green) again.

const int RED_PIN = 12;
const int YELLOW_PIN = 11;
const int GREEN_PIN = 10;

int state = 0;                 // 0 = green, 1 = yellow, 2 = red
unsigned long stateStart = 0;

void showState() {
  digitalWrite(GREEN_PIN, state == 0);
  digitalWrite(YELLOW_PIN, state == 1);
  digitalWrite(RED_PIN, state == 2);
}

void setup() {
  pinMode(RED_PIN, OUTPUT);
  pinMode(YELLOW_PIN, OUTPUT);
  pinMode(GREEN_PIN, OUTPUT);
  showState();
}

void loop() {
  unsigned long elapsed = millis() - stateStart;

  // One branch per state: green (0) lasts 3000, yellow (1) 1000, red (2) 3000.
  // When a state is over: set the next state, stateStart = millis(), showState()

}

Turn on JavaScript to edit, run and test this code in the browser.

Try it on a real Uno

Download Two LEDs, two speeds, wire two LEDs with 330 Ω resistors on pins 9 and 10, and upload it. Then add a button on pin 2 with INPUT_PULLUP and make it change one of the intervals while the LEDs keep blinking - something delay() could never do.

Want more? Code Along chapter 8 has a Deeper exercise that goes further than this lesson. It is optional.