2022-03-19

A Modular Architecture for Embedded Software


Organizing a software application into modules is a powerful way to manage complexity. Modules are reusable, development can take place in parallel and they can be tested separately.

In this architecture we design reusable modules that communicate via typed signals. It is not rocket science. It is the application of a few well known concepts that will make your code feel lighter, truly modular, straightforward to test and easier to change and reason about.


Basic C Modules

In the C tradition a module is a pair of files, one public header file and one private implementation file. The module is accessed through an API of public functions. Modules of this kind are very useful and we will use them as a foundation for the architecture presented below.

Signals

Embedded control systems process signals and events going in and out of the system. A module can be seen as a miniature system. We want signals to be passed to modules via function calls. This is important because it gives us full control of how to implement the functionality in the module. The input function is called when the signal changes. An event can be modeled as a signal with type void.

As an example we will use a module that receives a voltage signal as input. The C type is uint32_t and the engineering unit is mV. A scheduler event will drive the internal processing. Nothing prevents the input function to do all the work, but for the system as a whole it is often better to delegate computation to the scheduler event function. It is called periodically by the main scheduler or executed by a dedicated thread. This is similar to using a clocked flip-flop in a digital circuit to limit the logic depth of gates in sequence. In software we want to limit the call depth. The drawback is increased latency.

static uint32_t voltage_mV = 0;
static uint8_t  voltage_changed = 0;

void voltage_sink_input(unit32_t value)
{
    voltage_mV = value;
    voltage_changed = 1;
}

void voltage_sink_scheduler_event()
{
    if (voltage_changed)
    {
        voltage_changed = 0;
        /* Do stuff */
    }
}


Instances

The module above can only be used once in the system. To be able to reuse the module we need to introduce the concept of an instance. The instance is represented by a struct containing local data. It is passed to all input functions.

#include "stdint.h"

typedef struct {
    uint32_t voltage_mV;
    uint8_t  voltage_changed;
} voltage_sink_instance_t;

void voltage_sink_input(void* inst, uint32_t value)
{
    voltage_sink_instance_t* self = (voltage_sink_instance_t*)inst;
    self->voltage_mV = value;
    self->voltage_changed = 1;
}

void voltage_sink_scheduler_event(void* inst)
{
    voltage_sink_instance_t* self = (voltage_sink_instance_t*)inst;
    if (self->voltage_changed)
    {
        self->voltage_changed = 0;
        /* Do stuff */
    }
}

A macro can reduce the typing, but I will not use it here for clarity.

#define SELF(T) ( (T)* self = ((T)*)inst )

It is also useful to have the start state of a module instance defined in a single place.

#define VOLTAGE_SINK_START_STATE { \
    .voltage_mV = 0,               \
    .voltage_changed = 0           \
}


Module Decoupling

The purpose of the void* inst in the call signatures is to decouple the modules from each other. A module that outputs a voltage signal should be able to send it to any module that accepts such a signal, not only the modules it knows about. To send a voltage signal to an instance, all we need is a pointer to the following tuple.

typedef struct {
    void (*fn)(void* inst, uint32_t value);
    void* inst;
} uint32_mV_input_t;


Outputs

Now we can build a voltage source that can send signals to the voltage sink. It has the ability to send the signal to a list of inputs.

typedef struct {
    uint32_mV_input_t** output;
    uint32_t next_value;
} voltage_source_instance_t;

void voltage_source_scheduler_event(void* inst)
{
    voltage_source_instance_t* self = (voltage_source_instance_t*)inst;
    uint32_mV_input_t** output = self->output;
    while (*output != 0)
    {
        (*output)->fn((*output)->inst, self->next_value);
        output++;
    }
    self->next_value++;
}

Interconnect

Now we can build a system where these two modules are connected.

voltage_sink_instance_t sink = {
    .voltage_mV = 0,
    .voltage_changed = 0
};

uint32_mV_input_t input = {
    .fn = voltage_sink_input,
    .inst = &sink
};

uint32_mv_input_t* inputs[] = {&input, 0};

voltage_source_instance_t source = {
    .output = inputs,
    .next_value = 0
};

void main(void)
{
    while(1)
    {
        voltage_source_scheduler_event(&source);
        voltage_sink_scheduler_event(&sink);
    }
}

Request-Response

An output signal is not a request for another module to do something. It is only a statement about the current value of a signal in the system. If you want you can use signals to build request-response protocols. A module can be programmed to expect a response on input signal B soon after assigning a new value to output signal A. Connections made to other modules then need to match this expectation.


Complex Signals

Signals can have composite values that are too large for a primitive C type. We can use a 32 byte buffer as an example. We need to pass a pointer to the buffer to the input function. Signals should behave like values which means they should not be modified after transmission. This means we don't need to make copies of the signal for each downstream module. They can alll refer to the same memory. When all modules have stopped using the signal it can be reclaimed by the source to represent a different signal. To know when this is ok we can use a reference counter.

typedef struct {
    uint8_t buffer[32];
    uint32_t refcount;
} uint8x32_sample_t;

typedef struct {
    void (*fn)(void* inst, uint8x32_sample_t* value);
    void* inst;
} uint8x32_sample_input_t;

The output module increases refcount for each receiving module and the receiving modules decrease it when they are done. The output module probably needs a pool of signals that gets reused over time.

Hardware

An MCU has a lot of peripherals and these need to be mapped to input and output signals. For GPIO we can assign each pin an instance of the GPI and GPO modules depending on if they are inputs or outputs. To generate new values the GPI instances needs to be polled with a call to an event input. This can be done by a poll event from the module that wants to know the pin state, or it can be done by a scheduler event. An interrupt can also be used as a poll event. The GPO module doesn't need a separate event. It can change the pin immediately in its input function. SPI, CAN, UART and ADC can in similar ways be mapped to input and output signals.

It can be tempting to skip the signal abstraction and access peripheral registers directly from any module that needs the data. Take system time for example. It can easily be read directly from the hardware. This is simple but makes it more difficult to test the module. A more test friendly way to distribute time is to add it to the scheduler event signal. Use uint32_us or uint32_ms and pass it to the module in the scheduler event. In a test case the time can be simulated to test edge cases that are difficult to test in real time.

Peripheral register access should be limited to a few modules dedicated to hardware interactions. All other modules should only use input functions to send and receive data.


Threads

If you use an RTOS you probably want each module instance to be run by its own thread. The input functions will put the signal in a queue which is monitored by the thread that runs an event loop or state machine to implement the behavior of the module.


Layers

It can be helpful to categorize the modules into a hardware layer and an application layer. In the application layer we want to use a few domain specific engineering units, for example float32_V, int32_us, uint16x32_mV that fits the problem and are efficient to use on the hardware. The hardware layer on the other hand works with raw binary data, for example samples from an ADC. They need to be converted to engineering units when passed to the application layer. Lets say we have a 12 bit ADC with a range of 0-5V. We want to measure a signal that can be 0-50V. A resistor net reduces the signal 10 times before it hits the ADC. The conversion formula will be uint32_mV = uint12_raw * 50 * 1000 / 4095. This can be performed by a conversion module with an uint12_raw input and an uint32_mV output.

Drawbacks

The big drawback when decoupling modules via signals is that you can't follow the program execution by reading the code in the output function. The input function to be called is hidden in a runtime variable. The static program partly becomes a virtual machine executing a dynamic graph of signal connections. You will need supporting documentation where modules, signals and connections are sketched out. This is also true for many techniques used in object oriented programming.


Summary

  • Modules receive signals with public functions and transmit signals with function calls.
  • Timer/Polling/Scheduler/Interrupt Events are signals of type void.
  • An instance of a module is represented by a struct with local data that is passed to all input functions.
  • Initialization code creates instances and connects outputs with inputs.
  • Outputs depend on {(*fn)(), *inst} tuples, not specific input functions.
  • The hardware layer consists of modules that map signals to MCU peripherals.
  • All but the most primitive of modules are activated by scheduler events or by threads.

2022-01-22

Induction is Very Useful as a First Guess

Second edition: I was wrong. I have written about it before, but I forgot: induction doesn't exist. The number of possible theories for any set of observations are infinite. Brett Hall showed me the right path forward. We don't use induction to find knowledge. Instead we use a fundamental theory: There are regularities in the world. We use this theory to quickly draw conclusions from a few examples, also when the perceived regularity requires complicated transformations to be extracted from the experience. We hold on to the knowledge until we find a convincing counterexample, either through thinking or by observation.

Is it even possible to form a theory of an irregularity? Yes, I think so. We can use chaos theory and no-go theorems as examples. These theories can't be used to make predictions. It is more like a warning sign. Don't spend energy looking for regularities here, there are none.

First edition: Hume showed us that induction is not the mechanism we use to create knowledge but couldn't find an alternative explanation. Popper solved the problem using conjectures and refutations. We guess how the world works and our guesses can be falsified by things we already know and by cleverly constructed experiments when we lack convincing reasons to choose between competing theories.

But Popper does not tell us how we come up with conjectures. For this, induction is very useful. It lets us find patterns, regularities and causal relations that works surprisingly well as first guesses. If you believe that the sun will rise tomorrow because it has done so all days that came before, it will serve you well for billions of years. Only if you fail, with more time, to figure out how the solar system actually works - which will falsify your initial inductive guess - it will threaten your survival.

Felfri produktutveckling

Skojade bara. När vi konstruerar och tillverkar produkter är det alltid möjligt att göra misstag. Konstruktionen fungerar inte som det var t...