Join GitHub today
GitHub is home to over 50 million developers working together to host and review code, manage projects, and build software together.Sign up
GitHub is where the world builds software
Millions of developers and companies build, ship, and maintain their software on GitHub — the largest and most advanced development platform in the world.
/* # Theseus, an embedded software design pattern for bare-metal & RTOS applications * Copyright (C) 2018 Aidan Millar-Powell * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <https://www.gnu.org/licenses/>. * */ I humbly and respectfully present this design pattern as my first major project. I call this design pattern ‘Theseus’ because each section of the code could change and other sections should be able to operate the same regardless. This design is by no means perfect, there are more than a few kinks that need working out. That is partly why I am putting this out into the world. Not only to receive feedback on my approach, but also potentially spark an open conversation about better design principles in embedded software engineering. I will be sure to comment where there are obvious holes in the given examples, accompanied by ideas about refactors and improvements. Things like the XX_adaptor and mpi_XX files and other parts are incomplete, as are the various README files in the repo. However, the general idea of the design is present and should hopefully be obvious to those whom take an interest. One major thing that I have not implemented yet is typedefs for parameters. However given the inherent modularity of the design, this should be trivial and merely a matter of include statements and changing the data types on parameters themselves. If it weren't that easy, then I would have failed in one of my original design goals: to factor in expansion over time, then make it seamless and logical. The aim of this project arose out of the need to plan for future device upgrades. Not only in major versions of microcontroller, but also across variable Cortex series MCUs. I also wished to account for changing requirements with external devices, imagine a product that has seven or eight different radio variants i.e. one has GPS, Wifi, and LTE and another product has the same wifi requirements but for a country that allocates a separate frequency band. Swapping out an external device should be as easy as writing the library (in accordance with the design), and adjusting application level calls (if necessary). When looking at an application level code block, it should reflect a high-level pattern of behaviour intrinsic to an application, rather than having device and core logic mixed in at this level. The core tenets of this design are: - As just mentioned: seamless and logical expansion/contraction over time, leading to enhanced maintainability - Build the smaller pieces properly, and within a repeatable pattern, so things are much easier when it comes time to ‘glue it all together’, with jump tables and fn ptrs. - Abstracted connection between layers through the use of callback functions - Common device interfaces (optional) - Jump tables (so many jump tables...) - A config layer with compiler flags for different devices and buffer widths - Data structure prototypes at the device layer and instance declarations at config layer The use of pseudo-universal objects - A tendency towards coding general functionality of a device at the hardware layer, rather than application-centric coding (this will hopefully make sense later) - Moving all runtime configuration changes into a GUI or application layer, prior to write calls Some negatives: - Casting is required at the interface level - Jump tables generally need to be mirrored, so great care must be taken during definition - Currently it is written in C, which is not a bad thing in and of itself but it’s worth a mention before people ask me why it wasn’t written in C++. I am reviewing the possibilities of re-writing parts of the code in C++, particularly middleware and interface layers where method overloading is currently not possible. Variadic functions in C are still being considered as an alternative to mixing C & C++ This project is not just about a design pattern, but a proposal toward a sensible approach to programming embedded systems. # Understanding the code layout The best way to explain this design is that each section is theoretically decoupled from the next. The data and config struct prototypes are defined at various different layers for code maintainability, ease of arrangement, and code distribution. There is one data layer, with all instances of said layer declared at the configuration level as needed. There is no place for static structs declarations at the HAL in this design (please bear with me on this). There are some exceptions to this but they should be a last resort, not a core design feature. Error data is to be considered akin to any other data category. - HALs: The HAL layers are simply sets of read, write and clear functions arranged into a series of sensible jump tables. The device-native data / config struct prototypes are defined in a shared header at this level. - External devices: The external device layers are merely glorified message builders and decoders, with configuration included, that simply make callbacks to host hardware for communication. - Port adaptors / interface layer: this layer is where the public interface functions reside for each device and peripheral. This enables different layer configurations i.e. middlewares and HALs, simply by changing the interface (or adaptor). - Middleware: The middleware layer houses a series of functions that merely perform callbacks to host hardware via any external device layer. The MPI_host and MPI_slave object prototypes are defined at this level. Any state machines and error checks should also be contained at this level. - Config layer: The configuration layer houses compiler flags for any desired devices. Within these include flags are also the data / config struct declarations and assignments to relevant object members. - RTOS/Application layer: This layer houses main() and any arrangement of RTOS API calls. In this way a clear separation of concerns is achieved between the respective HALs, RTOS’, middleware, and external devices. Across the public interface (port adaptor) and middleware layers, there are a small set of uniform functions and parameters: Functions: - Init: initialize device or register - RegDump: get all the info for the whole device and place them in a middleware data struct - ConfigReg: alter the values for a single register - QueryReg: query the values for a single register - Data: data I/O functions for host and external devices - Reset: reset the device (with options for settings flush i.e. back to initial user-defined config, soft reset i.e. power cycle, and factory reset i.e. back to factory settings) - Off: turn the device off - Sleep: put the device to sleep - Wakeup: wake-up the device from sleep - ModeLevel: think of mode level as a universal power or user level function. Some devices contain the ability to have multiple active levels i.e. various sleep modes or default active/deactive peripherals. Some have one or two, others have four or even five. Parameters (IN THIS ORDER): - void* host_object - Int read_write_clear (optional) - int(*host_communication)() - void* ext_dev_object - int(*ext_dev_communication)() If you decide to expand or alter these guidelines, then changes should be reflected across the middleware and interface layers in order to retain a standard of uniformity. For example, you may have one set of parameters (A) for host controllers (at middleware and public interface), but another set of parameters (B) for the external devices (at middleware and public interface). However, you may NOT have one set (B) for one external device and another set (C) for another device. The idea is that the middleware layer enables generic connection between layers to ease maintenance and updates for device versioning. Further, you may NOT have one set (A) for the public interface and another set (B) for the middleware. It simply will not compile. ‘Callback’ V ‘All roads lead to Rome’ fns This is an interesting concept. Something I came across when comparing libraries I built for two different devices. Using a callback style you have a device that needs a spi transaction header or a message frame builder, instead of the interface fn calling that builder and then having that child function call through to the spi interface, you return and the interface calls back to the spi. Look at this basic flow: Boomerang example: (XX_Init() ) -> (XX_buildFrame() ) | (XX_Init() ) <--------| | V (XX_spi() ) Is much better than: (XX_Init() ) -> (XX_buildFrame() ) -> (XX_spi() ) The ‘all roads lead to Rome’ approach usually means passing around more than you need to and your code ends up littered with unnecessary callbacks. Not very DRY. Some may disagree and that's fine, I look forward to discussing advantages and disadvantages to both. Whereas the Boomerang approach promotes a neater set of functions that perform a task and return when the job is complete, ready for transmission/reception of data. Now that we have understood more about our general design approach. Let’s move on to the actual building of code: # Preparation As per usual, decide on the peripherals that you will require to fulfill the needs of this project i.e. VCMP, ACMP, i2C, DMA etc. These may or may not have been chosen in the requirements phase. When we begin actually coding, the initial focus should be on the following basic peripherals: CMU, USART/i2C, TIMER, GPIO. Without at least the CMU and TIMER, you cannot hope to achieve much at all and in fact, as you know: the chip won’t even work without the CMU. Note all the registers in these peripherals, along with their ability to perform read, write and clear functionality i.e. RW, R, W1. To do this go to your reference manual and, within each peripheral, find the section titled “Register Map”. Every peripheral should have one. You don’t need to write them down, just have a look over this section. It will tell you basically everything you need to know about this peripheral at this point in time. # HAL layer: Host devices: Build simple read, write and clear functions for those registers that are enabled to handle each respective functionality i.e. if it’s a read-only register, then just make a read function. Make a simple jump table for each register and place the read, write and clear functions in this jump table as per the order: read, write, clear. This ordering should become a standard mantra for all things i.e. read, write, clear… poll, response, final (dw1000 ranging phase), start to formulate repeatable patterns early on so that building jump tables will be easier. For those registers that are write-only or read-only: place the function in it’s appropriate place-setting i.e. read fns go in index 0, write fns go in index 1, clear fns index 2. THEN simply place NULL values at the empty indexes. Make another jump table which will then house the lower register jump tables in the order that each register appears in the register map in the reference manual, for that peripheral. This jump table will now act as a two-dimensional array and thus child functions (i.e. read, write or clear) can be accessed using double square brackets. E.g. int(*usart_read)() = usart_fn_table[CTRL][READ]; We can then execute the function like so: usart_read(usart_data_struct_member); //more on this parameter later Then, we build global_inteface functions that access these register tables, based on some parameters, and then write to or read from these registers into or out of a data object. Interrupts should also be defined at this level. External devices: External device layers are merely glorified message builders and runtime calculators Simply create your message builders, along with public Rx and Tx functions for device communication (utilizing callbacks to host hardware) # Port Adaptors: As previously stated, the port adaptors layer should be a set of public interface functions that access the relevant jump tables, according to the values pre-defined in our MPI_host and MPI_slave objects. //more on these later Each host and slave device should have a port adaptor file, which (for the hsot devices) encapsulates the following functions for each peripheral. Expose the following functions: Init: initialize device or register RegDump: get all the info for the whole device and place them in a middleware data struct ConfigReg: alter the values for a single register QueryReg: query the values for a single register Data: data I/O functions for host and external devices Reset: reset the device (with options for settings flush i.e. back to initial user-defined config, soft reset i.e. power cycle, and factory reset i.e. back to factory settings) OFF: turn the device off Wakeup: wakeup the device from sleep or off ModeLevel: if your device adheres to certain power levels, control them here # Middleware: Host devices: Each peripheral type i.e. usart, cmu, gpio, timer etc, should have the following functions: Init: initialize device or register RegDump: get all the info for the whole device and place them in a middleware data struct ConfigReg: alter the values for a single register QueryReg: query the values for a single register Data: data I/O functions for host and external devices Reset: reset the device (with options for settings flush i.e. back to initial user-defined config, soft reset i.e. power cycle, and factory reset i.e. back to factory settings) OFF: turn the device off Wakeup: wakeup the device from sleep or off ModeLevel: if your device adheres to certain power levels, control them here For the host device registers, these functions should be a simple set of generic functions that pass objects into a public interface layer and thus write to, read from, or clear host registers. (see example) External devices: There should be a single universal set of the same functions to cover all external devices. For external devices, the function should pass the host controllers communication function into the external device layer, which will then callback to the host function for communication. (see example) # Config Layer: Declare any instances of data / config structs, and include headers for relevant devices, inside compiler flags i.e. #ifdefs Initialize data structures with values as needed All pointer aliases should be assigned at this level All device-native interface functions should be assigned to the relevant object at this level (see MPI_host object members for details) It should be noted at this point that all struct member configuration should be initialized at struct instance declaration. Any values that require calculation should be in a lookup table, if possible. Further, any values that are desired to change at runtime should be considered for the GUI level, at which point we will be writing setters and getters which alter the struct values followed by a write cycle. # RTOS/Application layer This should be fairly self-explanatory. As per usual, RTOS API calls (or a super loop if you opt for that design) reside at this level. # A few words on data layers Data objects (peripherals) Each peripheral should have a struct typedef containing members that represent each register in that peripheral, in the order that each register appears in the register map (in the reference manual, this makes things super easy later on). Data objects (device modelling) Build a data object that makes sense for your project and set of devices. It should be generic and have the ability to be applied to any host controller that you wish to use Personally, I prefer a struct titled MPI_host, with some identifying information fields like model and revision, followed by a series of structs that house function pointers to the relevant interface functions, and an array of function pointers to various relevant data structs i.e. data_io, peripheral_config etc, finally a uin32_t bit field for identifying a single register that is to be configured (this last one may seem a little arbitrary, but it will make sense later).