Event-based Firmware (Part 2/2)

In the first part of this article, we explored the general concept of event-based firmware. For the first part, please click this link.

The introduced concepts were directly tailored to one specific firmware. Now let us develop the concepts further to build an event system which can be integrated in many different applications.

Let us analyze the last exampe:

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) {}
}

What makes this code static?

First, the variable gBlinkLedEvent is directly tied to this single event. It can not be reused by other events.

Second, the if (gBlinkLedEvent.isReady(currentTime)) { ... } code is repetitive for all events. If you have repetitive code, it is usually a sign of bad design. While there are very few cases where repetitive code is acceptable, usually it should be removed using structures, loops and tables.

Backtrack for a New Direction

Let us address the issue with the fixed function call first. To solve this, we have to backtrack and enter a new direction. The next example consists of the files blink9.ino, Event.hpp and Event.cpp:

Event.hpp

#pragma once

#include <Arduino.h>

class Event
{
public:
  typedef void (*Function)();
public:
  Event();
  Event(Function call, uint32_t next);
  Event(const Event&) = default;
  Event& operator=(const Event&) = default;
public:
  bool isValid() const;
  bool isReady(uint32_t currentTime) const;
  Function getCall() const;
  void clear();
private:
  Function _call;
  uint32_t _next; 
};

Event.cpp

#include "Event.hpp"

Event::Event()
  : _call(nullptr), _next(0)
{
}

Event::Event(Function call, uint32_t next)
  : _call(call), _next(next)
{   
}

bool Event::isValid() const
{
  return _call != nullptr;
}

bool Event::isReady(uint32_t currentTime) const
{
  if (_next == currentTime) {
    return true;
  }
  const auto delta = _next - currentTime;
  if ((delta & static_cast<uint32_t>(0x80000000ul)) != 0) {
    return true;
  }
  return false;
}

Event::Function Event::getCall() const
{
  return _call;
}

void Event::clear()
{
  _call = nullptr;
}

blink9.ino

#include "Event.hpp"

const uint8_t cOrangeLedPin = 13;

Event gEvent;

void ledOnEvent();
void ledOffEvent();

void setup() {
  pinMode(cOrangeLedPin, OUTPUT);
  ledOnEvent();
}

void ledOnEvent()
{
  digitalWrite(cOrangeLedPin, HIGH);
  gEvent = Event(&ledOffEvent, millis() + 800);
}

void ledOffEvent()
{
  digitalWrite(cOrangeLedPin, LOW);  
  gEvent = Event(&ledOnEvent, millis() + 600);
}

void loop() {
  const auto currentTime = millis();
  if (gEvent.isValid() && gEvent.isReady(currentTime)) {
    auto call = gEvent.getCall();
    gEvent.clear();
    call();
  }
  while (millis() == currentTime) {}
}

I rewrote the Event class to fit the new needs. In the first part of the class, I define methods for copy and construction:

public:
  Event();
  Event(Function call, uint32_t next);
  Event(const Event&) = default;
  Event& operator=(const Event&) = default;

There is a default constructor Event() which will create an invalid event. The second constructor Event(Function call, uint32_t next) will create a valid event which shall call the given function at the time point next.

To declare the class assignable and copyable, I included the copy constructor Event(const Event&) and the assign operator Event& operator=(const Event&). The statement = default at the end of these lines will instruct the compiler to generate the default implementation for these functions. Actually you could just omit these two lines for the same result. Adding them will tell everyone this class supports copy and assign.

In the next part, I define the functions to manage the event:

public:
  bool isValid() const;
  bool isReady(uint32_t currentTime) const;
  Function getCall() const;
  void clear();

The function isValid() tests if the event is valid. In the current situation it means, the event has a function to call. To test, if the function is ready to be called, there is the function isReady() which we already used before.

A getter getCall() let us access the function to call and the method clear() will turn the event into an invalid one by assigning nullptr to the _call variable.

Short Intro to Function Pointers

If you already have some experience with function pointers, feel free to skip this section.

Function pointers are a very low-level way to create dynamic calls. You probably already used something similar indirectly. The interrupt vector is working like an array of function pointers, or for some MCUs the start address of the firmware is written at a defined location into the flash rom.

In the example, I define a new type for a function pointer, using the line shown below:

typedef void (*Function)();

This is a ancient syntax to declare a funtion pointer type and it is very confusing. If you ever use function pointers for desktop applications in C++, you should use the header <functional> with the following modern syntax:

#include <functional>
// ...
using Function = std::function<void()>;

This declaration creates a new type Function, which defines a function pointer to functions with a specific calling syntax. In our case, functions with no return value void and no parameter (). A function pointer using this type, can only call functions with the given syntax.

typedef void (*Function)();

Function gFunc = nullptr;

void a() {
}

void b() {
}

void main() {
  gFunc = &a;
  gFunc();
  gFunc = &b;
  gFunc();
}

The example above illustrates this principle. In the main() function, first the address of function a is assigned to the function pointer using the syntax &a. Thefeore the next statement gFunc() will call function a().

Next the address of function b is assigned to gFunc. Thefore, gFunc() in the next line will call function b().

A side note: There are way better patterns and concepts in C++ to implement dynamic calls for events, but they produce way more machine code, which is an issue for embedded systems.

Creating Dynamic Events

Let us analyze how we use the new Event class in blink9.ino. At the begin of the code, there is one event variable gEvent and two forward declarations of the event functions we use:

Event gEvent;

void ledOnEvent();
void ledOffEvent();

The forward declarations are required, to use the function names in the code, before their definitions.

In the setup() function, the LED pin is configured as output and the first event ledOnEvent() is called manually.

void setup() {
  pinMode(cOrangeLedPin, OUTPUT);
  ledOnEvent();
}

Calling the first event function manually is just a smart way to set the output into a defined state and assign the first delayed event. Alternatively I could also write the setup() function like this:

void setup() {
  pinMode(cOrangeLedPin, OUTPUT);
  digitalWrite(cOrangeLedPin, HIGH);
  gEvent = Event(&ledOffEvent, millis() + 800);
}

The two event functions will just turn the output to HIGH or LOW and schedule a new delayed event for the opposite function.

void ledOnEvent()
{
  digitalWrite(cOrangeLedPin, HIGH);
  gEvent = Event(&ledOffEvent, millis() + 800);
}

void ledOffEvent()
{
  digitalWrite(cOrangeLedPin, LOW);  
  gEvent = Event(&ledOnEvent, millis() + 600);
}

The most interesting part is the new abstract loop() function:

void loop() {
  const auto currentTime = millis();
  if (gEvent.isValid() && gEvent.isReady(currentTime)) {
    auto call = gEvent.getCall();
    gEvent.clear();
    call();
  }
  while (millis() == currentTime) {}
}

As you can see, in this loop, there is no mention of ledOnEvent() or ledOffEvent(). There is also no delay value visible in this loop. Basically, you can use the same loop for any kind of event based code, which is a step in the right direction.

Issues of the Last Example

The last example adds some flexibility to the code, by using function pointers to call the event function, but a number of issues remain:

  • There is a single event variable gEvent. Adding more events will bind the events to own event variables which is not optimal.
  • The code Event(&ledOnEvent, millis() + 600); is using the real time counter millis() directly instead of passing a single delay value. This is not just hard to use code, you also have a potentential to lose long term precision of the event timing.

Encapsulate and Allow Multiple Events

For multiple events we need multiple event variables, but to keep the assignment to this variables dynamic we use an array of events.

We start creating a new compile unit with the files EventLoop.hpp and EventLoop.cpp. The interface of this compile unit contains all functions we need to operate the new event system.

As shown in previous examples, we use no class, but a collection of functions encapsulated in a namespace. There is one single event loop for the whole firmware, therefore a class would just add complexity to the implementation.

EventLoop.hpp

#pragma once

#include "Event.hpp"

namespace EventLoop {

typedef void (*Function)();

void initialize();
void addDelayedEvent(Function call, uint32_t delay);
void loop();
  
}

The function initialize() has to be called from the setup() function to initialize the event system. Adding new events should be as simple as possible, using the addDelayedEvent() function. Now we just need the function pointer and the delay, no calculation is required anymore. Last, the function loop() will process the events from the loop of the firmware. You have to call EventLoop::loop() from the loop() function of the firmware as shown shortly.

An ineffective implementation illustrates the basic concept of the new event system.

EventLoop.cpp

#include "EventLoop.hpp"

#include "Event.hpp"

namespace EventLoop {

const uint8_t cEventStackSize = 8;

uint32_t gCurrentTime = 0;
Event gEvents[cEventStackSize];

void initialize()
{
  gCurrentTime = millis();
}

void addEvent(const Event &newEvent)
{
  for (auto &event : gEvents) {
    if (!event.isValid()) {
      event = newEvent;
      break;
    }
  }
}

void addDelayedEvent(Function call, uint32_t delay)
{
  addEvent(Event(call, gCurrentTime + delay));
}

void processEvents()
{
  for (auto &event : gEvents) {
    if (event.isValid() && event.isReady(gCurrentTime)) {
      const auto call = event.getCall();
      event.clear();
      call();      
    }
  }  
}

void loop()
{
  const auto currentTime = millis();
  if (gCurrentTime != currentTime) {
    gCurrentTime = currentTime;
    processEvents();  
  }
}

}

At the start of the file, we define constants an variables for the module.

const uint8_t cEventStackSize = 8;

uint32_t gCurrentTime = 0;
Event gEvents[cEventStackSize];

The constant cEventStackSize defines the maximum number of “parallel” events for our system. The value of 8 means, there can be eight pending events waiting to be executed. Any additional added events will be ignored and likely cause an undefined behaviour.

Usually, it is very simple to find the maximum number of pending events for a firmware. It is not like some kind of desktop application, where every slight move of a mouse will add 100+ events to the stack. Every new event is added by a previous event or some other defined action.

The variable gCurrentTime will store the real time counter value for the current event processing. It may be a little bit tricky to understand, why this variable is used in this way for our simple examples, but it is there for improve the timing quality of the system.

If at a given time multiple functions are called from events, and these calls take more time as one millisecond, using millis() + delay would cause a slight shift in the timing. The variable gCurrentTime always stores the point of time at the begin processing the events, which reduce (but not completely solve) these problems.

At the end, there is the actual array or stack of events. The variables are automatically initialized with the default constructor of the Event class, and are therefore all invalid events.

void initialize()
{
  gCurrentTime = millis();
}

The initialize() function just sets the initial value of gCurrentTime for the case the firmware is adding some events in the setup() function.

void addEvent(const Event &newEvent)
{
  for (auto &event : gEvents) {
    if (!event.isValid()) {
      event = newEvent;
      break;
    }
  }
}

void addDelayedEvent(Function call, uint32_t delay)
{
  addEvent(Event(call, gCurrentTime + delay));
}

The implementation for adding an event to the array is very simple for this example. In the function addEvent() the for loop searches the first free “slot” and stores the event there. This is neither effective nor smart, but easy to understand.

Adding a delayed event using addDelayedEvent just constructs a new such Event object and is adding it to the array using addEvent(). The delay is calculated using the gCurrentTime plus the passed delay parameter.

void processEvents()
{
  for (auto &event : gEvents) {
    if (event.isValid() && event.isReady(gCurrentTime)) {
      const auto call = event.getCall();
      event.clear();
      call();      
    }
  }  
}

void loop()
{
  const auto currentTime = millis();
  if (gCurrentTime != currentTime) {
    gCurrentTime = currentTime;
    processEvents();  
  }
}

The core of the system is the loop() function. I assume, it is called indefinitely from the main loop() fuction. It starts by storing the current real time counter value from millis() and comparing it to the last stored time in gCurrentTime.

If there is a difference, the new time is stored in gCurrentTime and all events are processed by calling the processEvents() function.

This implementation will only process events every millisecond. It will always wait for the next tick of the clock (real time counter). If the loop is delayed for some reason, the events are processed immediately if there was a change of the clock.

Processing the events in processEvents() is similar to what we did in the example before. Just in this case, we process every event in the array. This implementation is ineffective and the order of events is not keept sequential, but it is very easy to understand.

Using the New Event System

To test the new event system EventLoop let us blink two LEDs at different speeds:

blink10.ino

#include "EventLoop.hpp"

const uint8_t cOrangeLedPin = 13;
const uint8_t cGreenLedPin = 12;

void orangeOnEvent();
void orangeOffEvent();
void greenToggleEvent();

void setup() {
  pinMode(cOrangeLedPin, OUTPUT);
  pinMode(cGreenLedPin, OUTPUT);
  EventLoop::initialize();
  orangeOnEvent();
  greenToggleEvent();
}

void orangeOnEvent() {
  digitalWrite(cOrangeLedPin, HIGH);
  EventLoop::addDelayedEvent(&orangeOffEvent, 800);
}

void orangeOffEvent() {
  digitalWrite(cOrangeLedPin, LOW);  
  EventLoop::addDelayedEvent(&orangeOnEvent, 600);
}

void greenToggleEvent() {
  static bool isHigh = false;
  digitalWrite(cGreenLedPin, (isHigh ? HIGH : LOW));
  isHigh = !isHigh;
  EventLoop::addDelayedEvent(&greenToggleEvent, 200);  
}

void loop() {
  EventLoop::loop();
}

The code starts with the constants and forward declarations. In the setup() function, I first configure the two outputs for the LEDs, then initialize the event system using the EventLoop::initialize() call. This has to be done, before any addDelayedEvent() function is called.

Next two of the event functions are called manually to set the state of the output and add the initial event.

Next are the three event functions you already know from previous examples, just with the difference they call EventLoop::addDelayedEvent to add a new event.

At the end, there is the almost empty loop() function, which is just calling EventLoop::loop().

Observations

  • This new example code is very clean and easy to read.
  • All implementation details of the event system are hidden behind the EventLoop interface.
  • The loop() function is a simple one liner.

Remaining Issues to Fix

  • Currently there is just one type of event, the delayed event. In most firmwares one would like to have something like an intervall or polling event as well.
  • Adding and processing of events is not very effectve in the current implementation. The array should be managed as a stack, adding new events at the end, removing processed events from any position.
  • This will also solve the issue with the order of processing. Events which are added at a later point in time should also be processed later. If multiple events should be executed at the same time, they should always be processed in the order how they were added.
  • Interrupts should be able to be the source of events in a safe way.

Example Files

You can find all examples in the following GitHub repository:

https://github.com/LuckyResistor/event-based-firmware

Learn More

Conclusion

This is the end of the second part of this article. I hope you learned the basic concepts of event-based firmware and hopefully you will apply this knowledge to your next project.

All the shown examples are as minimalistic and simple as possible, but for real applications you need to write better interfaces and add more functionality to the code. As you understand the concept, this should not be that hard.

If you have questions, miss some information or have any feedback, feel free to add a comment below.

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.