Disclaimer: This is a perpetual work in progress. Everything may change at any time. Also, consider that I am not a software engineering in a strict sense, but I am a control system engineer.
DEFRECS stands for DEvelopment Framework for Real-time Embedded Control Systems and it is nothing more than a software architecture and a bunch of guidelines to simplify and scale the development of real-time control systems and to help control systems engineers to code their algorithms by using the C language.
The architecture is an application of component-based software engineering where the components communicate with a publish/subscribe model and it resembles as much as possible the so-called model-based design which is the paradigm used for example by Simulink. Here, we wish that developer still thinks in terms of "connecting blocks" but instead of generating code from them, they directly C-code them. Note that within this framework advanced coding skills should not be required.
The proposed framework separates the application and the platform layers. The aim is to make the application as portable as possible and the platform as scalable as possible. For example, if you are working on a STM32 platform and you want to move on an ESP platform, the only layer that must be changed is the platform layer.
The best way to play with this framework is to look at the
examples. The README
files therein contained describe the application and you can see how the
components are connected. The theoretical part that serve as basis for all the
examples is discussed in the reminder of this README
file.
For running the examples "as-is" you need a STM32F446RE Nucleo board. Hence, you need the following software:
If you want, you can also install CubeIDE to have all in one. However, if you go for this solution you should add the include paths and the source code files to be compiled.
TBD.
There are other tools that I personally use:
- pyserial for interacting with the serial port of your laptop,
- compiledb for creating
compile_commands.json
files in case you use some LSP like clangd, - State Smith for generating code for finite state-machines (ok, I don't use it yet, but I plan to investigate its potential).
If you write the platform for other boards feel free to open a PR and if you are eager to start, jump to the How to use Section below.
It is also required that users know basic concepts of operating systems such as what is a tasks, what are race-conditions, what are mutexes as well as some basics of micro-controllers such as what is an interrupt, etc.
On the top level you have each folder representing each platform. As it is
today, we only have one platform folder (e.g. stm32f4x
). Each sub-folder
represents an example (e.g. Hello_World
, ADC_Readings
, etc.). You also
have some additional files that you could ignore as they are using for the
debugging framework used by the author.
defrecs
└── stm32f4x
├── Hello_World
│ ├── application
│ ├── platform
│ ├── utils
│ ├── contrib
│ └── ... other platform specific folders
└── ADC_Readings
├── application
├── platform
├── utils
├── contrib
└── ... other platform specific folders
The structure of each example is always the same: you have an application
folder that contains a selection of application components, and a platform
folder that contains platform components. Among other things, the platform
components are in charge of wrapping the HAL of a specific vendor.
The components in the application
folder shall be platform independent. They
shall never directly call HAL functions. At most, they call OS functions.
Given that your task is 90% developing and connecting components, you will work 90% of your time with the files contained in these two folders.
However, there are some other folders and files. For example, there is a
utils
folder that contains a number of utilities, like for example the
ftoa
used to convert floats into ASCII in embedded systems, that you may
want to re-use in your components.
There is a contrib
folder that you can ignore as it contains helpers from
third-parties, for example for accessing FreeRTOS tasks through openocd when
you are debugging and so on.
Finally, there are a bunch of other folders which are platform/framework
specific or are files that the author uses (like opendocd_*
files etc) that
you can simply ignore.
Note that each example has a README.md
file that explains what has been
done.
Again, you will most likely use only the application
and the platform
folders even though sometimes you need to do some adjustments here and there.
In the STM32 case, you have a bunch of folders (Core
, Drivers
and
Middleware
) that are automatically generated by CubeMX. They contain the
board firmware and FreeRTOS.
You also have a Makefile
which is originally generated by CubeMX and then it
has been slightly edited.
For more details, check the examples.
The proposed architecture is depicted below. The idea is to abstract the application to allow its re-utilization on different platforms with as less pain as possible. The OS used is FreeRTOS.
The proposed architecture.
The OS layer span a bit over the application layer because the latter makes
direct calls to FreeRTOS API.
This means that you cannot replace such layers independently.
The application is made by interconnected components stored in the
application
folder. A component could be a PI controller, a Kalman filter, a
sensor reader, a finite-state machine and so on and so forth. The aim is to
make components platform-independent and to allow users to connect them in an
easy manner. To achieve such a goal, components communicates one each other
through a publisher/subscriber model to resemble as much as possible
Simulink models.
Application example.
Components are connected through subscribe (inputs) and publish (outputs)
functions.
Each component has inputs u
, outputs y
, an internal state x
, a state
transition function f
and an output function h
. Hence each component can
be seen in classic Control Theory state space form (or the Moore/Melay
machines if you like it more), as it follows:
x[k+1] = f(x[k], u[k]),
y[k] = h(x[k], u[k]).
By traducing this boring math in Software Engineering language, and by
considering C as language reference, a component is nothing more than a
.h,.c
pair that contains the following:
- Static variables representing the component state
x
, - Init function: to initialize the internal state, that is, our
x0
, - Step function: a function that update the internal state and produces the
outputs, which is our
f
, - Output function that maps the state
x
and the inputu
to the outputy
. - publish*/subscribe* functions to publish/subscribe the outputs and inputs
y
andu
.
That is, a component is encapsulated in a file and communicate with the external world with its publish and subscribe functions.
A component.
It has an internal state x, input u and output y, it is
initialized with initial condition x0 and it can be scheduled periodically
or in an event-based fashion. The step function (aka state-transition
function) and the output function are f and h, respectively.
A component is encapsulated in a .h,.c pair.
Example: Say that our application wants component A performs some sensor readings and wants to send the values over the serial port. In a classic scenario, component A should #include some header file that allows writing over the serial port. Instead here, Component A limits to publish the message it wants to send and nothing more. It is then from the serial port component (let's call it component B) that shall subscribe to the outputs of component A and then perform internally all the operations to send data over the serial port.
It follows a checklist for components implementation:
- Each internal state
x
must have an associated mutex, - Each output must have both a publish and a subscribe function. The publish function is used internally to update the output and the subscribe is exposed to other components.
Example
If output
y
of component A is used as input of component B, then component A must implement both a publish and subscribe function fory
. The publish function is kept internal to component A (in-fact it is declared asstatic
) whereas the subscribe function is included incomponentA.h
. Next,ComponentB.c
shall includecomponentA.h
and nowComponentB
can ready
as input by simply call the subscribe function.The output function
h(x,u)
can be implemented either inside the publish or the subscribe function.
Tip
The definitions of the publish and subscribe function is always the same.
The only thing that changes is the dimension in the memcpy
function. In
the future we may thing to automatically generate that part of code based on
some .yaml/.json
file used to configure the input and output of the
components.
-
Header files of each component must only contain the declaration of the init function, the step function, and the subscribe functions.
- The
*_init()
function is called byapplication_setup.c
file, - The
*_step(WhoIsCalling caller)
is called byapplication_setup.c
and by some deferring tasks defined ininterrupts_to_tasks.c
file, - The
subscribe_*
functions are called by all the components that take those outputs as inputs.
- The
-
Due to that C language does not have namespaces, each component shall have an associated prefix,
-
Don't forget to initialize and schedule your component from the
application_setup.c
file.
Components' input, state and output u
x
and y
can be updated
periodically or in an event-based fashion. Periodic execution is performed
through periodic tasks, whereas event-based execution is achieved by
interrupts. More precisely, Interrupts Service Routines (ISR:s) wake up
dedicated, sleeping tasks that will carry out the actual work needed.
To allow a bit of flexibility, the step functions take an argument to keep track of the component caller. This because when running in periodic mode we may want the component to behave in a certain way, but we may want it to behave differently in response to a sudden event, e.g. when a button connected to some GPIO is pressed.
However, given that the component can be called periodically and in response to an event, both the subscribe and publish functions are always protected by a mutex to avoid race conditions.
Once we have connected your selected components through their publish and subscribe functions, it is time to run them. That is the topic of the next section.
We have connected our components. The data flow is clear. Next, we have to
schedule our components. In the application_setup.c
file you do the
following:
- Initalize the platform and the application components,
- Define the periodic tasks (i.e. you set sampling periods, allocated stack memory, etc),
- For each periodic task you list the components that shall be executed.
Then, in the main()
function, you should have something like the following:
int main(void){
// Set the various registers of the peripherals if needed
init_hardware()
// This is defined in application_setup.c. It inizializes both platform and
// application components and define the tasks
application_setup()
// Start scheduler
vTaskStartScheduler()
}
The chosen operating system is FreeRTOS. In the examples, we use the one shipped with CubeMX. In the picture at the beginning of this document, the OS takes a bit of application because components actually use FreeRTOS API.
Why use a RTOS is well explained in the FreeRTOS homepage, whereas it has been recently released a new FreeRTOS guideline.
However, regardless of the used OS, it is good to know general concepts such as tasks, interrupts, interrupt-service-routines (ISR) and the problems that may arise when using a RTOS. The way we are using the OS in DEFRECS should - hopefully - minimize all the occurrence of all the problems introduced by the usage of a RTOS.
We consider only two types of tasks: periodic and deferring. The first schedule components in a periodic fashion, whereas the second are woken up in response to an unpredictable event that has occurred. See interrupt Section below for more information.
We further carefully uses mutex to avoid blocking conditions and starvation.
In the platform
folder there are stored all the components that make calls
to the HAL layer. If you are changing platform you have to only have to adjust
the files here, leaving the application untouched.
As in the application layer, the platform components have states, inputs, outputs, an init function, a step function, etc.
Interrupts are used to perform some action in response to some event. Events can be somehow "predictable" or "unpredictable". An example of "predictable" event is the end-of-conversion (EOC) of an ADC when ADC readings are requested by a periodically scheduled task. In nominal conditions, we know more or less when the EOC is going to happen, which is about every T seconds, being T the period of the task. Another example of predictable event is the event corresponding to a Timer that fires. An example of "unpredictable" event is the pressure of a button connected to a GPIO pin. We have absolutely no idea when such a button is going to be pressed.
In both cases, interrupt service routines (ISR:s) don't preempt the OS by executing some arbitrary code, but they are gentle in the sense that they wake up some task that will in turn execute some code code. We say that interrupts defer functionality to tasks. In this way, we only have to deal with tasks concurrency and not with a mixture of interrupts/tasks concurrency that can be very nasty!
For this reason, ISR:s always have pretty much always the same structure regardless of the nature of the event as well as the associated callbacks functions that only perform the following operations:
- Notify a deferring task that wakes up,
- Ask for a context switch if the woken up task has higher priority than the current task.
However, the difference relies in the following:
- Predictable events assume that a periodic task is already running and it gets blocked while waiting for an event that will happen soon (an ADC reading to be completed, a Timer that fires, etc.). In this case the callback notifies the locked periodic task waiting for that event to happen and nothing more.
Example
Assume that
my_component
is scheduled intask_200ms
and thatmy_component
reads data from ADC that transfer the converted data to memory through the DMA. In the following code:
// ...
HAL_ADC_Start_DMA(&hadc1, analog_read, NUM_CHANNELS);
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
// ..
the function
ulTaskNotifyTake()
locks the tasktask_200ms
until it receives a notification. Such a notification is sent from the callback function invoked when the "DMA transfer completed" event happened.
- Unpredictable events. In this case the callback wakes up a specific task that calls the components with a specific caller argument.
ISR:s and Callbacks for STM32 are defined in Core/Src/stm32f4xx_it.c
(so you
must modify that file), whereas for Arduino I don't know... yet. The functions
implementing the deferred tasks for unpredictable events are defined in the
interrupts_to_task.c
file. When dealing with HAL functions that trigger
interrupts, check these three files: the function that calls the HAL function,
the Core/Src/stm32f4xx_it.c
and the interrupts_to_tasks.c
.
At the bottom level we have the hardware that is what you plan to deploy your code.
The best is to copy an example and adjust it based on your need. For each
example you have an associated README.md
file with the following structure:
- Introduction
- Requirements - what the system shall do.
- Implementation
- Hardware - which pin corresponds to what, etc.
- Software - how components are connected.
- Platform - platform components description, predictable and unpredictable events.
- Application - application components description.
- Scheduling - how components are scheduled
The process for modifying an existing example is fairly easy:
- Open the
.ioc
file with CubeMX and adjust your hardware configuration. Generate theMakefile
from there: in the SectionProject Manager - Project - Toolchain/IDE
selectMakefile
, - Adjust the
Makefile
to include your tools path and the source and include paths, - Adjust the platform layer components located in the
platform
folder, - Develop and connect your components in the
application
folder, through publish/subscribe functions, - Schedule the components in the
application_setup.c
file, - Adjust the code for handling interrupts, if any.
- Start your project by adjusting the platform components. Don't forget to
initialize them and to schedule them from the
application_setup.c
file, - You only have two types of tasks: periodic tasks defined in
application_setup.c
and deferring tasks called by ISR:s which are defined ininterrupts_to_tasks.c
. Deferring tasks wake up sporadically, i.e. upon Interrupt-Service-Routine (ISR) call, so they are not periodic, - ISR:s shall always be deferred to tasks,
- A component should be scheduled only in one periodic task. Avoid calling the same component from different periodic tasks. However, an already scheduled components can be called by a deferring tasks,
- Never use HAL functions in the application layer,
- Components shall communicate only through their publish/subscribe interfaces,
- Pay attention to your components scheduling. Some outputs may be sampled faster or slower than some inputs and that could lead to a number of problems. Be sure that the scheduling makes sense to your application.
- Each component must have a prefix, to be easily searched,
- Published signals must start with the prefix, e.g.
blink_led_state
, - Each published/subscribed is protected through a mutex,as well as HAL calls (think for example if some usart component that transmit data is called concurrently),
- Mutex for publishing/subscribing must contain the same name of the
associated output, e.g.
blink_led_state
->mutex_blink_led_state
- Publish and subscribing functions is the same for all the components and
must have the form
publish_<output_name>(...)
, e.g.publish_blink_led_state(&led_state)
, - The function to be placed in the scheduling must have the form
<prefix>_step
, e.g.blink_step(PERIODIC_TASK)
, - Outputs shall be initialized in the
<prefix>_init()
function, - Component header files must include only the prototypes of the init, the step and the subscribe functions.
Once done, edit the Makefile
and you are set. For STM32 you have to modify
the Programmer and compiler paths and the Custom targets sections.
TBD
Concurrency program is tough. Bugs are just behind the corner and in many cases it is hard to detect them. This issue is even more emphasized for those who are not strict software developers, like for example control systems engineers. However, one may try to prevent the occurrence of bugs by adhering to some coding rules and/or by following some standards.
Here, we aim at defining a software architecture and some guidelines that can help in preventing bugs when concurrency programming is employed. Furthermore, the proposed architecture allows different people working on different components without the risk of interfering one each other. For example, one person may work in designing a PID controller, another person or team in developing a Kalman filter for estimating some quantities, etc.
- cmake and/or function pointers to deal with cross-platform.
- HAL and OS Error handling.
- Doxygen.
- Components vs "utils" functions.
- Generate interfaces from yaml.
- Keep the components in a common top folder and link them from the Makefile of each example (how to deal with compile_commands.json file then?)
- Investigate if you can use vanilla FreeRTOS to make it even more platform independent.