delay(): Understanding blocking code.millis().enum class and switch statements.How can we make an Arduino perform multiple tasks seemingly at the same time?
For example, how do we flash two LEDs at completely independent rates?
The standard "Blink" sketch teaches a technique that makes this simple task very difficult.
Every Arduino journey starts with the "Blink" example. It's a great milestone, proving the connection between your code, the board, and the electronics.
However, the coding technique it uses is not optimal for more complex projects.
Let's look at the core logic.
#define RED_LED_PIN 10
void setup() {
pinMode(RED_LED_PIN, OUTPUT);
}
void loop() {
digitalWrite(RED_LED_PIN, HIGH);
delay(500);
digitalWrite(RED_LED_PIN, LOW);
delay(500);
}
delay()The delay() function is a blocking function.
This means that while the Arduino is in a delay(), it can do absolutely nothing else.
Your program is completely frozen for the duration of the delay. This is suboptimal for any responsive system.
delay()What happens if we just combine the blink logic for two LEDs into the main loop?
We want them to blink independently, but the result is very different.
The code becomes sequential. One LED must complete its entire on-off cycle before the next one can even start.
void loop() {
// Red LED flashes slowly
digitalWrite(RED_LED_PIN, HIGH);
delay(500);
digitalWrite(RED_LED_PIN, LOW);
delay(500);
// Green LED flashes quickly
digitalWrite(GREEN_LED_PIN, HIGH);
delay(100);
digitalWrite(GREEN_LED_PIN, LOW);
delay(100);
}
The code is executed sequentially. The green LED code cannot run while the red LED code is running, especially while it's paused in a delay().
They are not independent. The green LED is waiting on the red LED. We need a non-blocking way to handle time.
millis()The Arduino has a built-in function called millis().
Instead of saying "wait for X milliseconds", we can change our logic to "check if X milliseconds have passed".
This allows the loop() to run continuously and quickly, checking on different tasks each time through.
millis() PatternThe core logic for non-blocking delays involves three main elements:
previousMillis).interval).loop().This pattern lets the loop run freely. The code inside the `if` statement only executes when the time is right.
unsigned long previousMillis = 0;
const long interval = 1000;
void loop() {
unsigned long currentMillis = millis();
if (currentMillis - previousMillis >= interval) {
// Save the last time you did something
previousMillis = currentMillis;
// Do something here (e.g., toggle an LED)
}
// Other code can run here without waiting!
}
We break our tasks into separate functions.
The main loop() becomes very simple. Its only job is to call these functions repeatedly and as fast as possible.
Each function is responsible for deciding for itself if it's time to act, using the millis() pattern.
// setup() remains similar, setting pinMode for both LEDs
void loop() {
blinkRedLED();
blinkGreenLED();
// We can add more functions here!
}
void blinkRedLED() {
// Red LED logic goes here
}
void blinkGreenLED() {
// Green LED logic goes here
}
blinkRedLED()This function contains all the logic for the red LED.
Most of the time this function is called, the `if` condition is false, and it returns instantly.
void blinkRedLED() {
static unsigned long redMillis = 0;
const long redInterval = 500;
if (millis() - redMillis >= redInterval) {
redMillis = millis();
// Toggle the LED state
bool currentState = digitalRead(RED_LED_PIN);
digitalWrite(RED_LED_PIN, !currentState);
}
}
In this context, the keyword static is crucial.
static unsigned long redMillis = 0;
redMillis keeps its value between function calls. If it were not static, it would be reset to 0 every time the function was called, and our timer would never work.It creates a persistent variable that is only accessible within the scope of that function, which is good programming practice (avoids global variables).
blinkGreenLED()The function for the green LED is identical in structure to the red one.
The only difference is that it uses its own `static` timer variable and a different interval (100ms).
Because each function manages its own time, they do not interfere with each other.
void blinkGreenLED() {
static unsigned long greenMillis = 0;
const long greenInterval = 100;
if (millis() - greenMillis >= greenInterval) {
greenMillis = millis();
// Toggle the LED state
bool currentState = digitalRead(GREEN_LED_PIN);
digitalWrite(GREEN_LED_PIN, !currentState);
}
}
We've achieved our goal of running two tasks independently. This is a huge step forward!
Is it a true Finite State Machine (FSM)? Not quite.
digitalRead().This technique is the first, crucial step towards building a proper state machine. For many simple tasks, this is all you need. For more complex logic, we need to be more formal.
An FSM is a model used to design computer programs and digital logic.
FSMs are a powerful way to manage complex behaviour in a clear, organised, and non-blocking way.
Before writing any code, it's best to visualise the FSM with a State Diagram.
This diagram becomes the blueprint for your code.
A simple light switch has two states: OFF and ON.
The action "Flick Switch" causes a transition from the current state to the other state.
A traffic light cycles through states based on timers.
The transitions are triggered by time elapsing, not a direct user input.
We will model a simple elevator call button system.
The sequence of events is:
Let's map this to a state diagram.
enum classWe use an enum class to define our states. This is a modern C++ feature available in Arduino.
Benefits:
// Define all possible states for our FSM
enum class ElevatorState {
Idle,
Called,
Arrived,
DoorsOpen
};
We need a variable to keep track of the machine's current state.
Just like our `millis()` timers, it needs to be static so it remembers its value each time the function is called.
We initialise it to our starting state, which is `Idle`.
// Inside our elevator control function
// Declare a static variable of our new type
// and set its initial state.
static ElevatorState currentState = ElevatorState::Idle;
switch StatementThe switch statement is the heart of the FSM. It's a clean way to execute different code for each possible state.
Inside our main elevator function, we switch on the `currentState` variable.
Each case corresponds to one of the states we drew in our diagram.
void handleElevator() {
static ElevatorState currentState = ElevatorState::Idle;
switch (currentState) {
case ElevatorState::Idle:
// Code for Idle state...
break;
case ElevatorState::Called:
// Code for Called state...
break;
// ... other cases ...
}
}
case IdleWhen in the `Idle` state, the machine does only one thing: it checks if the call button has been pressed.
If the button is pressed:
currentState.If the button is not pressed, it does nothing and the `break` statement exits the switch.
case ElevatorState::Idle:
displayState("Idle");
// Has the button been pressed?
if (digitalRead(PUSH_BUTTON_PIN) == LOW) {
// Action: Start timer
elevatorMillis = millis();
// Action: Set a random delay
elevatorDelay = random(3000, 7000);
// Transition to the next state
currentState = ElevatorState::Called;
}
break;
case CalledIn the `Called` state, the machine is waiting for the elevator's arrival time to pass.
It continuously checks the non-blocking timer.
Once the time has elapsed:
case ElevatorState::Called:
displayState("Called");
digitalWrite(YELLOW_LED_PIN, HIGH);
// Has the delay time passed?
if (millis() - elevatorMillis > elevatorDelay) {
// Transition to the next state
currentState = ElevatorState::Arrived;
}
break;
case ArrivedUpon arriving, the machine performs its "arrival" actions.
This state is very brief; its only job is to start the arrival events.
case ElevatorState::Arrived:
displayState("Arrived");
// Action: Start the beeper
digitalWrite(BEEPER_PIN, HIGH);
// Action: Start a timer for the beep
beepMillis = millis();
// Transition immediately
currentState = ElevatorState::DoorsOpen;
break;
case DoorsOpenIn this final active state:
case ElevatorState::DoorsOpen:
displayState("Doors Open");
digitalWrite(YELLOW_LED_PIN, LOW);
// Has the beep duration passed?
if (millis() - beepMillis > 100) {
digitalWrite(BEEPER_PIN, LOW);
// Transition back to the start
currentState = ElevatorState::Idle;
}
break;
A switch statement should always have a `default` case. This case runs if the variable does not match any of the other cases.
In our FSM, this should be an impossible situation. If the `currentState` is a value other than `Idle`, `Called`, etc., something has gone very wrong in the program.
It is good practice to include it for error handling.
//... all other cases
default:
// This should never happen!
// Log an error message.
Serial.println("Error: Unknown state!");
break;
}
}
The true power of this model is evident when we combine our FSM with the non-blocking LED flashers from earlier.
The main loop() is still simple and fast. It calls each "task" function in turn.
Each function quickly checks if it needs to do anything and then returns, allowing the next function to run. The result is three tasks running concurrently without interfering with each other.
void loop() {
// Handles the red LED flashing
blinkRedLED();
// Handles the green LED flashing
blinkGreenLED();
// Handles the entire elevator state machine
handleElevator();
}
You don't always need a full FSM.
Use the simple non-blocking `millis()` pattern when:
Use a full Finite State Machine when:
delay() for any responsive or multi-tasking project. It blocks all other code from running.millis() pattern for timing-based events.enum class to define states for type-safety and readability.switch statement on a state variable.The Finite State Machine is one of the most fundamental and powerful patterns in embedded systems programming.
Mastering this non-blocking approach will allow you to build complex, responsive, and reliable projects.
The provided sample code will be available for you to download and experiment with.