You start with small, simple firmware. But with each added feature, the complexity grows and grows. Soon, you need a good design in order to maintain the firmware and ensure the code remains clean and readable.
In this article, I explain the event-based approach for writing firmware. I use the term “event-based” because it is similar to events systems for desktop applications, but it is also minimalistic and more straightforward. The “events” I use here are just functions, called at defined times.
This is nothing new or innovative; the demonstrated approach is just one effective design to make your firmware modular and extensible.
Using practical examples, I will guide you through this complex topic. Each chapter will improve the code in small iterations, meaning you can stop at any point. So, try to fully understand each example before you continue in the tutorial.
What are Event-Driven Applications?
You have probably read about event-driven applications if there is a graphical user interface involved. It is called event-driven because the software reacts only to events generated by the user. Each movement of the mouse and each key press will generate events. These events are pushed into a queue and processed in an event loop. This loop is what drives the entire application.
Timers are an important source for events. Timers can create a single event after a specified delay or generate repeated events at a fixed interval.
For embedded code, we only use timer and interrupts as an event source. In most cases, this is all we need. We process input from key presses or other sources using polling or interrupts. Polling is simply a repeated event used to check a condition at a defined interval. In this article, I will only explain events generated by timers.
The Blink Example – Without Events
Let’s start with the example application — the LED blink application.
All examples are written for the Arduino Uno using the Arduino IDE. This will allow anyone to reproduce the examples and will remove complexity from the code.
Nevertheless, the concepts I demonstrate here are meant to be used with any platform, especially real-world applications. I will discuss the core concepts, allowing you to adapt them to your special use case.
The Simple Blink Example
blink1.ino
const uint8_t cOrangeLedPin = 13; void setup() { pinMode(cOrangeLedPin, OUTPUT); } void loop() { digitalWrite(cOrangeLedPin, HIGH); delay(1000); digitalWrite(cOrangeLedPin, LOW); delay(1000); }
The Example Explained
In case you are not familiar with the Arduino libraries, there are two predefined functions: setup()
and loop()
. The setup()
function is called once after the start for setup, and the loop()
function is called perpetually after the setup. If you start a new project in the Arduino IDE, you will find these two functions already prepared for you. They are used to establish the base for event-based applications.
In the previous example, I set up the pin with the LED attachment as output. I do this using the pinMode()
function. In the loop, I toggle this output from low to high and vice versa with an interval of one second.
This example works perfectly, but it is not very extensible or modular. If you try to activate additional LEDs at different intervals, the test button states and retrieve data from sensors, and this simple approach won’t suffice.
Introducing a Real-Time Counter to use Time Points
Let’s now work on improving the example using time-points. Rather than blocking the CPU until a specified amount of time passes using the delay
function, we introduce a real-time counter. This is simply a counter that starts at zero at the start of the firmware, then increases every millisecond (or another time unit).
Real-time counters are often called real-time clocks. This can be confusing, because chips keeping the current date and time are called real-time clocks (RTC) as well. The core concept is the same, however: these all tick (count) at a defined time interval.
If you write firmware from scratch, you would use a hardware timer and interrupts to create a real-time counter such as this:
// pseudocode: uint32_t gMillisecondCounter = 0; setup { // setup the hardware timer to create an interrupt // every millisecond. } interrupt { // called every millisecond. ++gMillisecondCounter; }
In the Arduino environment, this counter is already implemented. The function millis()
is used to read the current value of the counter.
The counter is an unsigned 32bit value which is increased every millisecond. If the counter reaches the highest value (0xffffffff
), it will start over at zero (0x00000000
).
Blink using Time Points
Now, let’s rewrite the example using a real-time counter:
blink2.ino
const uint8_t cOrangeLedPin = 13; uint32_t gNextEvent; void setup() { pinMode(cOrangeLedPin, OUTPUT); gNextEvent = millis() + 1000; } void loop() { while (millis() != gNextEvent) {} digitalWrite(cOrangeLedPin, HIGH); gNextEvent += 1000; while (millis() != gNextEvent) {} digitalWrite(cOrangeLedPin, LOW); gNextEvent += 1000; }
Here, I introduce a new variable gNextEvent
, which stores the time point for the next event. This time point must be in the future, so I initialize the variable in the setup
function with millis() + 1000
.
millis()
returns the time point at the exact moment the function is called. By adding 1000, I will create a time point that is 1000 milliseconds, or one second, in the future and store it in gNextEvent
.
In the loop()
function, I replace the delay()
call with a while
loop:
while (millis() != gNextEvent) {}
This loop will wait until the real-time counter millis()
reaches the next expected point in time. Once this point is reached, the code continues, toggling the output with the digitalWrite
function and establishing a new time point in the future.
To set the new time point, we do not use millis()
as the time base, but rather the last time point instead. By adding the delay of 1000 milliseconds to this variable, the time point is moved into the future.
Before we analyse this further, let’s remove some obvious redundancy from the code by introducing a state:
blink3.ino
const uint8_t cOrangeLedPin = 13; uint32_t gNextEvent; bool gBlinkState = false; void setup() { pinMode(cOrangeLedPin, OUTPUT); gNextEvent = millis() + 1000; } void loop() { while (millis() != gNextEvent) {} digitalWrite(cOrangeLedPin, (gBlinkState ? HIGH : LOW)); gBlinkState = !gBlinkState; gNextEvent += 1000; }
We simplified the code by adding gBlinkState
. Now the output is toggled between high and low using the same interval of 1000 milliseconds.
Why is this any Better than Using delay
?
In this simple form, there is almost no improvement over the delay
based blink code. Only the theoretical long term precision sees an improvement.
The executed code using the delay()
function can be visualized like this:

This illustration exaggerates the duration code to change the output. If it were to take this long, there would be a shift, and the LED would actually blink slower than once per second.
You can compare this with the timing of the time point code:

This code calculates the new time point from the previous one, and therefore is as precise as the real-time counter.
I like to clearify at this point: The event-based approach is not about precise timing but about flexibility and extensibility. If you need precisely timed code, you have to work with an interrupt, triggered by a hardware timer.
Add a Second Event
In this case, it makes no sense to use time points for a single event. So, let’s blink two LEDs at different intervals. For the following example code, connect a second LED to pin 12 of the Arduino Uno board, as shown in the next illustration:

Now, let’s rewrite the code to blink two LEDs:
blink4.ino
const uint8_t cOrangeLedPin = 13; const uint8_t cGreenLedPin = 12; uint32_t gNextOrangeLedEvent; uint32_t gNextGreenLedEvent; void setup() { pinMode(cOrangeLedPin, OUTPUT); pinMode(cGreenLedPin, OUTPUT); gNextOrangeLedEvent = millis() + 100; gNextGreenLedEvent = millis() + 100; } void orangeLedEvent() { static bool state = false; digitalWrite(cOrangeLedPin, (state ? HIGH : LOW)); state = !state; gNextOrangeLedEvent += 810; } void greenLedEvent() { static bool state = false; digitalWrite(cGreenLedPin, (state ? HIGH : LOW)); state = !state; gNextGreenLedEvent += 1240; } void loop() { const auto currentTime = millis(); if (currentTime == gNextOrangeLedEvent) { orangeLedEvent(); } if (currentTime == gNextGreenLedEvent) { greenLedEvent(); } while (millis() == currentTime) {} }
In this example, I added the constant cGreenLedPin
and variable gNextGreenLedEvent
to define the LED pins and store a second time point. Next, I move the actual events into the separate functions orangeLedEvent()
and greenLedEvent()
.
I also moved the state
variable into these functions for separation and encapsulation. Each function will toggle its own LED state and set a new time point by adding different time values.
In the loop, I first capture the current time (from the real-time counter) in a variable currentTime
. Next, I compare this time with the gNextOrangeLedEvent
and gNextGreenLedEvent
variables to see whether one or both of these event functions should be called.
At the end of the loop()
function, I use the statement while (millis() == currentTime) {}
to wait for the next tick of the counter.
What did we Improve? What is missing?
What did we improve?
- We moved the events into functions, which is the key first step of modularisation.
- With the rewritten
loop()
, any number of additional events can be added to the system.
What is missing?
- There is the problem of skipped events: if an event takes longer than one millisecond, the counter can skip over a defined time point. In this case, the counter would have to count through the full range until it reaches the skipped value again. This would take an extensive ~49 days for the used 32bit counter.
- Each event must handle its own
gNext...
variable. This leads to repetitive code and bears the risk that a programmer may have difficulty in handling it. - All events are implemented with an individual time point variable, using a separate
if
clause, which is repetitive and not modular.
Now let’s solve the issues step by step.
Prevent Event Skips
The first issue we encounter is how we compare the time points with the current time provided by the real-time counter. Let’s assume we have two events. The first event “A” is scheduled at time point 100 and event “B” is scheduled at time point 200:

In our example, event “A” starts at time point 100 as scheduled, but takes longer than expected. The real-time counter is already at time-point 210 when event “A” ends. The comparison if (currentTime == gNextEventB) { ...
will not match anymore, because the specified time has already occurred. This code will wait for event “B” until the real-time counter reaches the maximum value and starts over at zero.
Check the Delta
Let’s make our time comparison safe: instead of a test for equal values, we calculate the time difference using a subtraction.
const uint32_t delta = _next - currentTime;
If the next time point _next
is in the future and therefore greater than currentTime
, the result of the subtraction is clear. It will be the number of milliseconds to the next event.
So what happens if the current time passed the event? We use unsigned integers, which do not allow negative numbers. The value of _next
is smaller than currentTime
meaning the result would be negative.
C++ handles these cases just as any CPU does: The result just “wraps” around, which typically leads to very large numbers. Imagine this as you would see just a limited number of digits of a value:
1 0000 - 1 = 9999
After subtracting 1 from 10000 you get 9999. If only the last four digits of a number are visible, the calculation would be as follows: subtracting 1 from 0000 will result in 9999. This is exactly how CPUs handle numbers: if you subtract 1 from the 32bit value 0x00000000, you get 0xFFFFFFFF.
If we want to check whether an event has already been passed, we can check the highest bit of the result, because it will be set if the result of the subtraction is a negative number.
const uint32_t delta = _next - currentTime; (delta & static_cast<uint32_t>(0x80000000ul)) != 0;
Signed integers actually use this exact principle to handle negative numbers:
Signed 32bit Integer: 0x7FFFFFFF = 2147483647 ... 0x00000003 = 3 0x00000002 = 2 0x00000001 = 1 0x00000000 = 0 0xFFFFFFFF = -1 0xFFFFFFFE = -2 0xFFFFFFFD = -3 0xFFFFFFFC = -4 ... 0x80000000 = -2147483648
If we cast an unsigned integer result to a signed one with the same number of bits, we obtain the correct negative result. Therefore, we can simplify the check like this:
const auto delta = static_cast<int32_t>(_next - currentTime); delta <= 0;
You can find more details about real-time counter and integer overflow in this article: Real Time Counter and Integer Overflow.
Encapsulate in a Class
Let’s now encapsulate the checks and manipulations in a class. This will help to keep our code clean and readable:
#pragma once #include <Arduino.h> class Event { public: Event(); public: void start(uint32_t delay); bool isReady(uint32_t currentTime); void scheduleNext(uint32_t delay); private: uint32_t _next; };
#include "Event.hpp" #include <Arduino.h> Event::Event() : _next(0) { } void Event::start(uint32_t delay) { _next = millis() + delay; } bool Event::isReady(uint32_t currentTime) { const auto delta = static_cast<int32_t>(_next - currentTime); return delta <= 0; } void Event::scheduleNext(uint32_t delay) { _next += delay; }
#include "Event.hpp" const uint8_t cOrangeLedPin = 13; const uint8_t cGreenLedPin = 12; Event gNextOrangeLedEvent; Event gNextGreenLedEvent; void setup() { pinMode(cOrangeLedPin, OUTPUT); pinMode(cGreenLedPin, OUTPUT); gNextOrangeLedEvent.start(100); gNextGreenLedEvent.start(100); } void orangeLedEvent() { static bool state = false; digitalWrite(cOrangeLedPin, (state ? HIGH : LOW)); state = !state; gNextOrangeLedEvent.scheduleNext(810); } void greenLedEvent() { static bool state = false; digitalWrite(cGreenLedPin, (state ? HIGH : LOW)); state = !state; gNextGreenLedEvent.scheduleNext(1240); } void loop() { const auto currentTime = millis(); if (gNextOrangeLedEvent.isReady(currentTime)) { orangeLedEvent(); } if (gNextGreenLedEvent.isReady(currentTime)) { greenLedEvent(); } while (millis() == currentTime) {} }
With the improved code, if an event overlaps the scheduled time point, other events start with only a short delay. Also, the new Event
class ensures the code is readable with clear method names like: start()
, isReady()
and scheduleNext()
.
Make Sure Events are Scheduled
Let’s address another issue we encountered: a developer could forget to reschedule an event that should be called repeatedly. There is no risk in our minimalistic code, but with complex nested control structures, the scheduleNext
call may be missed for a single case.
The following example uses a simple state machine to implement a complex LED blink pattern:
blink6.ino
#include "Event.hpp" const uint8_t cOrangeLedPin = 13; Event gBlinkLedEvent; void setup() { pinMode(cOrangeLedPin, OUTPUT); gBlinkLedEvent.start(10); } void blinkLedEvent() { const uint16_t fadeCount = 64; enum class Phase { FadeIn, On, FadeOut, Off }; static auto phase = Phase::FadeIn; static uint16_t phaseCount = 0; static bool state = false; switch (phase) { case Phase::FadeIn: state = !state; if (state) { gBlinkLedEvent.scheduleNext(phaseCount*2); } else { gBlinkLedEvent.scheduleNext((fadeCount-phaseCount+1)*2); } if (phaseCount == fadeCount) { phaseCount = 0; phase = Phase::On; } break; case Phase::On: state = true; phaseCount = 0; phase = Phase::FadeOut; gBlinkLedEvent.scheduleNext(1000); break; case Phase::FadeOut: state = !state; if (state) { gBlinkLedEvent.scheduleNext((fadeCount-phaseCount+1)*2); } else { gBlinkLedEvent.scheduleNext(phaseCount*2); } if (phaseCount == fadeCount) { phaseCount = 0; phase = Phase::Off; } break; case Phase::Off: state = false; phaseCount = 0; phase = Phase::FadeIn; gBlinkLedEvent.scheduleNext(1000); break; } phaseCount += 1; digitalWrite(cOrangeLedPin, (state ? HIGH : LOW)); } void loop() { const auto currentTime = millis(); if (gBlinkLedEvent.isReady(currentTime)) { blinkLedEvent(); } while (millis() == currentTime) {} }
Here, the timing for the next event varies for each call. Therefore, you find multiple scheduleNextcalls
. Scheduling the next event is critical, because if you miss it just once, the timing becomes undefined.
To prevent mistakes, let’s rewrite the example to return the delay to the next event. This will force you to provide a delay value with the return statement:
blink7.ino
#include "Event.hpp" const uint8_t cOrangeLedPin = 13; Event gBlinkLedEvent; void setup() { pinMode(cOrangeLedPin, OUTPUT); gBlinkLedEvent.start(10); } uint32_t blinkLedEvent() { const uint16_t fadeCount = 64; enum class Phase { FadeIn, On, FadeOut, Off }; static auto phase = Phase::FadeIn; static uint16_t phaseCount = 0; static bool state = false; uint32_t scheduledDelay; if (phase==Phase::FadeIn||phase==Phase::FadeOut) { state = !state; if (state ^ (phase == Phase::FadeOut)) { scheduledDelay = phaseCount*2; } else { scheduledDelay = (fadeCount-phaseCount+1)*2; } if (phaseCount == fadeCount) { if (phase == Phase::FadeIn) { phase = Phase::On; } else { phase = Phase::Off; } } phaseCount += 1; } else { if (phase == Phase::On) { state = true; phase = Phase::FadeOut; } else { state = false; phase = Phase::FadeIn; } phaseCount = 0; scheduledDelay = 1000; } digitalWrite(cOrangeLedPin, (state ? HIGH : LOW)); return scheduledDelay; } void loop() { const auto currentTime = millis(); if (gBlinkLedEvent.isReady(currentTime)) { const auto scheduledDelay = blinkLedEvent(); gBlinkLedEvent.scheduleNext(scheduledDelay); } while (millis() == currentTime) {} }
The altered interface leads to a different implementation. The scheduled delay is now passed via return value and a new variable scheduledDelay
is used to set the delay to the next call. If the code would miss in initializing this variable, the compiler will warn the user about using an uninitialized variable.
Why is the Last Example Better?
First, we changed the code to make it more modular. All code responsible for the event handling now lies outside of the event function. This is a first step towards establishing a universal event system.
Moving the Event Code into a Module
Now we need to improve the design of our example application by introducing a separate module for the LED blink code:
#include <Arduino.h> namespace BlinkLed { void initialize(); uint32_t event(); }
#include "BlinkLed.hpp" namespace BlinkLed { enum class Phase { FadeIn, On, FadeOut, Off }; const uint16_t fadeCount = 64; const uint8_t cOrangeLedPin = 13; auto phase = Phase::FadeIn; uint16_t phaseCount = 0; bool state = false; void initialize() { pinMode(cOrangeLedPin, OUTPUT); } uint32_t event() { uint32_t scheduledDelay; if (phase == Phase::FadeIn || phase == Phase::FadeOut) { state = !state; if (state ^ (phase == Phase::FadeOut)) { scheduledDelay = phaseCount; } else { scheduledDelay = (fadeCount-phaseCount+1); } if (phaseCount == fadeCount) { phase = (phase == Phase::FadeIn ? Phase::On : Phase::Off); } phaseCount += 1; } else { if (phase == Phase::On) { state = true; phase = Phase::FadeOut; } else { state = false; phase = Phase::FadeIn; } phaseCount = 0; scheduledDelay = 1000; } digitalWrite(cOrangeLedPin, (state ? HIGH : LOW)); return scheduledDelay; } }
The interface of the module consists of two functions in a namespace BlinkLed
: the function initialize()
is called once from the setup()
function to initialise everything for the module, and event()
is the function called from the event system.
We also moved all constants and state variables into the implementation file of the module.
blink8.ino
#include "BlinkLed.hpp" #include "Event.hpp" Event gBlinkLedEvent; void setup() { BlinkLed::initialize(); gBlinkLedEvent.start(10); } void loop() { const auto currentTime = millis(); if (gBlinkLedEvent.isReady(currentTime)) { const auto scheduledDelay = BlinkLed::event(); gBlinkLedEvent.scheduleNext(scheduledDelay); } while (millis() == currentTime) {} }
Now, the main code is much simpler. Everything related to the blink led event is now in the namespace BlinkLed
.
More events can be added using modules such as this. Each has its own separate module with its own variables and data, and even complex projects with many events remain clear and readable.
Structure complex projects with modules as shown in the previous example. A module uses a namespace or class in a header file as the interface. The implementation is hidden and therefore protected from the user. This design will ensure that your code is clean and readable.
Source Code
You can find all code examples in the GitHub repository below:
https://github.com/LuckyResistor/event-based-firmware
Next Article
In the next part of this series, we will develop a more versatile event system, with an event stack used to handle event functions dynamically.
Learn More

Use Enum with More Class!
Read More

C++ Templates for Embedded Code (Part 2)
Read More

Real Time Counter and Integer Overflow
Read More

C++ Templates for Embedded Code
Read More

Class or Module for Singletons?
Read More

Units of Measurements for Safe C++ Variables
Read More
My take a similar task with a similar approach: https://github.com/spbnick/christmas-card