Embedded Rust: Framework structure
There are already some embedded Rust projects such as tock-os or zync-rs. They all (AFAIK) took the path to a monolithic API in the sense that they provide everything as a single (potentially) heavy crate that includes all ports and their drivers.
A single dependency to all your projects can look appealing, but it has downsides that (IMHO) make it unworthy. It greatly impairs maintainability and slows down the release pace because it makes the crate heavier (in features and targets):
- Updating a driver for a single target or family requires a patch bump for the whole framework;
- Keeping things consistent when changing an API requires you to update ALL drivers, which can be a tedious task when supporting a wide range or targets;
- It is way too easy to intricate modules’ dependencies and end up with a tight knot of internal relations between modules. It goes against the KISS principle.
Splitting the framework in a “galaxy” of lightweight crates allows adoption of updates of the main APIs to be propagated in some sort of waterfall way. A team responsible for maintaining a certain set of targets can use semantic versioning to only update the release when it is ready. It adds latency between the main API & the target implementation updates but it prevents :
- highly demanded features to be stuck until all team do implement it ;
- code rot of abandoned targets in the framework crates.
This would also help :
- enforcing a mindful design of APIs and keep them clear between crates ;
- benefit from the semantic versioning of APIs ;
- organise communities around focus groups that wouldn’t be limited by slower/less active groups…
This diagram is here to give you a rough idea of what could be achieved. This would of course be extended by more crates dedicated to others tasks such has file system drivers, motor/sensor control (loops?)…
For example here is how things could be spread :
silica1 (api level) :
Defines generic traits that exposes features for common resources such as UART, SPI, I²C, ADC, DAC, CAN, Timers… It also reexport modules from other core crates (such as synchronization primitives etc) ;
Provides generic implementations for Mutexes, Semaphores and critical sections. It expects (at least) two external functions to be defined :
silica_cortexm(mcu core level) :
Implements Mutexes, Semaphores & critical sections’ internals.
This is also where is implemented all the os mechanisms are because they are generic to all cortex cores ;
Implements extra traits that cover cases that can be handled/accelerated by hardware specific features ;
silica_atsam4e(chip level) :
Implements drivers for all peripherals that are common to this family ;
Exports all symbols related to peripheral instances, gpios etc ;
silica_duet(module/board level) :
Exports a subsets of gpio and determine the configuration of most peripherals. It may also contain board specific configuration such as external clock frequency, gpio that are actually always output with a low active level etc.
silica_demo_blinky(application level) :
Implements the actual application. It may contain some code specific to a target that would be feature gated.
Rust’s Features are a nice & clean replacement for what used to be done with
#define in C.
They could be used to select a specific implementation of a given feature.
The most obvious use case that comes to my mind is a bootloader application requiring that ‘no-os’ is provided to get the smallest footprint possible while another app would require a full featured ‘RTOS’ with system calls, os aware synchronisation primitive implementations primitives. Both apps would depend on the same target crate (e.g.
silica_arduino_mega2560) but with slightly different implementations.